1package registrypush_test
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "fmt"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "net/url"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/google/go-containerregistry/pkg/v1"
18 "github.com/google/go-containerregistry/pkg/v1/random"
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21
22 "github.com/bnema/gordon/pkg/registrypush"
23)
24
25func TestNew_defaults(t *testing.T) {
26 p := registrypush.New()
27 assert.NotNil(t, p)
28}
29
30func TestNew_withChunkSize(t *testing.T) {
31 p := registrypush.New(registrypush.WithChunkSize(10 * 1024 * 1024))
32 assert.NotNil(t, p)
33}
34
35func TestPusher_UploadBlob(t *testing.T) {
36 testCases := []struct {
37 name string
38 blobSize int
39 chunkSize int64
40 headStatus int
41 expectedPatchSizes []int
42 expectedHeadCalls int
43 expectedPostCalls int
44 expectedPutCalls int
45 }{
46 {
47 name: "small blob under chunk size",
48 blobSize: 100,
49 chunkSize: 1024,
50 headStatus: http.StatusNotFound,
51 expectedPatchSizes: []int{100},
52 expectedHeadCalls: 1,
53 expectedPostCalls: 1,
54 expectedPutCalls: 1,
55 },
56 {
57 name: "exact chunk size",
58 blobSize: 1024,
59 chunkSize: 1024,
60 headStatus: http.StatusNotFound,
61 expectedPatchSizes: []int{1024},
62 expectedHeadCalls: 1,
63 expectedPostCalls: 1,
64 expectedPutCalls: 1,
65 },
66 {
67 name: "large blob across multiple chunks",
68 blobSize: 3072,
69 chunkSize: 1024,
70 headStatus: http.StatusNotFound,
71 expectedPatchSizes: []int{1024, 1024, 1024},
72 expectedHeadCalls: 1,
73 expectedPostCalls: 1,
74 expectedPutCalls: 1,
75 },
76 {
77 name: "blob already exists",
78 blobSize: 512,
79 chunkSize: 1024,
80 headStatus: http.StatusOK,
81 expectedPatchSizes: nil,
82 expectedHeadCalls: 1,
83 expectedPostCalls: 0,
84 expectedPutCalls: 0,
85 },
86 {
87 name: "auth header present on every request",
88 blobSize: 1536,
89 chunkSize: 1024,
90 headStatus: http.StatusNotFound,
91 expectedPatchSizes: []int{1024, 512},
92 expectedHeadCalls: 1,
93 expectedPostCalls: 1,
94 expectedPutCalls: 1,
95 },
96 }
97
98 for _, tc := range testCases {
99 t.Run(tc.name, func(t *testing.T) {
100 const authHeader = "Bearer test-token"
101 const repo = "demo/app"
102 const digest = "sha256:testdigest"
103
104 blob := bytes.Repeat([]byte("a"), tc.blobSize)
105 serverState := newFakeBlobUploadRegistry(t, authHeader, digest, tc.headStatus)
106 server := httptest.NewServer(serverState.handler())
107 defer server.Close()
108
109 p := registrypush.New(
110 registrypush.WithChunkSize(tc.chunkSize),
111 registrypush.WithTransport(server.Client().Transport),
112 )
113
114 err := p.UploadBlob(context.Background(), server.URL, repo, digest, int64(len(blob)), bytes.NewReader(blob), authHeader)
115 require.NoError(t, err)
116
117 assert.Equal(t, tc.expectedHeadCalls, serverState.headCalls)
118 assert.Equal(t, tc.expectedPostCalls, serverState.postCalls)
119 assert.Equal(t, tc.expectedPutCalls, serverState.putCalls)
120 assert.Equal(t, tc.expectedPatchSizes, serverState.patchSizes)
121 assert.Equal(t, tc.expectedPatchSizes, serverState.rangeSizes)
122 assert.Equal(t, serverState.requestCount, serverState.authCount)
123 })
124 }
125}
126
127func TestPusher_PlainHTTP(t *testing.T) {
128 img, err := random.Image(1024, 1)
129 require.NoError(t, err)
130
131 server := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handler())
132 defer server.Close()
133 serverURL, err := url.Parse(server.URL)
134 require.NoError(t, err)
135
136 p := registrypush.New(
137 registrypush.WithPlainHTTP(true),
138 registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
139 return img, nil
140 }),
141 )
142 require.NoError(t, p.Push(context.Background(), serverURL.Host+"/demo/app:v1"))
143}
144
145func TestPusher_Push(t *testing.T) {
146 testCases := []struct {
147 name string
148 imageSize int64
149 layerCount int64
150 existingBlobDigests func(t *testing.T, img v1.Image) map[string]bool
151 expectedUploadedBlobCount int
152 expectedManifestMediaTypes []string
153 }{
154 {
155 name: "single-layer image uploads config layer and manifest",
156 imageSize: 1024,
157 layerCount: 1,
158 existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} },
159 expectedUploadedBlobCount: 2,
160 expectedManifestMediaTypes: []string{
161 "application/vnd.docker.distribution.manifest.v2+json",
162 typesOCIManifestSchema1(),
163 },
164 },
165 {
166 name: "multi-layer image uploads all blobs and manifest",
167 imageSize: 1024,
168 layerCount: 2,
169 existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} },
170 expectedUploadedBlobCount: 3,
171 expectedManifestMediaTypes: []string{
172 "application/vnd.docker.distribution.manifest.v2+json",
173 typesOCIManifestSchema1(),
174 },
175 },
176 {
177 name: "existing layer is skipped while others upload",
178 imageSize: 1024,
179 layerCount: 2,
180 existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool {
181 t.Helper()
182 layers, err := img.Layers()
183 require.NoError(t, err)
184 digest, err := layers[0].Digest()
185 require.NoError(t, err)
186 return map[string]bool{digest.String(): true}
187 },
188 expectedUploadedBlobCount: 2,
189 expectedManifestMediaTypes: []string{
190 "application/vnd.docker.distribution.manifest.v2+json",
191 typesOCIManifestSchema1(),
192 },
193 },
194 }
195
196 for _, tc := range testCases {
197 t.Run(tc.name, func(t *testing.T) {
198 img, err := random.Image(tc.imageSize, tc.layerCount)
199 require.NoError(t, err)
200
201 serverState := newFakePushRegistry(t, img, tc.existingBlobDigests(t, img))
202 server := httptest.NewServer(serverState.handler())
203 defer server.Close()
204
205 serverURL, err := url.Parse(server.URL)
206 require.NoError(t, err)
207 ref := serverURL.Host + "/demo/app:v1"
208
209 p := registrypush.New(
210 registrypush.WithTransport(server.Client().Transport),
211 registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
212 return img, nil
213 }),
214 )
215
216 err = p.Push(context.Background(), ref)
217 require.NoError(t, err)
218
219 expectedDigests := make([]string, 0, len(serverState.expectedBlobs))
220 for digest := range serverState.expectedBlobs {
221 expectedDigests = append(expectedDigests, digest)
222 assert.Contains(t, serverState.headChecks, digest)
223 }
224 assert.Len(t, serverState.headChecks, len(expectedDigests))
225
226 assert.Len(t, serverState.uploadedBlobs, tc.expectedUploadedBlobCount)
227 for digest, content := range serverState.uploadedBlobs {
228 assert.Equal(t, serverState.expectedBlobs[digest], content)
229 }
230
231 manifestPut, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
232 require.True(t, ok)
233 assert.Contains(t, tc.expectedManifestMediaTypes, manifestPut.contentType)
234 assert.Equal(t, serverState.expectedManifest, manifestPut.body)
235 })
236 }
237}
238
239func TestPusher_Push_InvokesCleanup(t *testing.T) {
240 var cleanupCount int
241 img, err := random.Image(1024, 1)
242 require.NoError(t, err)
243
244 registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handler())
245 defer registry.Close()
246
247 p := registrypush.New(
248 registrypush.WithChunkSize(1024),
249 registrypush.WithImageSource(func(ctx context.Context, ref string) (v1.Image, error) {
250 return img, nil
251 }),
252 registrypush.WithImageCleanup(func() {
253 cleanupCount++
254 }),
255 )
256
257 registryHost := strings.TrimPrefix(registry.URL, "http://")
258 err = p.Push(context.Background(), registryHost+"/demo/app:v1")
259 require.NoError(t, err)
260 assert.Equal(t, 1, cleanupCount, "cleanup callback should have been invoked after first push")
261
262 err = p.Push(context.Background(), registryHost+"/demo/app:v1")
263 require.NoError(t, err)
264 assert.Equal(t, 2, cleanupCount, "cleanup callback should have been invoked after second push")
265}
266
267func TestPusher_WithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
268 t.Parallel()
269
270 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
271 if r.Method == http.MethodHead {
272 w.WriteHeader(http.StatusNotFound)
273 return
274 }
275 if r.Method == http.MethodPost {
276 w.Header().Set("Location", "/v2/repo/blobs/uploads/test-uuid")
277 w.WriteHeader(http.StatusAccepted)
278 return
279 }
280 if r.Method == http.MethodPatch {
281 w.Header().Set("Location", "/v2/repo/blobs/uploads/test-uuid")
282 w.WriteHeader(http.StatusAccepted)
283 return
284 }
285 if r.Method == http.MethodPut {
286 w.WriteHeader(http.StatusCreated)
287 return
288 }
289 }))
290 defer srv.Close()
291
292 ctx := context.Background()
293 content := []byte("test-blob-data")
294 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(content))
295
296 pusherSecure := registrypush.New(registrypush.WithChunkSize(1024))
297 err := pusherSecure.UploadBlob(ctx, srv.URL, "repo", digest, int64(len(content)), bytes.NewReader(content), "")
298 require.Error(t, err)
299 assert.Contains(t, err.Error(), "certificate")
300
301 pusherInsecure := registrypush.New(registrypush.WithChunkSize(1024), registrypush.WithInsecureTLS(true))
302 err = pusherInsecure.UploadBlob(ctx, srv.URL, "repo", digest, int64(len(content)), bytes.NewReader(content), "")
303 require.NoError(t, err)
304}
305
306func TestPusher_WithTimeout_RespectsDeadline(t *testing.T) {
307 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
308 <-r.Context().Done()
309 }))
310 defer srv.Close()
311
312 p := registrypush.New(
313 registrypush.WithChunkSize(1024),
314 registrypush.WithTimeout(100*time.Millisecond),
315 )
316
317 ctx := context.Background()
318 err := p.UploadBlob(ctx, srv.URL, "test/repo", "sha256:abc123", 10, strings.NewReader("0123456789"), "")
319
320 require.Error(t, err)
321}
322
323func TestUploadBlob_RejectsCrossOriginRedirect(t *testing.T) {
324 attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
325 t.Errorf("request leaked to attacker: %s %s (Authorization: %s)", r.Method, r.URL.Path, r.Header.Get("Authorization"))
326 w.WriteHeader(http.StatusOK)
327 }))
328 defer attacker.Close()
329
330 registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
331 switch r.Method {
332 case http.MethodHead:
333 w.WriteHeader(http.StatusNotFound)
334 case http.MethodPost:
335 w.Header().Set("Location", attacker.URL+"/v2/repo/blobs/uploads/evil")
336 w.WriteHeader(http.StatusAccepted)
337 default:
338 w.WriteHeader(http.StatusInternalServerError)
339 }
340 }))
341 defer registry.Close()
342
343 p := registrypush.New(registrypush.WithChunkSize(1024))
344 ctx := context.Background()
345 err := p.UploadBlob(ctx, registry.URL, "test/repo", "sha256:abc", 5, strings.NewReader("hello"), "Basic secret")
346
347 require.Error(t, err)
348 assert.Contains(t, err.Error(), "cross-origin")
349}
350
351// authRecordingTransport records Authorization headers per request URL.
352type authRecordingTransport struct {
353 inner http.RoundTripper
354 records []authRecord
355}
356
357type authRecord struct {
358 URL string
359 Auth string
360}
361
362func (t *authRecordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
363 t.records = append(t.records, authRecord{
364 URL: req.URL.String(),
365 Auth: req.Header.Get("Authorization"),
366 })
367 return t.inner.RoundTrip(req)
368}
369
370func TestUploadBlob_AuthHeaderOnlySentToSameOrigin(t *testing.T) {
371 registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
372 switch r.Method {
373 case http.MethodHead:
374 w.WriteHeader(http.StatusNotFound)
375 case http.MethodPost:
376 w.Header().Set("Location", "/v2/repo/blobs/uploads/abc?chunk=0")
377 w.WriteHeader(http.StatusAccepted)
378 case http.MethodPatch:
379 w.Header().Set("Location", "/v2/repo/blobs/uploads/abc?chunk=1")
380 w.WriteHeader(http.StatusAccepted)
381 case http.MethodPut:
382 w.WriteHeader(http.StatusCreated)
383 default:
384 w.WriteHeader(http.StatusInternalServerError)
385 }
386 }))
387 defer registry.Close()
388
389 recorder := &authRecordingTransport{inner: http.DefaultTransport}
390 p := registrypush.New(
391 registrypush.WithChunkSize(1024),
392 registrypush.WithTransport(recorder),
393 )
394 ctx := context.Background()
395 secret := "Basic dXNlcjpwYXNz"
396 err := p.UploadBlob(ctx, registry.URL, "test/repo", "sha256:abc", 5, strings.NewReader("hello"), secret)
397
398 require.NoError(t, err)
399 require.NotEmpty(t, recorder.records)
400
401 registryURL, _ := url.Parse(registry.URL)
402 for _, rec := range recorder.records {
403 reqURL, _ := url.Parse(rec.URL)
404 if reqURL.Host == registryURL.Host {
405 assert.Equal(t, secret, rec.Auth, "same-origin request to %s should have auth", rec.URL)
406 } else {
407 assert.Empty(t, rec.Auth, "cross-origin request to %s should NOT have auth", rec.URL)
408 }
409 }
410}
411
412type fakeBlobUploadRegistry struct {
413 t *testing.T
414 expectedAuth string
415 expectedDigest string
416 headStatus int
417 patchSizes []int
418 rangeSizes []int
419 headCalls int
420 postCalls int
421 putCalls int
422 requestCount int
423 authCount int
424 nextOffset int
425 location string
426 uuid string
427}
428
429func newFakeBlobUploadRegistry(t *testing.T, expectedAuth, expectedDigest string, headStatus int) *fakeBlobUploadRegistry {
430 t.Helper()
431 return &fakeBlobUploadRegistry{
432 t: t,
433 expectedAuth: expectedAuth,
434 expectedDigest: expectedDigest,
435 headStatus: headStatus,
436 location: "/upload/session-1",
437 uuid: "session-1",
438 }
439}
440
441func (f *fakeBlobUploadRegistry) handler() http.Handler {
442 f.t.Helper()
443 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
444 f.requestCount++
445 if r.Header.Get("Authorization") == f.expectedAuth {
446 f.authCount++
447 }
448
449 switch {
450 case r.Method == http.MethodHead && strings.Contains(r.URL.Path, "/blobs/"):
451 f.headCalls++
452 w.WriteHeader(f.headStatus)
453 case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"):
454 f.postCalls++
455 w.Header().Set("Location", f.location)
456 w.Header().Set("Docker-Upload-UUID", f.uuid)
457 w.Header().Set("Range", rangeHeaderValue(0, f.nextOffset-1))
458 w.WriteHeader(http.StatusAccepted)
459 case r.Method == http.MethodPatch && r.URL.Path == f.location:
460 f.handlePatch(w, r)
461 case r.Method == http.MethodPut && r.URL.Path == f.location:
462 f.putCalls++
463 assert.Equal(f.t, f.expectedDigest, r.URL.Query().Get("digest"))
464 body, err := io.ReadAll(r.Body)
465 require.NoError(f.t, err)
466 assert.Len(f.t, body, 0)
467 w.Header().Set("Location", f.location)
468 w.Header().Set("Docker-Content-Digest", f.expectedDigest)
469 w.WriteHeader(http.StatusCreated)
470 default:
471 f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
472 }
473 })
474}
475
476func (f *fakeBlobUploadRegistry) handlePatch(w http.ResponseWriter, r *http.Request) {
477 body, err := io.ReadAll(r.Body)
478 require.NoError(f.t, err)
479
480 start, end := parseContentRange(f.t, r.Header.Get("Content-Range"))
481 assert.Equal(f.t, f.nextOffset, start)
482 assert.Equal(f.t, len(body), end-start+1)
483
484 contentLength, err := strconv.Atoi(r.Header.Get("Content-Length"))
485 require.NoError(f.t, err)
486 assert.Equal(f.t, len(body), contentLength)
487 assert.Equal(f.t, "application/octet-stream", r.Header.Get("Content-Type"))
488
489 f.patchSizes = append(f.patchSizes, len(body))
490 f.rangeSizes = append(f.rangeSizes, end-start+1)
491 f.nextOffset = end + 1
492
493 w.Header().Set("Location", f.location)
494 w.Header().Set("Docker-Upload-UUID", f.uuid)
495 w.Header().Set("Range", rangeHeaderValue(0, end))
496 w.WriteHeader(http.StatusAccepted)
497}
498
499func parseContentRange(t *testing.T, value string) (int, int) {
500 t.Helper()
501 parts := strings.Split(value, "-")
502 require.Len(t, parts, 2)
503
504 start, err := strconv.Atoi(parts[0])
505 require.NoError(t, err)
506
507 end, err := strconv.Atoi(parts[1])
508 require.NoError(t, err)
509
510 return start, end
511}
512
513func rangeHeaderValue(start, end int) string {
514 if end < start {
515 return ""
516 }
517 return fmt.Sprintf("%d-%d", start, end)
518}
519
520type fakePushRegistry struct {
521 t *testing.T
522 existingBlobs map[string]bool
523 uploadedBlobs map[string][]byte
524 manifestPuts map[string]fakeManifestPut
525 uploads map[string]*fakeUploadSession
526 nextUploadID int
527 headChecks []string
528 expectedBlobs map[string][]byte
529 expectedRepo string
530 expectedTag string
531 expectedMT string
532 expectedManifest []byte
533}
534
535type fakeUploadSession struct {
536 repo string
537 data []byte
538}
539
540type fakeManifestPut struct {
541 contentType string
542 body []byte
543}
544
545func newFakePushRegistry(t *testing.T, img v1.Image, existingBlobs map[string]bool) *fakePushRegistry {
546 t.Helper()
547
548 layers, err := img.Layers()
549 require.NoError(t, err)
550
551 expectedBlobs := make(map[string][]byte, len(layers)+1)
552 for _, layer := range layers {
553 digest, err := layer.Digest()
554 require.NoError(t, err)
555 rc, err := layer.Compressed()
556 require.NoError(t, err)
557 content, err := io.ReadAll(rc)
558 require.NoError(t, err)
559 require.NoError(t, rc.Close())
560 expectedBlobs[digest.String()] = content
561 }
562
563 config, err := img.RawConfigFile()
564 require.NoError(t, err)
565 configDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(config))
566 expectedBlobs[configDigest] = config
567
568 manifest, err := img.RawManifest()
569 require.NoError(t, err)
570 mediaType, err := img.MediaType()
571 require.NoError(t, err)
572
573 return &fakePushRegistry{
574 t: t,
575 existingBlobs: existingBlobs,
576 uploadedBlobs: make(map[string][]byte),
577 manifestPuts: make(map[string]fakeManifestPut),
578 uploads: make(map[string]*fakeUploadSession),
579 expectedBlobs: expectedBlobs,
580 expectedRepo: "demo/app",
581 expectedTag: "v1",
582 expectedMT: string(mediaType),
583 expectedManifest: manifest,
584 }
585}
586
587func (f *fakePushRegistry) handler() http.Handler {
588 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
589 switch {
590 case r.Method == http.MethodHead && strings.HasPrefix(r.URL.Path, "/v2/") && strings.Contains(r.URL.Path, "/blobs/"):
591 f.handleBlobHead(w, r)
592 case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"):
593 f.handleBlobStart(w, r)
594 case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/blobs/uploads/"):
595 f.handleBlobPatch(w, r)
596 case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/blobs/uploads/"):
597 f.handleBlobFinalize(w, r)
598 case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/manifests/"):
599 f.handleManifestPut(w, r)
600 default:
601 f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
602 }
603 })
604}
605
606func (f *fakePushRegistry) handleBlobHead(w http.ResponseWriter, r *http.Request) {
607 digest := pathTail(r.URL.Path)
608 f.headChecks = append(f.headChecks, digest)
609 if f.existingBlobs[digest] {
610 w.WriteHeader(http.StatusOK)
611 return
612 }
613 w.WriteHeader(http.StatusNotFound)
614}
615
616func (f *fakePushRegistry) handleBlobStart(w http.ResponseWriter, r *http.Request) {
617 repo := pathSegmentBetween(r.URL.Path, "/v2/", "/blobs/uploads/")
618 assert.Equal(f.t, f.expectedRepo, repo)
619 f.nextUploadID++
620 uploadPath := fmt.Sprintf("/v2/%s/blobs/uploads/upload-%d", repo, f.nextUploadID)
621 f.uploads[uploadPath] = &fakeUploadSession{repo: repo}
622 w.Header().Set("Location", uploadPath)
623 w.WriteHeader(http.StatusAccepted)
624}
625
626func (f *fakePushRegistry) handleBlobPatch(w http.ResponseWriter, r *http.Request) {
627 session := f.uploads[r.URL.Path]
628 if session == nil {
629 f.t.Fatalf("missing upload session for %s", r.URL.Path)
630 }
631 body, err := io.ReadAll(r.Body)
632 require.NoError(f.t, err)
633 session.data = append(session.data, body...)
634 w.Header().Set("Location", r.URL.Path)
635 w.WriteHeader(http.StatusAccepted)
636}
637
638func (f *fakePushRegistry) handleBlobFinalize(w http.ResponseWriter, r *http.Request) {
639 session := f.uploads[r.URL.Path]
640 if session == nil {
641 f.t.Fatalf("missing upload session for %s", r.URL.Path)
642 }
643 digest := r.URL.Query().Get("digest")
644 f.uploadedBlobs[digest] = append([]byte(nil), session.data...)
645 delete(f.uploads, r.URL.Path)
646 w.WriteHeader(http.StatusCreated)
647}
648
649func (f *fakePushRegistry) handleManifestPut(w http.ResponseWriter, r *http.Request) {
650 repo := pathSegmentBetween(r.URL.Path, "/v2/", "/manifests/")
651 tag := pathTail(r.URL.Path)
652 assert.Equal(f.t, f.expectedRepo, repo)
653 assert.Equal(f.t, f.expectedTag, tag)
654 body, err := io.ReadAll(r.Body)
655 require.NoError(f.t, err)
656 f.manifestPuts[repo+":"+tag] = fakeManifestPut{contentType: r.Header.Get("Content-Type"), body: body}
657 w.WriteHeader(http.StatusCreated)
658}
659
660func pathTail(path string) string {
661 parts := strings.Split(path, "/")
662 return parts[len(parts)-1]
663}
664
665func pathSegmentBetween(value, start, end string) string {
666 startIdx := strings.Index(value, start)
667 if startIdx == -1 {
668 return ""
669 }
670 trimmed := value[startIdx+len(start):]
671 endIdx := strings.Index(trimmed, end)
672 if endIdx == -1 {
673 return ""
674 }
675 return trimmed[:endIdx]
676}
677
678func typesOCIManifestSchema1() string {
679 return "application/vnd.oci.image.manifest.v1+json"
680}
681
682// TestPusher_WithAuth verifies that when WithAuth is provided, its header is
683// used for all registry requests instead of the Docker keychain, and that
684// omitting WithAuth leaves existing behavior unchanged.
685func TestPusher_WithAuth(t *testing.T) {
686 t.Run("WithAuth header is sent on every registry request", func(t *testing.T) {
687 img, err := random.Image(1024, 1)
688 require.NoError(t, err)
689
690 const explicitToken = "Bearer my-token"
691
692 // authAssertingRegistry fails the test if any request lacks the expected
693 // Authorization header.
694 var requestCount int
695 registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount))
696 defer registry.Close()
697
698 serverURL, err := url.Parse(registry.URL)
699 require.NoError(t, err)
700 ref := serverURL.Host + "/demo/app:v1"
701
702 p := registrypush.New(
703 registrypush.WithTransport(registry.Client().Transport),
704 registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
705 return img, nil
706 }),
707 registrypush.WithAuth(explicitToken),
708 )
709
710 err = p.Push(context.Background(), ref)
711 require.NoError(t, err)
712 assert.Positive(t, requestCount, "at least one request should have been made")
713 })
714
715 t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) {
716 img, err := random.Image(1024, 1)
717 require.NoError(t, err)
718
719 serverState := newFakePushRegistry(t, img, map[string]bool{})
720 registry := httptest.NewServer(serverState.handler())
721 defer registry.Close()
722
723 serverURL, err := url.Parse(registry.URL)
724 require.NoError(t, err)
725 ref := serverURL.Host + "/demo/app:v1"
726
727 // No WithAuth — relies on Docker keychain (returns empty string for localhost).
728 p := registrypush.New(
729 registrypush.WithTransport(registry.Client().Transport),
730 registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) {
731 return img, nil
732 }),
733 )
734
735 err = p.Push(context.Background(), ref)
736 require.NoError(t, err)
737
738 // Manifest should have been pushed — verifies the push completed normally.
739 _, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag]
740 assert.True(t, ok, "manifest should have been uploaded")
741 })
742}
743
744// handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that
745// every request carries the expected Authorization header.
746func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler {
747 t.Helper()
748 inner := f.handler()
749 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
750 *requestCount++
751 assert.Equal(t, expectedAuth, r.Header.Get("Authorization"),
752 "expected Authorization header on %s %s", r.Method, r.URL.Path)
753 inner.ServeHTTP(w, r)
754 })
755}