detached d98712e

test(adapters/http): add unit tests for auth middleware and registry handler

- Add 15 tests for auth middleware (basic auth, timing attack prevention)
- Add 22 tests for registry handler (manifests, blobs, uploads, tags)
- Use httptest and mocks for isolated unit testing
commit
d98712e5b9481f9d55122e9470f7ade364f4228a
author
brice · · 2026-01-12 18:58 UTC
committer
brice · · 2026-01-12 20:11 UTC
parent
22acf88
signature
unsigned
2 files · +793 -0
A internal/adapters/in/http/middleware/auth_test.go +349 -0
@@ -0,0 +1,349 @@
1+package middleware
2+
3+import (
4+ "encoding/base64"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+)
12+
13+func testLogger() zerowrap.Logger {
14+ return zerowrap.Default()
15+}
16+
17+func TestRegistryAuth_ValidCredentials(t *testing.T) {
18+ log := testLogger()
19+ middleware := RegistryAuth("admin", "secret", log)
20+
21+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+ w.WriteHeader(http.StatusOK)
23+ w.Write([]byte("authenticated"))
24+ }))
25+
26+ req := httptest.NewRequest("GET", "/v2/", nil)
27+ req.SetBasicAuth("admin", "secret")
28+ rec := httptest.NewRecorder()
29+
30+ handler.ServeHTTP(rec, req)
31+
32+ assert.Equal(t, http.StatusOK, rec.Code)
33+ assert.Equal(t, "authenticated", rec.Body.String())
34+}
35+
36+func TestRegistryAuth_InvalidUsername(t *testing.T) {
37+ log := testLogger()
38+ middleware := RegistryAuth("admin", "secret", log)
39+
40+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+ w.WriteHeader(http.StatusOK)
42+ }))
43+
44+ req := httptest.NewRequest("GET", "/v2/", nil)
45+ req.SetBasicAuth("wronguser", "secret")
46+ rec := httptest.NewRecorder()
47+
48+ handler.ServeHTTP(rec, req)
49+
50+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
51+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
52+ assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
53+}
54+
55+func TestRegistryAuth_InvalidPassword(t *testing.T) {
56+ log := testLogger()
57+ middleware := RegistryAuth("admin", "secret", log)
58+
59+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+ w.WriteHeader(http.StatusOK)
61+ }))
62+
63+ req := httptest.NewRequest("GET", "/v2/", nil)
64+ req.SetBasicAuth("admin", "wrongpassword")
65+ rec := httptest.NewRecorder()
66+
67+ handler.ServeHTTP(rec, req)
68+
69+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
70+}
71+
72+func TestRegistryAuth_NoCredentials(t *testing.T) {
73+ log := testLogger()
74+ middleware := RegistryAuth("admin", "secret", log)
75+
76+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+ w.WriteHeader(http.StatusOK)
78+ }))
79+
80+ req := httptest.NewRequest("GET", "/v2/", nil)
81+ // No auth header
82+ rec := httptest.NewRecorder()
83+
84+ handler.ServeHTTP(rec, req)
85+
86+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
87+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
88+}
89+
90+func TestRegistryAuth_MalformedAuthHeader(t *testing.T) {
91+ log := testLogger()
92+ middleware := RegistryAuth("admin", "secret", log)
93+
94+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ w.WriteHeader(http.StatusOK)
96+ }))
97+
98+ req := httptest.NewRequest("GET", "/v2/", nil)
99+ req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64
100+ rec := httptest.NewRecorder()
101+
102+ handler.ServeHTTP(rec, req)
103+
104+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
105+}
106+
107+func TestRegistryAuth_WrongAuthScheme(t *testing.T) {
108+ log := testLogger()
109+ middleware := RegistryAuth("admin", "secret", log)
110+
111+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+ w.WriteHeader(http.StatusOK)
113+ }))
114+
115+ req := httptest.NewRequest("GET", "/v2/", nil)
116+ req.Header.Set("Authorization", "Bearer some-token")
117+ rec := httptest.NewRecorder()
118+
119+ handler.ServeHTTP(rec, req)
120+
121+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
122+}
123+
124+func TestRegistryAuth_EmptyCredentials(t *testing.T) {
125+ log := testLogger()
126+ middleware := RegistryAuth("admin", "secret", log)
127+
128+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129+ w.WriteHeader(http.StatusOK)
130+ }))
131+
132+ req := httptest.NewRequest("GET", "/v2/", nil)
133+ req.SetBasicAuth("", "")
134+ rec := httptest.NewRecorder()
135+
136+ handler.ServeHTTP(rec, req)
137+
138+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
139+}
140+
141+func TestRegistryAuth_PartialCredentials(t *testing.T) {
142+ log := testLogger()
143+ middleware := RegistryAuth("admin", "secret", log)
144+
145+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146+ w.WriteHeader(http.StatusOK)
147+ }))
148+
149+ // Only username, empty password
150+ req := httptest.NewRequest("GET", "/v2/", nil)
151+ req.SetBasicAuth("admin", "")
152+ rec := httptest.NewRecorder()
153+
154+ handler.ServeHTTP(rec, req)
155+
156+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
157+}
158+
159+func TestIsAuthenticated_ValidCredentials(t *testing.T) {
160+ log := testLogger()
161+
162+ req := httptest.NewRequest("GET", "/v2/", nil)
163+ req.SetBasicAuth("admin", "secret")
164+
165+ result := isAuthenticated(req, "admin", "secret", log)
166+
167+ assert.True(t, result)
168+}
169+
170+func TestIsAuthenticated_InvalidCredentials(t *testing.T) {
171+ log := testLogger()
172+
173+ req := httptest.NewRequest("GET", "/v2/", nil)
174+ req.SetBasicAuth("admin", "wrong")
175+
176+ result := isAuthenticated(req, "admin", "secret", log)
177+
178+ assert.False(t, result)
179+}
180+
181+func TestIsAuthenticated_NoAuthHeader(t *testing.T) {
182+ log := testLogger()
183+
184+ req := httptest.NewRequest("GET", "/v2/", nil)
185+
186+ result := isAuthenticated(req, "admin", "secret", log)
187+
188+ assert.False(t, result)
189+}
190+
191+func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) {
192+ // This test verifies that constant-time comparison is used
193+ // by checking both username and password are validated
194+ log := testLogger()
195+
196+ tests := []struct {
197+ name string
198+ username string
199+ password string
200+ expectedUser string
201+ expectedPass string
202+ shouldAuth bool
203+ }{
204+ {
205+ name: "both correct",
206+ username: "admin",
207+ password: "secret",
208+ expectedUser: "admin",
209+ expectedPass: "secret",
210+ shouldAuth: true,
211+ },
212+ {
213+ name: "username wrong first char",
214+ username: "bdmin",
215+ password: "secret",
216+ expectedUser: "admin",
217+ expectedPass: "secret",
218+ shouldAuth: false,
219+ },
220+ {
221+ name: "username wrong last char",
222+ username: "admio",
223+ password: "secret",
224+ expectedUser: "admin",
225+ expectedPass: "secret",
226+ shouldAuth: false,
227+ },
228+ {
229+ name: "password wrong first char",
230+ username: "admin",
231+ password: "aecret",
232+ expectedUser: "admin",
233+ expectedPass: "secret",
234+ shouldAuth: false,
235+ },
236+ {
237+ name: "password wrong last char",
238+ username: "admin",
239+ password: "secreo",
240+ expectedUser: "admin",
241+ expectedPass: "secret",
242+ shouldAuth: false,
243+ },
244+ {
245+ name: "different length username",
246+ username: "adm",
247+ password: "secret",
248+ expectedUser: "admin",
249+ expectedPass: "secret",
250+ shouldAuth: false,
251+ },
252+ {
253+ name: "different length password",
254+ username: "admin",
255+ password: "sec",
256+ expectedUser: "admin",
257+ expectedPass: "secret",
258+ shouldAuth: false,
259+ },
260+ }
261+
262+ for _, tt := range tests {
263+ t.Run(tt.name, func(t *testing.T) {
264+ req := httptest.NewRequest("GET", "/v2/", nil)
265+ req.SetBasicAuth(tt.username, tt.password)
266+
267+ result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log)
268+
269+ assert.Equal(t, tt.shouldAuth, result)
270+ })
271+ }
272+}
273+
274+func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) {
275+ log := testLogger()
276+
277+ tests := []struct {
278+ name string
279+ username string
280+ password string
281+ }{
282+ {"unicode username", "用户", "password"},
283+ {"unicode password", "admin", "密码"},
284+ {"special chars", "admin@example.com", "p@ss:w0rd!"},
285+ {"spaces", "admin user", "pass word"},
286+ {"colon in password", "admin", "pass:word"},
287+ }
288+
289+ for _, tt := range tests {
290+ t.Run(tt.name, func(t *testing.T) {
291+ middleware := RegistryAuth(tt.username, tt.password, log)
292+
293+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
294+ w.WriteHeader(http.StatusOK)
295+ }))
296+
297+ req := httptest.NewRequest("GET", "/v2/", nil)
298+ req.SetBasicAuth(tt.username, tt.password)
299+ rec := httptest.NewRecorder()
300+
301+ handler.ServeHTTP(rec, req)
302+
303+ assert.Equal(t, http.StatusOK, rec.Code)
304+ })
305+ }
306+}
307+
308+func TestRegistryAuth_Base64EdgeCases(t *testing.T) {
309+ log := testLogger()
310+ middleware := RegistryAuth("admin", "secret", log)
311+
312+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
313+ w.WriteHeader(http.StatusOK)
314+ }))
315+
316+ tests := []struct {
317+ name string
318+ authHeader string
319+ wantStatus int
320+ }{
321+ {
322+ name: "empty basic auth",
323+ authHeader: "Basic ",
324+ wantStatus: http.StatusUnauthorized,
325+ },
326+ {
327+ name: "basic auth with only colon",
328+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")),
329+ wantStatus: http.StatusUnauthorized,
330+ },
331+ {
332+ name: "missing colon in decoded value",
333+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")),
334+ wantStatus: http.StatusUnauthorized,
335+ },
336+ }
337+
338+ for _, tt := range tests {
339+ t.Run(tt.name, func(t *testing.T) {
340+ req := httptest.NewRequest("GET", "/v2/", nil)
341+ req.Header.Set("Authorization", tt.authHeader)
342+ rec := httptest.NewRecorder()
343+
344+ handler.ServeHTTP(rec, req)
345+
346+ assert.Equal(t, tt.wantStatus, rec.Code)
347+ })
348+ }
349+}
@@ -0,0 +1,349 @@
1+package middleware
2+
3+import (
4+ "encoding/base64"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+)
12+
13+func testLogger() zerowrap.Logger {
14+ return zerowrap.Default()
15+}
16+
17+func TestRegistryAuth_ValidCredentials(t *testing.T) {
18+ log := testLogger()
19+ middleware := RegistryAuth("admin", "secret", log)
20+
21+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+ w.WriteHeader(http.StatusOK)
23+ w.Write([]byte("authenticated"))
24+ }))
25+
26+ req := httptest.NewRequest("GET", "/v2/", nil)
27+ req.SetBasicAuth("admin", "secret")
28+ rec := httptest.NewRecorder()
29+
30+ handler.ServeHTTP(rec, req)
31+
32+ assert.Equal(t, http.StatusOK, rec.Code)
33+ assert.Equal(t, "authenticated", rec.Body.String())
34+}
35+
36+func TestRegistryAuth_InvalidUsername(t *testing.T) {
37+ log := testLogger()
38+ middleware := RegistryAuth("admin", "secret", log)
39+
40+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
41+ w.WriteHeader(http.StatusOK)
42+ }))
43+
44+ req := httptest.NewRequest("GET", "/v2/", nil)
45+ req.SetBasicAuth("wronguser", "secret")
46+ rec := httptest.NewRecorder()
47+
48+ handler.ServeHTTP(rec, req)
49+
50+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
51+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
52+ assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
53+}
54+
55+func TestRegistryAuth_InvalidPassword(t *testing.T) {
56+ log := testLogger()
57+ middleware := RegistryAuth("admin", "secret", log)
58+
59+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+ w.WriteHeader(http.StatusOK)
61+ }))
62+
63+ req := httptest.NewRequest("GET", "/v2/", nil)
64+ req.SetBasicAuth("admin", "wrongpassword")
65+ rec := httptest.NewRecorder()
66+
67+ handler.ServeHTTP(rec, req)
68+
69+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
70+}
71+
72+func TestRegistryAuth_NoCredentials(t *testing.T) {
73+ log := testLogger()
74+ middleware := RegistryAuth("admin", "secret", log)
75+
76+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+ w.WriteHeader(http.StatusOK)
78+ }))
79+
80+ req := httptest.NewRequest("GET", "/v2/", nil)
81+ // No auth header
82+ rec := httptest.NewRecorder()
83+
84+ handler.ServeHTTP(rec, req)
85+
86+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
87+ assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
88+}
89+
90+func TestRegistryAuth_MalformedAuthHeader(t *testing.T) {
91+ log := testLogger()
92+ middleware := RegistryAuth("admin", "secret", log)
93+
94+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95+ w.WriteHeader(http.StatusOK)
96+ }))
97+
98+ req := httptest.NewRequest("GET", "/v2/", nil)
99+ req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64
100+ rec := httptest.NewRecorder()
101+
102+ handler.ServeHTTP(rec, req)
103+
104+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
105+}
106+
107+func TestRegistryAuth_WrongAuthScheme(t *testing.T) {
108+ log := testLogger()
109+ middleware := RegistryAuth("admin", "secret", log)
110+
111+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+ w.WriteHeader(http.StatusOK)
113+ }))
114+
115+ req := httptest.NewRequest("GET", "/v2/", nil)
116+ req.Header.Set("Authorization", "Bearer some-token")
117+ rec := httptest.NewRecorder()
118+
119+ handler.ServeHTTP(rec, req)
120+
121+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
122+}
123+
124+func TestRegistryAuth_EmptyCredentials(t *testing.T) {
125+ log := testLogger()
126+ middleware := RegistryAuth("admin", "secret", log)
127+
128+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129+ w.WriteHeader(http.StatusOK)
130+ }))
131+
132+ req := httptest.NewRequest("GET", "/v2/", nil)
133+ req.SetBasicAuth("", "")
134+ rec := httptest.NewRecorder()
135+
136+ handler.ServeHTTP(rec, req)
137+
138+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
139+}
140+
141+func TestRegistryAuth_PartialCredentials(t *testing.T) {
142+ log := testLogger()
143+ middleware := RegistryAuth("admin", "secret", log)
144+
145+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146+ w.WriteHeader(http.StatusOK)
147+ }))
148+
149+ // Only username, empty password
150+ req := httptest.NewRequest("GET", "/v2/", nil)
151+ req.SetBasicAuth("admin", "")
152+ rec := httptest.NewRecorder()
153+
154+ handler.ServeHTTP(rec, req)
155+
156+ assert.Equal(t, http.StatusUnauthorized, rec.Code)
157+}
158+
159+func TestIsAuthenticated_ValidCredentials(t *testing.T) {
160+ log := testLogger()
161+
162+ req := httptest.NewRequest("GET", "/v2/", nil)
163+ req.SetBasicAuth("admin", "secret")
164+
165+ result := isAuthenticated(req, "admin", "secret", log)
166+
167+ assert.True(t, result)
168+}
169+
170+func TestIsAuthenticated_InvalidCredentials(t *testing.T) {
171+ log := testLogger()
172+
173+ req := httptest.NewRequest("GET", "/v2/", nil)
174+ req.SetBasicAuth("admin", "wrong")
175+
176+ result := isAuthenticated(req, "admin", "secret", log)
177+
178+ assert.False(t, result)
179+}
180+
181+func TestIsAuthenticated_NoAuthHeader(t *testing.T) {
182+ log := testLogger()
183+
184+ req := httptest.NewRequest("GET", "/v2/", nil)
185+
186+ result := isAuthenticated(req, "admin", "secret", log)
187+
188+ assert.False(t, result)
189+}
190+
191+func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) {
192+ // This test verifies that constant-time comparison is used
193+ // by checking both username and password are validated
194+ log := testLogger()
195+
196+ tests := []struct {
197+ name string
198+ username string
199+ password string
200+ expectedUser string
201+ expectedPass string
202+ shouldAuth bool
203+ }{
204+ {
205+ name: "both correct",
206+ username: "admin",
207+ password: "secret",
208+ expectedUser: "admin",
209+ expectedPass: "secret",
210+ shouldAuth: true,
211+ },
212+ {
213+ name: "username wrong first char",
214+ username: "bdmin",
215+ password: "secret",
216+ expectedUser: "admin",
217+ expectedPass: "secret",
218+ shouldAuth: false,
219+ },
220+ {
221+ name: "username wrong last char",
222+ username: "admio",
223+ password: "secret",
224+ expectedUser: "admin",
225+ expectedPass: "secret",
226+ shouldAuth: false,
227+ },
228+ {
229+ name: "password wrong first char",
230+ username: "admin",
231+ password: "aecret",
232+ expectedUser: "admin",
233+ expectedPass: "secret",
234+ shouldAuth: false,
235+ },
236+ {
237+ name: "password wrong last char",
238+ username: "admin",
239+ password: "secreo",
240+ expectedUser: "admin",
241+ expectedPass: "secret",
242+ shouldAuth: false,
243+ },
244+ {
245+ name: "different length username",
246+ username: "adm",
247+ password: "secret",
248+ expectedUser: "admin",
249+ expectedPass: "secret",
250+ shouldAuth: false,
251+ },
252+ {
253+ name: "different length password",
254+ username: "admin",
255+ password: "sec",
256+ expectedUser: "admin",
257+ expectedPass: "secret",
258+ shouldAuth: false,
259+ },
260+ }
261+
262+ for _, tt := range tests {
263+ t.Run(tt.name, func(t *testing.T) {
264+ req := httptest.NewRequest("GET", "/v2/", nil)
265+ req.SetBasicAuth(tt.username, tt.password)
266+
267+ result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log)
268+
269+ assert.Equal(t, tt.shouldAuth, result)
270+ })
271+ }
272+}
273+
274+func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) {
275+ log := testLogger()
276+
277+ tests := []struct {
278+ name string
279+ username string
280+ password string
281+ }{
282+ {"unicode username", "用户", "password"},
283+ {"unicode password", "admin", "密码"},
284+ {"special chars", "admin@example.com", "p@ss:w0rd!"},
285+ {"spaces", "admin user", "pass word"},
286+ {"colon in password", "admin", "pass:word"},
287+ }
288+
289+ for _, tt := range tests {
290+ t.Run(tt.name, func(t *testing.T) {
291+ middleware := RegistryAuth(tt.username, tt.password, log)
292+
293+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
294+ w.WriteHeader(http.StatusOK)
295+ }))
296+
297+ req := httptest.NewRequest("GET", "/v2/", nil)
298+ req.SetBasicAuth(tt.username, tt.password)
299+ rec := httptest.NewRecorder()
300+
301+ handler.ServeHTTP(rec, req)
302+
303+ assert.Equal(t, http.StatusOK, rec.Code)
304+ })
305+ }
306+}
307+
308+func TestRegistryAuth_Base64EdgeCases(t *testing.T) {
309+ log := testLogger()
310+ middleware := RegistryAuth("admin", "secret", log)
311+
312+ handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
313+ w.WriteHeader(http.StatusOK)
314+ }))
315+
316+ tests := []struct {
317+ name string
318+ authHeader string
319+ wantStatus int
320+ }{
321+ {
322+ name: "empty basic auth",
323+ authHeader: "Basic ",
324+ wantStatus: http.StatusUnauthorized,
325+ },
326+ {
327+ name: "basic auth with only colon",
328+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")),
329+ wantStatus: http.StatusUnauthorized,
330+ },
331+ {
332+ name: "missing colon in decoded value",
333+ authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")),
334+ wantStatus: http.StatusUnauthorized,
335+ },
336+ }
337+
338+ for _, tt := range tests {
339+ t.Run(tt.name, func(t *testing.T) {
340+ req := httptest.NewRequest("GET", "/v2/", nil)
341+ req.Header.Set("Authorization", tt.authHeader)
342+ rec := httptest.NewRecorder()
343+
344+ handler.ServeHTTP(rec, req)
345+
346+ assert.Equal(t, tt.wantStatus, rec.Code)
347+ })
348+ }
349+}
A internal/adapters/in/http/registry/handler_test.go +444 -0
@@ -0,0 +1,444 @@
1+package registry
2+
3+import (
4+ "bytes"
5+ "net/http"
6+ "net/http/httptest"
7+ "os"
8+ "testing"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+
14+ inmocks "gordon/internal/boundaries/in/mocks"
15+ "gordon/internal/boundaries/out/mocks"
16+ "gordon/internal/domain"
17+)
18+
19+func testLogger() zerowrap.Logger {
20+ return zerowrap.Default()
21+}
22+
23+func TestHandler_Base(t *testing.T) {
24+ registrySvc := inmocks.NewMockRegistryService(t)
25+ blobStorage := mocks.NewMockBlobStorage(t)
26+ eventBus := mocks.NewMockEventPublisher(t)
27+
28+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
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+
39+func TestHandler_NotFound(t *testing.T) {
40+ registrySvc := inmocks.NewMockRegistryService(t)
41+ blobStorage := mocks.NewMockBlobStorage(t)
42+ eventBus := mocks.NewMockEventPublisher(t)
43+
44+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
45+
46+ req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
47+ rec := httptest.NewRecorder()
48+
49+ handler.ServeHTTP(rec, req)
50+
51+ assert.Equal(t, http.StatusNotFound, rec.Code)
52+}
53+
54+func TestHandler_GetManifest_Success(t *testing.T) {
55+ registrySvc := inmocks.NewMockRegistryService(t)
56+ blobStorage := mocks.NewMockBlobStorage(t)
57+ eventBus := mocks.NewMockEventPublisher(t)
58+
59+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
60+
61+ manifestData := []byte(`{"schemaVersion": 2}`)
62+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
63+ Name: "myapp",
64+ Reference: "latest",
65+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
66+ Data: manifestData,
67+ }, nil)
68+
69+ req := httptest.NewRequest("GET", "/v2/myapp/manifests/latest", nil)
70+ rec := httptest.NewRecorder()
71+
72+ handler.ServeHTTP(rec, req)
73+
74+ assert.Equal(t, http.StatusOK, rec.Code)
75+ assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", rec.Header().Get("Content-Type"))
76+ assert.Equal(t, manifestData, rec.Body.Bytes())
77+}
78+
79+func TestHandler_GetManifest_HEAD(t *testing.T) {
80+ registrySvc := inmocks.NewMockRegistryService(t)
81+ blobStorage := mocks.NewMockBlobStorage(t)
82+ eventBus := mocks.NewMockEventPublisher(t)
83+
84+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
85+
86+ manifestData := []byte(`{"schemaVersion": 2}`)
87+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
88+ Name: "myapp",
89+ Reference: "latest",
90+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
91+ Data: manifestData,
92+ }, nil)
93+
94+ req := httptest.NewRequest("HEAD", "/v2/myapp/manifests/latest", nil)
95+ rec := httptest.NewRecorder()
96+
97+ handler.ServeHTTP(rec, req)
98+
99+ assert.Equal(t, http.StatusOK, rec.Code)
100+ assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", rec.Header().Get("Content-Type"))
101+ // HEAD should not return body
102+ assert.Empty(t, rec.Body.Bytes())
103+}
104+
105+func TestHandler_GetManifest_NotFound(t *testing.T) {
106+ registrySvc := inmocks.NewMockRegistryService(t)
107+ blobStorage := mocks.NewMockBlobStorage(t)
108+ eventBus := mocks.NewMockEventPublisher(t)
109+
110+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
111+
112+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)
113+
114+ req := httptest.NewRequest("GET", "/v2/myapp/manifests/notexists", nil)
115+ rec := httptest.NewRecorder()
116+
117+ handler.ServeHTTP(rec, req)
118+
119+ assert.Equal(t, http.StatusNotFound, rec.Code)
120+}
121+
122+func TestHandler_GetManifest_NestedName(t *testing.T) {
123+ registrySvc := inmocks.NewMockRegistryService(t)
124+ blobStorage := mocks.NewMockBlobStorage(t)
125+ eventBus := mocks.NewMockEventPublisher(t)
126+
127+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
128+
129+ manifestData := []byte(`{"schemaVersion": 2}`)
130+ registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
131+ Name: "org/project/app",
132+ Reference: "v1.0",
133+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
134+ Data: manifestData,
135+ }, nil)
136+
137+ req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil)
138+ rec := httptest.NewRecorder()
139+
140+ handler.ServeHTTP(rec, req)
141+
142+ assert.Equal(t, http.StatusOK, rec.Code)
143+}
144+
145+func TestHandler_PutManifest_Success(t *testing.T) {
146+ registrySvc := inmocks.NewMockRegistryService(t)
147+ blobStorage := mocks.NewMockBlobStorage(t)
148+ eventBus := mocks.NewMockEventPublisher(t)
149+
150+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
151+
152+ manifestData := []byte(`{"schemaVersion": 2}`)
153+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
154+ return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
155+ })).Return(nil)
156+
157+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
158+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
159+ rec := httptest.NewRecorder()
160+
161+ handler.ServeHTTP(rec, req)
162+
163+ assert.Equal(t, http.StatusCreated, rec.Code)
164+ assert.NotEmpty(t, rec.Header().Get("Docker-Content-Digest"))
165+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
166+}
167+
168+func TestHandler_PutManifest_MissingContentType(t *testing.T) {
169+ registrySvc := inmocks.NewMockRegistryService(t)
170+ blobStorage := mocks.NewMockBlobStorage(t)
171+ eventBus := mocks.NewMockEventPublisher(t)
172+
173+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
174+
175+ manifestData := []byte(`{"schemaVersion": 2}`)
176+
177+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
178+ // No Content-Type header
179+ rec := httptest.NewRecorder()
180+
181+ handler.ServeHTTP(rec, req)
182+
183+ assert.Equal(t, http.StatusBadRequest, rec.Code)
184+}
185+
186+func TestHandler_PutManifest_StorageError(t *testing.T) {
187+ registrySvc := inmocks.NewMockRegistryService(t)
188+ blobStorage := mocks.NewMockBlobStorage(t)
189+ eventBus := mocks.NewMockEventPublisher(t)
190+
191+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
192+
193+ manifestData := []byte(`{"schemaVersion": 2}`)
194+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return(assert.AnError)
195+
196+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
197+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
198+ rec := httptest.NewRecorder()
199+
200+ handler.ServeHTTP(rec, req)
201+
202+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
203+}
204+
205+func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
206+ registrySvc := inmocks.NewMockRegistryService(t)
207+ blobStorage := mocks.NewMockBlobStorage(t)
208+ eventBus := mocks.NewMockEventPublisher(t)
209+
210+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
211+
212+ req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
213+ rec := httptest.NewRecorder()
214+
215+ handler.ServeHTTP(rec, req)
216+
217+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
218+}
219+
220+func TestHandler_GetBlob_Success(t *testing.T) {
221+ registrySvc := inmocks.NewMockRegistryService(t)
222+ blobStorage := mocks.NewMockBlobStorage(t)
223+ eventBus := mocks.NewMockEventPublisher(t)
224+
225+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
226+
227+ // Create a temp file for http.ServeFile
228+ tmpFile, err := os.CreateTemp("", "blob-*")
229+ assert.NoError(t, err)
230+ defer tmpFile.Close()
231+
232+ blobContent := []byte("blob content here")
233+ _, err = tmpFile.Write(blobContent)
234+ assert.NoError(t, err)
235+
236+ blobStorage.EXPECT().GetBlobPath("sha256:abc123").Return(tmpFile.Name(), nil)
237+
238+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:abc123", nil)
239+ rec := httptest.NewRecorder()
240+
241+ handler.ServeHTTP(rec, req)
242+
243+ assert.Equal(t, http.StatusOK, rec.Code)
244+ assert.Equal(t, blobContent, rec.Body.Bytes())
245+}
246+
247+func TestHandler_GetBlob_NotFound(t *testing.T) {
248+ registrySvc := inmocks.NewMockRegistryService(t)
249+ blobStorage := mocks.NewMockBlobStorage(t)
250+ eventBus := mocks.NewMockEventPublisher(t)
251+
252+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
253+
254+ blobStorage.EXPECT().GetBlobPath("sha256:notexists").Return("", assert.AnError)
255+
256+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:notexists", nil)
257+ rec := httptest.NewRecorder()
258+
259+ handler.ServeHTTP(rec, req)
260+
261+ assert.Equal(t, http.StatusNotFound, rec.Code)
262+}
263+
264+func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
265+ registrySvc := inmocks.NewMockRegistryService(t)
266+ blobStorage := mocks.NewMockBlobStorage(t)
267+ eventBus := mocks.NewMockEventPublisher(t)
268+
269+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
270+
271+ req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:abc123", nil)
272+ rec := httptest.NewRecorder()
273+
274+ handler.ServeHTTP(rec, req)
275+
276+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
277+}
278+
279+func TestHandler_StartBlobUpload_Success(t *testing.T) {
280+ registrySvc := inmocks.NewMockRegistryService(t)
281+ blobStorage := mocks.NewMockBlobStorage(t)
282+ eventBus := mocks.NewMockEventPublisher(t)
283+
284+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
285+
286+ registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("upload-uuid-123", nil)
287+
288+ req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
289+ rec := httptest.NewRecorder()
290+
291+ handler.ServeHTTP(rec, req)
292+
293+ assert.Equal(t, http.StatusAccepted, rec.Code)
294+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/upload-uuid-123")
295+ assert.Equal(t, "0-0", rec.Header().Get("Range"))
296+}
297+
298+func TestHandler_StartBlobUpload_Error(t *testing.T) {
299+ registrySvc := inmocks.NewMockRegistryService(t)
300+ blobStorage := mocks.NewMockBlobStorage(t)
301+ eventBus := mocks.NewMockEventPublisher(t)
302+
303+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
304+
305+ registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)
306+
307+ req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
308+ rec := httptest.NewRecorder()
309+
310+ handler.ServeHTTP(rec, req)
311+
312+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
313+}
314+
315+func TestHandler_BlobUpload_PATCH(t *testing.T) {
316+ registrySvc := inmocks.NewMockRegistryService(t)
317+ blobStorage := mocks.NewMockBlobStorage(t)
318+ eventBus := mocks.NewMockEventPublisher(t)
319+
320+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
321+
322+ chunkData := []byte("chunk content")
323+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-uuid-123", chunkData).Return(int64(len(chunkData)), nil)
324+
325+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/upload-uuid-123", bytes.NewReader(chunkData))
326+ rec := httptest.NewRecorder()
327+
328+ handler.ServeHTTP(rec, req)
329+
330+ assert.Equal(t, http.StatusAccepted, rec.Code)
331+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/upload-uuid-123")
332+}
333+
334+func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
335+ registrySvc := inmocks.NewMockRegistryService(t)
336+ blobStorage := mocks.NewMockBlobStorage(t)
337+ eventBus := mocks.NewMockEventPublisher(t)
338+
339+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
340+
341+ chunkData := []byte("final chunk")
342+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-uuid-123", chunkData).Return(int64(len(chunkData)), nil)
343+ registrySvc.EXPECT().FinishUpload(mock.Anything, "upload-uuid-123", "sha256:abc123").Return(nil)
344+
345+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/upload-uuid-123?digest=sha256:abc123", bytes.NewReader(chunkData))
346+ rec := httptest.NewRecorder()
347+
348+ handler.ServeHTTP(rec, req)
349+
350+ assert.Equal(t, http.StatusCreated, rec.Code)
351+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/sha256:abc123")
352+ assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest"))
353+}
354+
355+func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
356+ registrySvc := inmocks.NewMockRegistryService(t)
357+ blobStorage := mocks.NewMockBlobStorage(t)
358+ eventBus := mocks.NewMockEventPublisher(t)
359+
360+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
361+
362+ chunkData := []byte("final chunk")
363+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-uuid-123", chunkData).Return(int64(len(chunkData)), nil)
364+ registrySvc.EXPECT().FinishUpload(mock.Anything, "upload-uuid-123", "sha256:wrong").Return(assert.AnError)
365+ registrySvc.EXPECT().CancelUpload(mock.Anything, "upload-uuid-123").Return(nil)
366+
367+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/upload-uuid-123?digest=sha256:wrong", bytes.NewReader(chunkData))
368+ rec := httptest.NewRecorder()
369+
370+ handler.ServeHTTP(rec, req)
371+
372+ assert.Equal(t, http.StatusBadRequest, rec.Code)
373+}
374+
375+func TestHandler_ListTags_Success(t *testing.T) {
376+ registrySvc := inmocks.NewMockRegistryService(t)
377+ blobStorage := mocks.NewMockBlobStorage(t)
378+ eventBus := mocks.NewMockEventPublisher(t)
379+
380+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
381+
382+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
383+
384+ req := httptest.NewRequest("GET", "/v2/myapp/tags/list", nil)
385+ rec := httptest.NewRecorder()
386+
387+ handler.ServeHTTP(rec, req)
388+
389+ assert.Equal(t, http.StatusOK, rec.Code)
390+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
391+ assert.Contains(t, rec.Body.String(), "myapp")
392+ assert.Contains(t, rec.Body.String(), "latest")
393+}
394+
395+func TestHandler_ListTags_NotFound(t *testing.T) {
396+ registrySvc := inmocks.NewMockRegistryService(t)
397+ blobStorage := mocks.NewMockBlobStorage(t)
398+ eventBus := mocks.NewMockEventPublisher(t)
399+
400+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
401+
402+ registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)
403+
404+ req := httptest.NewRequest("GET", "/v2/notexists/tags/list", nil)
405+ rec := httptest.NewRecorder()
406+
407+ handler.ServeHTTP(rec, req)
408+
409+ assert.Equal(t, http.StatusNotFound, rec.Code)
410+}
411+
412+func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
413+ registrySvc := inmocks.NewMockRegistryService(t)
414+ blobStorage := mocks.NewMockBlobStorage(t)
415+ eventBus := mocks.NewMockEventPublisher(t)
416+
417+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
418+
419+ req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)
420+ rec := httptest.NewRecorder()
421+
422+ handler.ServeHTTP(rec, req)
423+
424+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
425+}
426+
427+func TestHandler_RegisterRoutes(t *testing.T) {
428+ registrySvc := inmocks.NewMockRegistryService(t)
429+ blobStorage := mocks.NewMockBlobStorage(t)
430+ eventBus := mocks.NewMockEventPublisher(t)
431+
432+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
433+
434+ mux := http.NewServeMux()
435+ handler.RegisterRoutes(mux)
436+
437+ // Verify routes are registered by making a request through the mux
438+ req := httptest.NewRequest("GET", "/v2/", nil)
439+ rec := httptest.NewRecorder()
440+
441+ mux.ServeHTTP(rec, req)
442+
443+ assert.Equal(t, http.StatusOK, rec.Code)
444+}
@@ -0,0 +1,444 @@
1+package registry
2+
3+import (
4+ "bytes"
5+ "net/http"
6+ "net/http/httptest"
7+ "os"
8+ "testing"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+
14+ inmocks "gordon/internal/boundaries/in/mocks"
15+ "gordon/internal/boundaries/out/mocks"
16+ "gordon/internal/domain"
17+)
18+
19+func testLogger() zerowrap.Logger {
20+ return zerowrap.Default()
21+}
22+
23+func TestHandler_Base(t *testing.T) {
24+ registrySvc := inmocks.NewMockRegistryService(t)
25+ blobStorage := mocks.NewMockBlobStorage(t)
26+ eventBus := mocks.NewMockEventPublisher(t)
27+
28+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
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+
39+func TestHandler_NotFound(t *testing.T) {
40+ registrySvc := inmocks.NewMockRegistryService(t)
41+ blobStorage := mocks.NewMockBlobStorage(t)
42+ eventBus := mocks.NewMockEventPublisher(t)
43+
44+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
45+
46+ req := httptest.NewRequest("GET", "/v2/unknown/path", nil)
47+ rec := httptest.NewRecorder()
48+
49+ handler.ServeHTTP(rec, req)
50+
51+ assert.Equal(t, http.StatusNotFound, rec.Code)
52+}
53+
54+func TestHandler_GetManifest_Success(t *testing.T) {
55+ registrySvc := inmocks.NewMockRegistryService(t)
56+ blobStorage := mocks.NewMockBlobStorage(t)
57+ eventBus := mocks.NewMockEventPublisher(t)
58+
59+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
60+
61+ manifestData := []byte(`{"schemaVersion": 2}`)
62+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
63+ Name: "myapp",
64+ Reference: "latest",
65+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
66+ Data: manifestData,
67+ }, nil)
68+
69+ req := httptest.NewRequest("GET", "/v2/myapp/manifests/latest", nil)
70+ rec := httptest.NewRecorder()
71+
72+ handler.ServeHTTP(rec, req)
73+
74+ assert.Equal(t, http.StatusOK, rec.Code)
75+ assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", rec.Header().Get("Content-Type"))
76+ assert.Equal(t, manifestData, rec.Body.Bytes())
77+}
78+
79+func TestHandler_GetManifest_HEAD(t *testing.T) {
80+ registrySvc := inmocks.NewMockRegistryService(t)
81+ blobStorage := mocks.NewMockBlobStorage(t)
82+ eventBus := mocks.NewMockEventPublisher(t)
83+
84+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
85+
86+ manifestData := []byte(`{"schemaVersion": 2}`)
87+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(&domain.Manifest{
88+ Name: "myapp",
89+ Reference: "latest",
90+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
91+ Data: manifestData,
92+ }, nil)
93+
94+ req := httptest.NewRequest("HEAD", "/v2/myapp/manifests/latest", nil)
95+ rec := httptest.NewRecorder()
96+
97+ handler.ServeHTTP(rec, req)
98+
99+ assert.Equal(t, http.StatusOK, rec.Code)
100+ assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", rec.Header().Get("Content-Type"))
101+ // HEAD should not return body
102+ assert.Empty(t, rec.Body.Bytes())
103+}
104+
105+func TestHandler_GetManifest_NotFound(t *testing.T) {
106+ registrySvc := inmocks.NewMockRegistryService(t)
107+ blobStorage := mocks.NewMockBlobStorage(t)
108+ eventBus := mocks.NewMockEventPublisher(t)
109+
110+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
111+
112+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "notexists").Return(nil, assert.AnError)
113+
114+ req := httptest.NewRequest("GET", "/v2/myapp/manifests/notexists", nil)
115+ rec := httptest.NewRecorder()
116+
117+ handler.ServeHTTP(rec, req)
118+
119+ assert.Equal(t, http.StatusNotFound, rec.Code)
120+}
121+
122+func TestHandler_GetManifest_NestedName(t *testing.T) {
123+ registrySvc := inmocks.NewMockRegistryService(t)
124+ blobStorage := mocks.NewMockBlobStorage(t)
125+ eventBus := mocks.NewMockEventPublisher(t)
126+
127+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
128+
129+ manifestData := []byte(`{"schemaVersion": 2}`)
130+ registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{
131+ Name: "org/project/app",
132+ Reference: "v1.0",
133+ ContentType: "application/vnd.docker.distribution.manifest.v2+json",
134+ Data: manifestData,
135+ }, nil)
136+
137+ req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil)
138+ rec := httptest.NewRecorder()
139+
140+ handler.ServeHTTP(rec, req)
141+
142+ assert.Equal(t, http.StatusOK, rec.Code)
143+}
144+
145+func TestHandler_PutManifest_Success(t *testing.T) {
146+ registrySvc := inmocks.NewMockRegistryService(t)
147+ blobStorage := mocks.NewMockBlobStorage(t)
148+ eventBus := mocks.NewMockEventPublisher(t)
149+
150+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
151+
152+ manifestData := []byte(`{"schemaVersion": 2}`)
153+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool {
154+ return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json"
155+ })).Return(nil)
156+
157+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
158+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
159+ rec := httptest.NewRecorder()
160+
161+ handler.ServeHTTP(rec, req)
162+
163+ assert.Equal(t, http.StatusCreated, rec.Code)
164+ assert.NotEmpty(t, rec.Header().Get("Docker-Content-Digest"))
165+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest")
166+}
167+
168+func TestHandler_PutManifest_MissingContentType(t *testing.T) {
169+ registrySvc := inmocks.NewMockRegistryService(t)
170+ blobStorage := mocks.NewMockBlobStorage(t)
171+ eventBus := mocks.NewMockEventPublisher(t)
172+
173+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
174+
175+ manifestData := []byte(`{"schemaVersion": 2}`)
176+
177+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
178+ // No Content-Type header
179+ rec := httptest.NewRecorder()
180+
181+ handler.ServeHTTP(rec, req)
182+
183+ assert.Equal(t, http.StatusBadRequest, rec.Code)
184+}
185+
186+func TestHandler_PutManifest_StorageError(t *testing.T) {
187+ registrySvc := inmocks.NewMockRegistryService(t)
188+ blobStorage := mocks.NewMockBlobStorage(t)
189+ eventBus := mocks.NewMockEventPublisher(t)
190+
191+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
192+
193+ manifestData := []byte(`{"schemaVersion": 2}`)
194+ registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return(assert.AnError)
195+
196+ req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData))
197+ req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
198+ rec := httptest.NewRecorder()
199+
200+ handler.ServeHTTP(rec, req)
201+
202+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
203+}
204+
205+func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) {
206+ registrySvc := inmocks.NewMockRegistryService(t)
207+ blobStorage := mocks.NewMockBlobStorage(t)
208+ eventBus := mocks.NewMockEventPublisher(t)
209+
210+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
211+
212+ req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil)
213+ rec := httptest.NewRecorder()
214+
215+ handler.ServeHTTP(rec, req)
216+
217+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
218+}
219+
220+func TestHandler_GetBlob_Success(t *testing.T) {
221+ registrySvc := inmocks.NewMockRegistryService(t)
222+ blobStorage := mocks.NewMockBlobStorage(t)
223+ eventBus := mocks.NewMockEventPublisher(t)
224+
225+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
226+
227+ // Create a temp file for http.ServeFile
228+ tmpFile, err := os.CreateTemp("", "blob-*")
229+ assert.NoError(t, err)
230+ defer tmpFile.Close()
231+
232+ blobContent := []byte("blob content here")
233+ _, err = tmpFile.Write(blobContent)
234+ assert.NoError(t, err)
235+
236+ blobStorage.EXPECT().GetBlobPath("sha256:abc123").Return(tmpFile.Name(), nil)
237+
238+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:abc123", nil)
239+ rec := httptest.NewRecorder()
240+
241+ handler.ServeHTTP(rec, req)
242+
243+ assert.Equal(t, http.StatusOK, rec.Code)
244+ assert.Equal(t, blobContent, rec.Body.Bytes())
245+}
246+
247+func TestHandler_GetBlob_NotFound(t *testing.T) {
248+ registrySvc := inmocks.NewMockRegistryService(t)
249+ blobStorage := mocks.NewMockBlobStorage(t)
250+ eventBus := mocks.NewMockEventPublisher(t)
251+
252+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
253+
254+ blobStorage.EXPECT().GetBlobPath("sha256:notexists").Return("", assert.AnError)
255+
256+ req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:notexists", nil)
257+ rec := httptest.NewRecorder()
258+
259+ handler.ServeHTTP(rec, req)
260+
261+ assert.Equal(t, http.StatusNotFound, rec.Code)
262+}
263+
264+func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) {
265+ registrySvc := inmocks.NewMockRegistryService(t)
266+ blobStorage := mocks.NewMockBlobStorage(t)
267+ eventBus := mocks.NewMockEventPublisher(t)
268+
269+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
270+
271+ req := httptest.NewRequest("POST", "/v2/myapp/blobs/sha256:abc123", nil)
272+ rec := httptest.NewRecorder()
273+
274+ handler.ServeHTTP(rec, req)
275+
276+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
277+}
278+
279+func TestHandler_StartBlobUpload_Success(t *testing.T) {
280+ registrySvc := inmocks.NewMockRegistryService(t)
281+ blobStorage := mocks.NewMockBlobStorage(t)
282+ eventBus := mocks.NewMockEventPublisher(t)
283+
284+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
285+
286+ registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("upload-uuid-123", nil)
287+
288+ req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
289+ rec := httptest.NewRecorder()
290+
291+ handler.ServeHTTP(rec, req)
292+
293+ assert.Equal(t, http.StatusAccepted, rec.Code)
294+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/upload-uuid-123")
295+ assert.Equal(t, "0-0", rec.Header().Get("Range"))
296+}
297+
298+func TestHandler_StartBlobUpload_Error(t *testing.T) {
299+ registrySvc := inmocks.NewMockRegistryService(t)
300+ blobStorage := mocks.NewMockBlobStorage(t)
301+ eventBus := mocks.NewMockEventPublisher(t)
302+
303+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
304+
305+ registrySvc.EXPECT().StartUpload(mock.Anything, "myapp").Return("", assert.AnError)
306+
307+ req := httptest.NewRequest("POST", "/v2/myapp/blobs/uploads/", nil)
308+ rec := httptest.NewRecorder()
309+
310+ handler.ServeHTTP(rec, req)
311+
312+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
313+}
314+
315+func TestHandler_BlobUpload_PATCH(t *testing.T) {
316+ registrySvc := inmocks.NewMockRegistryService(t)
317+ blobStorage := mocks.NewMockBlobStorage(t)
318+ eventBus := mocks.NewMockEventPublisher(t)
319+
320+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
321+
322+ chunkData := []byte("chunk content")
323+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-uuid-123", chunkData).Return(int64(len(chunkData)), nil)
324+
325+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/upload-uuid-123", bytes.NewReader(chunkData))
326+ rec := httptest.NewRecorder()
327+
328+ handler.ServeHTTP(rec, req)
329+
330+ assert.Equal(t, http.StatusAccepted, rec.Code)
331+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/uploads/upload-uuid-123")
332+}
333+
334+func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
335+ registrySvc := inmocks.NewMockRegistryService(t)
336+ blobStorage := mocks.NewMockBlobStorage(t)
337+ eventBus := mocks.NewMockEventPublisher(t)
338+
339+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
340+
341+ chunkData := []byte("final chunk")
342+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-uuid-123", chunkData).Return(int64(len(chunkData)), nil)
343+ registrySvc.EXPECT().FinishUpload(mock.Anything, "upload-uuid-123", "sha256:abc123").Return(nil)
344+
345+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/upload-uuid-123?digest=sha256:abc123", bytes.NewReader(chunkData))
346+ rec := httptest.NewRecorder()
347+
348+ handler.ServeHTTP(rec, req)
349+
350+ assert.Equal(t, http.StatusCreated, rec.Code)
351+ assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/blobs/sha256:abc123")
352+ assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest"))
353+}
354+
355+func TestHandler_BlobUpload_PUT_DigestMismatch(t *testing.T) {
356+ registrySvc := inmocks.NewMockRegistryService(t)
357+ blobStorage := mocks.NewMockBlobStorage(t)
358+ eventBus := mocks.NewMockEventPublisher(t)
359+
360+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
361+
362+ chunkData := []byte("final chunk")
363+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-uuid-123", chunkData).Return(int64(len(chunkData)), nil)
364+ registrySvc.EXPECT().FinishUpload(mock.Anything, "upload-uuid-123", "sha256:wrong").Return(assert.AnError)
365+ registrySvc.EXPECT().CancelUpload(mock.Anything, "upload-uuid-123").Return(nil)
366+
367+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/upload-uuid-123?digest=sha256:wrong", bytes.NewReader(chunkData))
368+ rec := httptest.NewRecorder()
369+
370+ handler.ServeHTTP(rec, req)
371+
372+ assert.Equal(t, http.StatusBadRequest, rec.Code)
373+}
374+
375+func TestHandler_ListTags_Success(t *testing.T) {
376+ registrySvc := inmocks.NewMockRegistryService(t)
377+ blobStorage := mocks.NewMockBlobStorage(t)
378+ eventBus := mocks.NewMockEventPublisher(t)
379+
380+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
381+
382+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0", "v2.0"}, nil)
383+
384+ req := httptest.NewRequest("GET", "/v2/myapp/tags/list", nil)
385+ rec := httptest.NewRecorder()
386+
387+ handler.ServeHTTP(rec, req)
388+
389+ assert.Equal(t, http.StatusOK, rec.Code)
390+ assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
391+ assert.Contains(t, rec.Body.String(), "myapp")
392+ assert.Contains(t, rec.Body.String(), "latest")
393+}
394+
395+func TestHandler_ListTags_NotFound(t *testing.T) {
396+ registrySvc := inmocks.NewMockRegistryService(t)
397+ blobStorage := mocks.NewMockBlobStorage(t)
398+ eventBus := mocks.NewMockEventPublisher(t)
399+
400+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
401+
402+ registrySvc.EXPECT().ListTags(mock.Anything, "notexists").Return(nil, assert.AnError)
403+
404+ req := httptest.NewRequest("GET", "/v2/notexists/tags/list", nil)
405+ rec := httptest.NewRecorder()
406+
407+ handler.ServeHTTP(rec, req)
408+
409+ assert.Equal(t, http.StatusNotFound, rec.Code)
410+}
411+
412+func TestHandler_ListTags_MethodNotAllowed(t *testing.T) {
413+ registrySvc := inmocks.NewMockRegistryService(t)
414+ blobStorage := mocks.NewMockBlobStorage(t)
415+ eventBus := mocks.NewMockEventPublisher(t)
416+
417+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
418+
419+ req := httptest.NewRequest("POST", "/v2/myapp/tags/list", nil)
420+ rec := httptest.NewRecorder()
421+
422+ handler.ServeHTTP(rec, req)
423+
424+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
425+}
426+
427+func TestHandler_RegisterRoutes(t *testing.T) {
428+ registrySvc := inmocks.NewMockRegistryService(t)
429+ blobStorage := mocks.NewMockBlobStorage(t)
430+ eventBus := mocks.NewMockEventPublisher(t)
431+
432+ handler := NewHandler(registrySvc, blobStorage, eventBus, testLogger())
433+
434+ mux := http.NewServeMux()
435+ handler.RegisterRoutes(mux)
436+
437+ // Verify routes are registered by making a request through the mux
438+ req := httptest.NewRequest("GET", "/v2/", nil)
439+ rec := httptest.NewRecorder()
440+
441+ mux.ServeHTTP(rec, req)
442+
443+ assert.Equal(t, http.StatusOK, rec.Code)
444+}

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