v3.3.2 9697f9d

push_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close