1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 package registry_test
16
17 import (
18 "fmt"
19 "io"
20 "log"
21 "net/http"
22 "net/http/httptest"
23 "net/url"
24 "strings"
25 "testing"
26
27 "github.com/google/go-containerregistry/pkg/registry"
28 v1 "github.com/google/go-containerregistry/pkg/v1"
29 )
30
31 const (
32 weirdIndex = `{
33 "manifests": [
34 {
35 "digest":"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
36 "mediaType":"application/vnd.oci.image.layer.nondistributable.v1.tar+gzip"
37 },{
38 "digest":"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
39 "mediaType":"application/xml"
40 },{
41 "digest":"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
42 "mediaType":"application/vnd.oci.image.manifest.v1+json"
43 }
44 ]
45 }`
46 )
47
48 func sha256String(s string) string {
49 h, _, _ := v1.SHA256(strings.NewReader(s))
50 return h.Hex
51 }
52
53 func TestCalls(t *testing.T) {
54 tcs := []struct {
55 Description string
56
57
58 URL string
59 Digests map[string]string
60 Manifests map[string]string
61 BlobStream map[string]string
62 RequestHeader map[string]string
63
64
65 Code int
66 Header map[string]string
67 Method string
68 Body string
69 Want string
70 }{
71 {
72 Description: "/v2 returns 200",
73 Method: "GET",
74 URL: "/v2",
75 Code: http.StatusOK,
76 Header: map[string]string{"Docker-Distribution-API-Version": "registry/2.0"},
77 },
78 {
79 Description: "/v2/ returns 200",
80 Method: "GET",
81 URL: "/v2/",
82 Code: http.StatusOK,
83 Header: map[string]string{"Docker-Distribution-API-Version": "registry/2.0"},
84 },
85 {
86 Description: "/v2/bad returns 404",
87 Method: "GET",
88 URL: "/v2/bad",
89 Code: http.StatusNotFound,
90 Header: map[string]string{"Docker-Distribution-API-Version": "registry/2.0"},
91 },
92 {
93 Description: "GET non existent blob",
94 Method: "GET",
95 URL: "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
96 Code: http.StatusNotFound,
97 },
98 {
99 Description: "HEAD non existent blob",
100 Method: "HEAD",
101 URL: "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
102 Code: http.StatusNotFound,
103 },
104 {
105 Description: "GET bad digest",
106 Method: "GET",
107 URL: "/v2/foo/blobs/sha256:asd",
108 Code: http.StatusBadRequest,
109 },
110 {
111 Description: "HEAD bad digest",
112 Method: "HEAD",
113 URL: "/v2/foo/blobs/sha256:asd",
114 Code: http.StatusBadRequest,
115 },
116 {
117 Description: "bad blob verb",
118 Method: "FOO",
119 URL: "/v2/foo/blobs/sha256:asd",
120 Code: http.StatusBadRequest,
121 },
122 {
123 Description: "GET containerless blob",
124 Digests: map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
125 Method: "GET",
126 URL: "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
127 Code: http.StatusOK,
128 Header: map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
129 Want: "foo",
130 },
131 {
132 Description: "GET blob",
133 Digests: map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
134 Method: "GET",
135 URL: "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
136 Code: http.StatusOK,
137 Header: map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
138 Want: "foo",
139 },
140 {
141 Description: "HEAD blob",
142 Digests: map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
143 Method: "HEAD",
144 URL: "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
145 Code: http.StatusOK,
146 Header: map[string]string{
147 "Content-Length": "3",
148 "Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
149 },
150 },
151 {
152 Description: "DELETE blob",
153 Digests: map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
154 Method: "DELETE",
155 URL: "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
156 Code: http.StatusAccepted,
157 },
158 {
159 Description: "blob url with no container",
160 Method: "GET",
161 URL: "/v2/blobs/sha256:asd",
162 Code: http.StatusBadRequest,
163 },
164 {
165 Description: "uploadurl",
166 Method: "POST",
167 URL: "/v2/foo/blobs/uploads",
168 Code: http.StatusAccepted,
169 Header: map[string]string{"Range": "0-0"},
170 },
171 {
172 Description: "uploadurl",
173 Method: "POST",
174 URL: "/v2/foo/blobs/uploads/",
175 Code: http.StatusAccepted,
176 Header: map[string]string{"Range": "0-0"},
177 },
178 {
179 Description: "upload put missing digest",
180 Method: "PUT",
181 URL: "/v2/foo/blobs/uploads/1",
182 Code: http.StatusBadRequest,
183 },
184 {
185 Description: "monolithic upload good digest",
186 Method: "POST",
187 URL: "/v2/foo/blobs/uploads?digest=sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
188 Code: http.StatusCreated,
189 Body: "foo",
190 Header: map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
191 },
192 {
193 Description: "monolithic upload bad digest",
194 Method: "POST",
195 URL: "/v2/foo/blobs/uploads?digest=sha256:fake",
196 Code: http.StatusBadRequest,
197 Body: "foo",
198 },
199 {
200 Description: "upload good digest",
201 Method: "PUT",
202 URL: "/v2/foo/blobs/uploads/1?digest=sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
203 Code: http.StatusCreated,
204 Body: "foo",
205 Header: map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
206 },
207 {
208 Description: "upload bad digest",
209 Method: "PUT",
210 URL: "/v2/foo/blobs/uploads/1?digest=sha256:baddigest",
211 Code: http.StatusBadRequest,
212 Body: "foo",
213 },
214 {
215 Description: "stream upload",
216 Method: "PATCH",
217 URL: "/v2/foo/blobs/uploads/1",
218 Code: http.StatusNoContent,
219 Body: "foo",
220 Header: map[string]string{
221 "Range": "0-2",
222 "Location": "/v2/foo/blobs/uploads/1",
223 },
224 },
225 {
226 Description: "stream duplicate upload",
227 Method: "PATCH",
228 URL: "/v2/foo/blobs/uploads/1",
229 Code: http.StatusBadRequest,
230 Body: "foo",
231 BlobStream: map[string]string{"1": "foo"},
232 },
233 {
234 Description: "stream finish upload",
235 Method: "PUT",
236 URL: "/v2/foo/blobs/uploads/1?digest=sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
237 BlobStream: map[string]string{"1": "foo"},
238 Code: http.StatusCreated,
239 Header: map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
240 },
241 {
242 Description: "get missing manifest",
243 Method: "GET",
244 URL: "/v2/foo/manifests/latest",
245 Code: http.StatusNotFound,
246 },
247 {
248 Description: "head missing manifest",
249 Method: "HEAD",
250 URL: "/v2/foo/manifests/latest",
251 Code: http.StatusNotFound,
252 },
253 {
254 Description: "get missing manifest good container",
255 Manifests: map[string]string{"foo/manifests/latest": "foo"},
256 Method: "GET",
257 URL: "/v2/foo/manifests/bar",
258 Code: http.StatusNotFound,
259 },
260 {
261 Description: "head missing manifest good container",
262 Manifests: map[string]string{"foo/manifests/latest": "foo"},
263 Method: "HEAD",
264 URL: "/v2/foo/manifests/bar",
265 Code: http.StatusNotFound,
266 },
267 {
268 Description: "get manifest by tag",
269 Manifests: map[string]string{"foo/manifests/latest": "foo"},
270 Method: "GET",
271 URL: "/v2/foo/manifests/latest",
272 Code: http.StatusOK,
273 Want: "foo",
274 },
275 {
276 Description: "get manifest by digest",
277 Manifests: map[string]string{"foo/manifests/latest": "foo"},
278 Method: "GET",
279 URL: "/v2/foo/manifests/sha256:" + sha256String("foo"),
280 Code: http.StatusOK,
281 Want: "foo",
282 },
283 {
284 Description: "head manifest",
285 Manifests: map[string]string{"foo/manifests/latest": "foo"},
286 Method: "HEAD",
287 URL: "/v2/foo/manifests/latest",
288 Code: http.StatusOK,
289 },
290 {
291 Description: "create manifest",
292 Method: "PUT",
293 URL: "/v2/foo/manifests/latest",
294 Code: http.StatusCreated,
295 Body: "foo",
296 },
297 {
298 Description: "create index",
299 Method: "PUT",
300 URL: "/v2/foo/manifests/latest",
301 Code: http.StatusCreated,
302 Body: weirdIndex,
303 RequestHeader: map[string]string{
304 "Content-Type": "application/vnd.oci.image.index.v1+json",
305 },
306 Manifests: map[string]string{"foo/manifests/image": "foo"},
307 },
308 {
309 Description: "create index missing child",
310 Method: "PUT",
311 URL: "/v2/foo/manifests/latest",
312 Code: http.StatusNotFound,
313 Body: weirdIndex,
314 RequestHeader: map[string]string{
315 "Content-Type": "application/vnd.oci.image.index.v1+json",
316 },
317 },
318 {
319 Description: "bad index body",
320 Method: "PUT",
321 URL: "/v2/foo/manifests/latest",
322 Code: http.StatusBadRequest,
323 Body: "foo",
324 RequestHeader: map[string]string{
325 "Content-Type": "application/vnd.oci.image.index.v1+json",
326 },
327 },
328 {
329 Description: "bad manifest method",
330 Method: "BAR",
331 URL: "/v2/foo/manifests/latest",
332 Code: http.StatusBadRequest,
333 },
334 {
335 Description: "Chunk upload start",
336 Method: "PATCH",
337 URL: "/v2/foo/blobs/uploads/1",
338 RequestHeader: map[string]string{"Content-Range": "0-3"},
339 Code: http.StatusNoContent,
340 Body: "foo",
341 Header: map[string]string{
342 "Range": "0-2",
343 "Location": "/v2/foo/blobs/uploads/1",
344 },
345 },
346 {
347 Description: "Chunk upload bad content range",
348 Method: "PATCH",
349 URL: "/v2/foo/blobs/uploads/1",
350 RequestHeader: map[string]string{"Content-Range": "0-bar"},
351 Code: http.StatusRequestedRangeNotSatisfiable,
352 Body: "foo",
353 },
354 {
355 Description: "Chunk upload overlaps previous data",
356 Method: "PATCH",
357 URL: "/v2/foo/blobs/uploads/1",
358 BlobStream: map[string]string{"1": "foo"},
359 RequestHeader: map[string]string{"Content-Range": "2-5"},
360 Code: http.StatusRequestedRangeNotSatisfiable,
361 Body: "bar",
362 },
363 {
364 Description: "Chunk upload after previous data",
365 Method: "PATCH",
366 URL: "/v2/foo/blobs/uploads/1",
367 BlobStream: map[string]string{"1": "foo"},
368 RequestHeader: map[string]string{"Content-Range": "3-6"},
369 Code: http.StatusNoContent,
370 Body: "bar",
371 Header: map[string]string{
372 "Range": "0-5",
373 "Location": "/v2/foo/blobs/uploads/1",
374 },
375 },
376 {
377 Description: "DELETE Unknown name",
378 Method: "DELETE",
379 URL: "/v2/test/honk/manifests/latest",
380 Code: http.StatusNotFound,
381 },
382 {
383 Description: "DELETE Unknown manifest",
384 Manifests: map[string]string{"honk/manifests/latest": "honk"},
385 Method: "DELETE",
386 URL: "/v2/honk/manifests/tag-honk",
387 Code: http.StatusNotFound,
388 },
389 {
390 Description: "DELETE existing manifest",
391 Manifests: map[string]string{"foo/manifests/latest": "foo"},
392 Method: "DELETE",
393 URL: "/v2/foo/manifests/latest",
394 Code: http.StatusAccepted,
395 },
396 {
397 Description: "DELETE existing manifest by digest",
398 Manifests: map[string]string{"foo/manifests/latest": "foo"},
399 Method: "DELETE",
400 URL: "/v2/foo/manifests/sha256:" + sha256String("foo"),
401 Code: http.StatusAccepted,
402 },
403 {
404 Description: "list tags",
405 Manifests: map[string]string{"foo/manifests/latest": "foo", "foo/manifests/tag1": "foo"},
406 Method: "GET",
407 URL: "/v2/foo/tags/list?n=1000",
408 Code: http.StatusOK,
409 Want: `{"name":"foo","tags":["latest","tag1"]}`,
410 },
411 {
412 Description: "limit tags",
413 Manifests: map[string]string{"foo/manifests/latest": "foo", "foo/manifests/tag1": "foo"},
414 Method: "GET",
415 URL: "/v2/foo/tags/list?n=1",
416 Code: http.StatusOK,
417 Want: `{"name":"foo","tags":["latest"]}`,
418 },
419 {
420 Description: "offset tags",
421 Manifests: map[string]string{"foo/manifests/latest": "foo", "foo/manifests/tag1": "foo"},
422 Method: "GET",
423 URL: "/v2/foo/tags/list?last=latest",
424 Code: http.StatusOK,
425 Want: `{"name":"foo","tags":["tag1"]}`,
426 },
427 {
428 Description: "list non existing tags",
429 Method: "GET",
430 URL: "/v2/foo/tags/list?n=1000",
431 Code: http.StatusNotFound,
432 },
433 {
434 Description: "list repos",
435 Manifests: map[string]string{"foo/manifests/latest": "foo", "bar/manifests/latest": "bar"},
436 Method: "GET",
437 URL: "/v2/_catalog?n=1000",
438 Code: http.StatusOK,
439 },
440 {
441 Description: "fetch references",
442 Method: "GET",
443 URL: "/v2/foo/referrers/sha256:" + sha256String("foo"),
444 Code: http.StatusOK,
445 Manifests: map[string]string{
446 "foo/manifests/image": "foo",
447 "foo/manifests/points-to-image": "{\"subject\": {\"digest\": \"sha256:" + sha256String("foo") + "\"}}",
448 },
449 Header: map[string]string{
450 "Content-Type": "application/vnd.oci.image.index.v1+json",
451 },
452 },
453 {
454 Description: "fetch references, subject pointing elsewhere",
455 Method: "GET",
456 URL: "/v2/foo/referrers/sha256:" + sha256String("foo"),
457 Code: http.StatusOK,
458 Manifests: map[string]string{
459 "foo/manifests/image": "foo",
460 "foo/manifests/points-to-image": "{\"subject\": {\"digest\": \"sha256:" + sha256String("nonexistant") + "\"}}",
461 },
462 Header: map[string]string{
463 "Content-Type": "application/vnd.oci.image.index.v1+json",
464 },
465 },
466 {
467 Description: "fetch references, no results",
468 Method: "GET",
469 URL: "/v2/foo/referrers/sha256:" + sha256String("foo"),
470 Code: http.StatusOK,
471 Manifests: map[string]string{
472 "foo/manifests/image": "foo",
473 },
474 Header: map[string]string{
475 "Content-Type": "application/vnd.oci.image.index.v1+json",
476 },
477 },
478 {
479 Description: "fetch references, missing repo",
480 Method: "GET",
481 URL: "/v2/does-not-exist/referrers/sha256:" + sha256String("foo"),
482 Code: http.StatusNotFound,
483 },
484 {
485 Description: "fetch references, bad target (tag vs. digest)",
486 Method: "GET",
487 URL: "/v2/foo/referrers/latest",
488 Code: http.StatusBadRequest,
489 },
490 {
491 Description: "fetch references, bad method",
492 Method: "POST",
493 URL: "/v2/foo/referrers/sha256:" + sha256String("foo"),
494 Code: http.StatusBadRequest,
495 },
496 }
497
498 for _, tc := range tcs {
499
500 var logger *log.Logger
501 testf := func(t *testing.T) {
502
503 opts := []registry.Option{registry.WithReferrersSupport(true)}
504 if logger != nil {
505 opts = append(opts, registry.Logger(logger))
506 }
507 r := registry.New(opts...)
508 s := httptest.NewServer(r)
509 defer s.Close()
510
511 for manifest, contents := range tc.Manifests {
512 u, err := url.Parse(s.URL + "/v2/" + manifest)
513 if err != nil {
514 t.Fatalf("Error parsing %q: %v", s.URL+"/v2", err)
515 }
516 req := &http.Request{
517 Method: "PUT",
518 URL: u,
519 Body: io.NopCloser(strings.NewReader(contents)),
520 }
521 t.Log(req.Method, req.URL)
522 resp, err := s.Client().Do(req)
523 if err != nil {
524 t.Fatalf("Error uploading manifest: %v", err)
525 }
526 if resp.StatusCode != http.StatusCreated {
527 body, _ := io.ReadAll(resp.Body)
528 t.Fatalf("Error uploading manifest got status: %d %s", resp.StatusCode, body)
529 }
530 t.Logf("created manifest with digest %v", resp.Header.Get("Docker-Content-Digest"))
531 }
532
533 for digest, contents := range tc.Digests {
534 u, err := url.Parse(fmt.Sprintf("%s/v2/foo/blobs/uploads/1?digest=%s", s.URL, digest))
535 if err != nil {
536 t.Fatalf("Error parsing %q: %v", s.URL+tc.URL, err)
537 }
538 req := &http.Request{
539 Method: "PUT",
540 URL: u,
541 Body: io.NopCloser(strings.NewReader(contents)),
542 }
543 t.Log(req.Method, req.URL)
544 resp, err := s.Client().Do(req)
545 if err != nil {
546 t.Fatalf("Error uploading digest: %v", err)
547 }
548 if resp.StatusCode != http.StatusCreated {
549 body, _ := io.ReadAll(resp.Body)
550 t.Fatalf("Error uploading digest got status: %d %s", resp.StatusCode, body)
551 }
552 }
553
554 for upload, contents := range tc.BlobStream {
555 u, err := url.Parse(fmt.Sprintf("%s/v2/foo/blobs/uploads/%s", s.URL, upload))
556 if err != nil {
557 t.Fatalf("Error parsing %q: %v", s.URL+tc.URL, err)
558 }
559 req := &http.Request{
560 Method: "PATCH",
561 URL: u,
562 Body: io.NopCloser(strings.NewReader(contents)),
563 }
564 t.Log(req.Method, req.URL)
565 resp, err := s.Client().Do(req)
566 if err != nil {
567 t.Fatalf("Error streaming blob: %v", err)
568 }
569 if resp.StatusCode != http.StatusNoContent {
570 body, _ := io.ReadAll(resp.Body)
571 t.Fatalf("Error streaming blob: %d %s", resp.StatusCode, body)
572 }
573
574 }
575
576 u, err := url.Parse(s.URL + tc.URL)
577 if err != nil {
578 t.Fatalf("Error parsing %q: %v", s.URL+tc.URL, err)
579 }
580 req := &http.Request{
581 Method: tc.Method,
582 URL: u,
583 Body: io.NopCloser(strings.NewReader(tc.Body)),
584 Header: map[string][]string{},
585 }
586 for k, v := range tc.RequestHeader {
587 req.Header.Set(k, v)
588 }
589 t.Log(req.Method, req.URL)
590 resp, err := s.Client().Do(req)
591 if err != nil {
592 t.Fatalf("Error getting %q: %v", tc.URL, err)
593 }
594 defer resp.Body.Close()
595 body, err := io.ReadAll(resp.Body)
596 if err != nil {
597 t.Errorf("Reading response body: %v", err)
598 }
599 if resp.StatusCode != tc.Code {
600 t.Errorf("Incorrect status code, got %d, want %d; body: %s", resp.StatusCode, tc.Code, body)
601 }
602
603 for k, v := range tc.Header {
604 r := resp.Header.Get(k)
605 if r != v {
606 t.Errorf("Incorrect header %q received, got %q, want %q", k, r, v)
607 }
608 }
609
610 if tc.Want != "" && string(body) != tc.Want {
611 t.Errorf("Incorrect response body, got %q, want %q", body, tc.Want)
612 }
613 }
614 t.Run(tc.Description, testf)
615 logger = log.New(io.Discard, "", log.Ldate)
616 t.Run(tc.Description+" - custom log", testf)
617 }
618 }
619
View as plain text