v2.30.0-pre 7341e0e

handler_test.go RawHistoryFolder

1package registry
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"io"
8	"net/http"
9	"net/http/httptest"
10	"os"
11	"testing"
12
13	"github.com/bnema/zerowrap"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/mock"
16
17	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
18	"github.com/bnema/gordon/internal/domain"
19)
20
21func testLogger() zerowrap.Logger {
22	return zerowrap.Default()
23}
24
25func TestHandler_Base(t *testing.T) {
26	registrySvc := inmocks.NewMockRegistryService(t)
27
28	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
29
30	req := httptest.NewRequest("GET", "/v2/", nil)
31	rec := httptest.NewRecorder()
32
33	handler.ServeHTTP(rec, req)
34
35	assert.Equal(t, http.StatusOK, rec.Code)
36	assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
37}
38
39func TestHandler_NotFound(t *testing.T) {
40	registrySvc := inmocks.NewMockRegistryService(t)
41
42	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
43
44	req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
45	rec := httptest.NewRecorder()
46
47	handler.ServeHTTP(rec, req)
48
49	assert.Equal(t, http.StatusNotFound, rec.Code)
50}
51
52func TestHandler_GetManifest_Success(t *testing.T) {
53	registrySvc := inmocks.NewMockRegistryService(t)
54
55	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
56
57	manifestData := []byte(`{"schemaVersion": 2}`)
58	registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
59		Name:        "myapp",
60		Reference:   "latest",
61		ContentType: "application/vnd.docker.distribution.manifest.v2+json",
62		Data:        manifestData,
63	}, nil)
64
65	req := httptest.NewRequest("GET", "/v2/myapp/manifests/latest", nil)
66	rec := httptest.NewRecorder()
67
68	handler.ServeHTTP(rec, req)
69
70	assert.Equal(t, http.StatusOK, rec.Code)
71	assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", rec.Header().Get("Content-Type"))
72	assert.Equal(t, manifestData, rec.Body.Bytes())
73}
74
75func TestHandler_GetManifest_HEAD(t *testing.T) {
76	registrySvc := inmocks.NewMockRegistryService(t)
77
78	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
79
80	manifestData := []byte(`{"schemaVersion": 2}`)
81	registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
82		Name:        "myapp",
83		Reference:   "latest",
84		ContentType: "application/vnd.docker.distribution.manifest.v2+json",
85		Data:        manifestData,
86	}, nil)
87
88	req := httptest.NewRequest("HEAD", "/v2/myapp/manifests/latest", nil)
89	rec := httptest.NewRecorder()
90
91	handler.ServeHTTP(rec, req)
92
93	assert.Equal(t, http.StatusOK, rec.Code)
94	assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", rec.Header().Get("Content-Type"))
95	// HEAD should not return body
96	assert.Empty(t, rec.Body.Bytes())
97}
98
99func TestHandler_GetManifest_NotFound(t *testing.T) {
100	registrySvc := inmocks.NewMockRegistryService(t)
101
102	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
103
104	registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)
105
106	req := httptest.NewRequest("GET", "/v2/myapp/manifests/notexists", nil)
107	rec := httptest.NewRecorder()
108
109	handler.ServeHTTP(rec, req)
110
111	assert.Equal(t, http.StatusNotFound, rec.Code)
112	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
113}
114
115func TestHandler_GetManifest_NestedName(t *testing.T) {
116	registrySvc := inmocks.NewMockRegistryService(t)
117
118	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
119
120	manifestData := []byte(`{"schemaVersion": 2}`)
121	registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
122		Name:        "org/project/app",
123		Reference:   "v1.0",
124		ContentType: "application/vnd.docker.distribution.manifest.v2+json",
125		Data:        manifestData,
126	}, nil)
127
128	req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil)
129	rec := httptest.NewRecorder()
130
131	handler.ServeHTTP(rec, req)
132
133	assert.Equal(t, http.StatusOK, rec.Code)
134}
135
136func TestHandler_PutManifest_Success(t *testing.T) {
137	registrySvc := inmocks.NewMockRegistryService(t)
138
139	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
140
141	manifestData := []byte(`{"schemaVersion": 2}`)
142	registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
143		return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
144	})).Return("sha256:abc123", nil)
145
146	req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
147	req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
148	rec := httptest.NewRecorder()
149
150	handler.ServeHTTP(rec, req)
151
152	assert.Equal(t, http.StatusCreated, rec.Code)
153	assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest"))
154	assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
155}
156
157func TestHandler_PutManifest_MissingContentType(t *testing.T) {
158	registrySvc := inmocks.NewMockRegistryService(t)
159
160	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
161
162	manifestData := []byte(`{"schemaVersion": 2}`)
163
164	req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
165	// No Content-Type header
166	rec := httptest.NewRecorder()
167
168	handler.ServeHTTP(rec, req)
169
170	assert.Equal(t, http.StatusBadRequest, rec.Code)
171}
172
173func TestHandler_PutManifest_StorageError(t *testing.T) {
174	registrySvc := inmocks.NewMockRegistryService(t)
175
176	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
177
178	manifestData := []byte(`{"schemaVersion": 2}`)
179	registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError)
180
181	req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
182	req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
183	rec := httptest.NewRecorder()
184
185	handler.ServeHTTP(rec, req)
186
187	assert.Equal(t, http.StatusInternalServerError, rec.Code)
188}
189
190func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
191	registrySvc := inmocks.NewMockRegistryService(t)
192
193	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
194
195	req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
196	rec := httptest.NewRecorder()
197
198	handler.ServeHTTP(rec, req)
199
200	assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
201}
202
203func TestHandler_GetBlob_Success(t *testing.T) {
204	registrySvc := inmocks.NewMockRegistryService(t)
205
206	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
207
208	// Create a temp file for http.ServeFile
209	tmpFile, err := os.CreateTemp("", "blob-*")
210	assert.NoError(t, err)
211	defer tmpFile.Close()
212
213	blobContent := []byte("blob content here")
214	_, err = tmpFile.Write(blobContent)
215	assert.NoError(t, err)
216
217	registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil)
218
219	req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
220	rec := httptest.NewRecorder()
221
222	handler.ServeHTTP(rec, req)
223
224	assert.Equal(t, http.StatusOK, rec.Code)
225	assert.Equal(t, blobContent, rec.Body.Bytes())
226}
227
228func TestHandler_GetBlob_NotFound(t *testing.T) {
229	registrySvc := inmocks.NewMockRegistryService(t)
230
231	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
232
233	registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError)
234
235	req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil)
236	rec := httptest.NewRecorder()
237
238	handler.ServeHTTP(rec, req)
239
240	assert.Equal(t, http.StatusNotFound, rec.Code)
241}
242
243func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
244	registrySvc := inmocks.NewMockRegistryService(t)
245
246	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
247
248	req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil)
249	rec := httptest.NewRecorder()
250
251	handler.ServeHTTP(rec, req)
252
253	assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
254	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
255}
256
257func TestHandler_StartBlobUpload_Success(t *testing.T) {
258	registrySvc := inmocks.NewMockRegistryService(t)
259
260	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
261
262	registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
263
264	req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
265	rec := httptest.NewRecorder()
266
267	handler.ServeHTTP(rec, req)
268
269	assert.Equal(t, http.StatusAccepted, rec.Code)
270	assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000")
271	assert.Equal(t, "0-0", rec.Header().Get("Range"))
272}
273
274func TestHandler_StartBlobUpload_Error(t *testing.T) {
275	registrySvc := inmocks.NewMockRegistryService(t)
276
277	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
278
279	registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)
280
281	req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
282	rec := httptest.NewRecorder()
283
284	handler.ServeHTTP(rec, req)
285
286	assert.Equal(t, http.StatusInternalServerError, rec.Code)
287	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
288}
289
290func TestHandler_BlobUpload_PATCH(t *testing.T) {
291	registrySvc := inmocks.NewMockRegistryService(t)
292
293	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
294
295	chunkData := []byte("chunk content")
296	registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)
297
298	req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
299	rec := httptest.NewRecorder()
300
301	handler.ServeHTTP(rec, req)
302
303	assert.Equal(t, http.StatusAccepted, rec.Code)
304	assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000")
305}
306
307func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
308	registrySvc := inmocks.NewMockRegistryService(t)
309
310	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
311
312	chunkData := []byte("final chunk")
313	registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)
314	registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
315
316	req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
317	rec := httptest.NewRecorder()
318
319	handler.ServeHTTP(rec, req)
320
321	assert.Equal(t, http.StatusCreated, rec.Code)
322	assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4")
323	assert.Equal(t, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", rec.Header().Get("Docker-Content-Digest"))
324}
325
326func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
327	registrySvc := inmocks.NewMockRegistryService(t)
328
329	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
330
331	chunkData := []byte("final chunk")
332	// Note: Invalid digest format is rejected by validation before reaching storage layer
333	// This test verifies that invalid digests are properly rejected
334
335	req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:wrong", bytes.NewReader(chunkData))
336	rec := httptest.NewRecorder()
337
338	handler.ServeHTTP(rec, req)
339
340	assert.Equal(t, http.StatusBadRequest, rec.Code)
341	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
342}
343
344func TestHandler_ListTags_Success(t *testing.T) {
345	registrySvc := inmocks.NewMockRegistryService(t)
346
347	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
348
349	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
350
351	req := httptest.NewRequest("GET", "/v2/myapp/tags/list", nil)
352	rec := httptest.NewRecorder()
353
354	handler.ServeHTTP(rec, req)
355
356	assert.Equal(t, http.StatusOK, rec.Code)
357	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
358
359	var response struct {
360		Name string   `json:"name"`
361		Tags []string `json:"tags"`
362	}
363	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
364	assert.Equal(t, "myapp", response.Name)
365	assert.Contains(t, response.Tags, "latest")
366}
367
368func TestHandler_ListTags_NotFound(t *testing.T) {
369	registrySvc := inmocks.NewMockRegistryService(t)
370
371	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
372
373	registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)
374
375	req := httptest.NewRequest("GET", "/v2/notexists/tags/list", nil)
376	rec := httptest.NewRecorder()
377
378	handler.ServeHTTP(rec, req)
379
380	assert.Equal(t, http.StatusNotFound, rec.Code)
381	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
382}
383
384func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
385	registrySvc := inmocks.NewMockRegistryService(t)
386
387	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
388
389	req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)
390	rec := httptest.NewRecorder()
391
392	handler.ServeHTTP(rec, req)
393
394	assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
395	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
396}
397
398func TestHandler_RegisterRoutes(t *testing.T) {
399	registrySvc := inmocks.NewMockRegistryService(t)
400
401	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
402
403	mux := http.NewServeMux()
404	handler.RegisterRoutes(mux)
405
406	// Verify routes are registered by making a request through the mux
407	req := httptest.NewRequest("GET", "/v2/", nil)
408	rec := httptest.NewRecorder()
409
410	mux.ServeHTTP(rec, req)
411
412	assert.Equal(t, http.StatusOK, rec.Code)
413}
414
415func TestHandler_StartBlobUpload_ReturnsDockerUploadUUID(t *testing.T) {
416	registrySvc := inmocks.NewMockRegistryService(t)
417	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
418
419	registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("550e8400-e29b-41d4-a716-446655440000", nil)
420
421	req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
422	rec := httptest.NewRecorder()
423
424	handler.ServeHTTP(rec, req)
425
426	assert.Equal(t, http.StatusAccepted, rec.Code)
427	assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", rec.Header().Get("Docker-Upload-UUID"))
428}
429
430func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
431	registrySvc := inmocks.NewMockRegistryService(t)
432	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
433
434	chunkData := []byte("chunk content")
435	registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)
436
437	req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
438	rec := httptest.NewRecorder()
439
440	handler.ServeHTTP(rec, req)
441
442	assert.Equal(t, http.StatusAccepted, rec.Code)
443	assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", rec.Header().Get("Docker-Upload-UUID"))
444}
445
446func TestDefaultMaxBlobChunkSize_Is95MB(t *testing.T) {
447	assert.Equal(t, int64(95*1024*1024), int64(DefaultMaxBlobChunkSize))
448}
449
450func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing.T) {
451	registrySvc := inmocks.NewMockRegistryService(t)
452	handler := NewHandler(registrySvc, testLogger(), 5)
453
454	// The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk.
455	// The mock must read the body to trigger MaxBytesError, which the handler detects.
456	registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).
457		Run(func(_ context.Context, _ string, _ string, data io.Reader) {
458			// Drain the reader to trigger MaxBytesError
459			_, _ = io.ReadAll(data)
460		}).
461		Return(int64(0), &http.MaxBytesError{Limit: 5})
462
463	req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("chunk content")))
464	rec := httptest.NewRecorder()
465
466	handler.ServeHTTP(rec, req)
467
468	assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
469}
470
471func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
472	registrySvc := inmocks.NewMockRegistryService(t)
473	handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
474
475	chunkData := []byte("chunk content")
476	registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)
477
478	req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
479	rec := httptest.NewRecorder()
480
481	handler.ServeHTTP(rec, req)
482
483	assert.Equal(t, http.StatusAccepted, rec.Code)
484	// OCI spec: Range header should reflect total received bytes
485	assert.Equal(t, "0-12", rec.Header().Get("Range"))
486}

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