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