1package admin
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/http/httptest"
12 "net/url"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/bnema/zerowrap"
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/mock"
20 "github.com/stretchr/testify/require"
21
22 "github.com/bnema/gordon/internal/adapters/dto"
23 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
24 "github.com/bnema/gordon/internal/domain"
25)
26
27type stubImageService struct {
28 listImagesFunc func(context.Context) ([]domain.ImageInfo, error)
29 pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)
30}
31
32type noopReloadTrigger struct{}
33
34func (noopReloadTrigger) Trigger(context.Context) error { return nil }
35
36type reloadTriggerRecorder struct {
37 calls int
38 err error
39 ctxErrAtCall error
40 hasDeadline bool
41}
42
43func (r *reloadTriggerRecorder) Trigger(ctx context.Context) error {
44 r.calls++
45 r.ctxErrAtCall = ctx.Err()
46 _, r.hasDeadline = ctx.Deadline()
47 return r.err
48}
49
50func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
51 if s.listImagesFunc == nil {
52 return nil, nil
53 }
54 return s.listImagesFunc(ctx)
55}
56
57func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
58 if s.pruneFunc == nil {
59 return domain.ImagePruneReport{}, nil
60 }
61 return s.pruneFunc(ctx, opts)
62}
63
64func testLogger() zerowrap.Logger {
65 return zerowrap.Default()
66}
67
68func ctxWithScopes(scopes ...string) context.Context {
69 ctx := context.Background()
70 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
71}
72
73func newScopedTestServer(t *testing.T, handler http.Handler, scopes ...string) *httptest.Server {
74 t.Helper()
75
76 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77 handler.ServeHTTP(w, r.WithContext(ctxWithScopes(scopes...)))
78 }))
79 t.Cleanup(server.Close)
80
81 return server
82}
83
84func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {
85 t.Helper()
86 deps := HandlerDeps{
87 ConfigSvc: inmocks.NewMockConfigService(t),
88 AuthSvc: inmocks.NewMockAuthService(t),
89 ContainerSvc: inmocks.NewMockContainerService(t),
90 HealthSvc: inmocks.NewMockHealthService(t),
91 SecretSvc: inmocks.NewMockSecretService(t),
92 Log: testLogger(),
93 ReloadTrigger: noopReloadTrigger{},
94 }
95 for _, opt := range opts {
96 opt(&deps)
97 }
98 return NewHandler(deps)
99}
100
101// Routes endpoint tests
102
103func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
104 configSvc := inmocks.NewMockConfigService(t)
105 authSvc := inmocks.NewMockAuthService(t)
106 containerSvc := inmocks.NewMockContainerService(t)
107 secretSvc := inmocks.NewMockSecretService(t)
108
109 handler := newTestHandler(t, func(d *HandlerDeps) {
110 d.ConfigSvc = configSvc
111 d.AuthSvc = authSvc
112 d.ContainerSvc = containerSvc
113 d.SecretSvc = secretSvc
114 d.RegistrySvc = inmocks.NewMockRegistryService(t)
115 })
116
117 tests := []struct {
118 name string
119 scopes []string
120 wantStatus int
121 }{
122 {
123 name: "routes read access granted",
124 scopes: []string{"admin:routes:read"},
125 wantStatus: http.StatusOK,
126 },
127 {
128 name: "routes wildcard access granted",
129 scopes: []string{"admin:routes:*"},
130 wantStatus: http.StatusOK,
131 },
132 {
133 name: "all admin access granted",
134 scopes: []string{"admin:*:*"},
135 wantStatus: http.StatusOK,
136 },
137 {
138 name: "only write access denied",
139 scopes: []string{"admin:routes:write"},
140 wantStatus: http.StatusForbidden,
141 },
142 {
143 name: "wrong resource denied",
144 scopes: []string{"admin:secrets:read"},
145 wantStatus: http.StatusForbidden,
146 },
147 {
148 name: "no scopes denied",
149 scopes: []string{},
150 wantStatus: http.StatusForbidden,
151 },
152 }
153
154 for _, tt := range tests {
155 t.Run(tt.name, func(t *testing.T) {
156 if tt.wantStatus == http.StatusOK {
157 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
158 }
159
160 req := httptest.NewRequest("GET", "/admin/routes", nil)
161 req = req.WithContext(ctxWithScopes(tt.scopes...))
162 rec := httptest.NewRecorder()
163
164 handler.ServeHTTP(rec, req)
165
166 assert.Equal(t, tt.wantStatus, rec.Code)
167 })
168 }
169}
170
171func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
172 configSvc := inmocks.NewMockConfigService(t)
173 authSvc := inmocks.NewMockAuthService(t)
174 containerSvc := inmocks.NewMockContainerService(t)
175 secretSvc := inmocks.NewMockSecretService(t)
176
177 handler := newTestHandler(t, func(d *HandlerDeps) {
178 d.ConfigSvc = configSvc
179 d.AuthSvc = authSvc
180 d.ContainerSvc = containerSvc
181 d.SecretSvc = secretSvc
182 })
183
184 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
185
186 tests := []struct {
187 name string
188 scopes []string
189 wantStatus int
190 }{
191 {
192 name: "routes write access granted",
193 scopes: []string{"admin:routes:write"},
194 wantStatus: http.StatusCreated,
195 },
196 {
197 name: "routes wildcard access granted",
198 scopes: []string{"admin:routes:*"},
199 wantStatus: http.StatusCreated,
200 },
201 {
202 name: "all admin access granted",
203 scopes: []string{"admin:*:*"},
204 wantStatus: http.StatusCreated,
205 },
206 {
207 name: "only read access denied",
208 scopes: []string{"admin:routes:read"},
209 wantStatus: http.StatusForbidden,
210 },
211 {
212 name: "wrong resource denied",
213 scopes: []string{"admin:secrets:write"},
214 wantStatus: http.StatusForbidden,
215 },
216 }
217
218 for _, tt := range tests {
219 t.Run(tt.name, func(t *testing.T) {
220 if tt.wantStatus == http.StatusCreated {
221 configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
222 }
223
224 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
225 req = req.WithContext(ctxWithScopes(tt.scopes...))
226 rec := httptest.NewRecorder()
227
228 handler.ServeHTTP(rec, req)
229
230 assert.Equal(t, tt.wantStatus, rec.Code)
231 })
232 }
233}
234
235func TestHandler_RoutesPost_InvalidRouteDomain(t *testing.T) {
236 configSvc := inmocks.NewMockConfigService(t)
237 authSvc := inmocks.NewMockAuthService(t)
238 containerSvc := inmocks.NewMockContainerService(t)
239 secretSvc := inmocks.NewMockSecretService(t)
240
241 handler := newTestHandler(t, func(d *HandlerDeps) {
242 d.ConfigSvc = configSvc
243 d.AuthSvc = authSvc
244 d.ContainerSvc = containerSvc
245 d.SecretSvc = secretSvc
246 })
247 server := newScopedTestServer(t, handler, "admin:routes:write")
248
249 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "localhost", Image: "myapp:latest", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once()
250
251 req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"localhost","image":"myapp:latest"}`))
252 require.NoError(t, err)
253
254 resp, err := server.Client().Do(req)
255 require.NoError(t, err)
256 defer resp.Body.Close()
257
258 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
259 body, err := io.ReadAll(resp.Body)
260 require.NoError(t, err)
261 assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`, string(body))
262}
263
264func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
265 configSvc := inmocks.NewMockConfigService(t)
266 authSvc := inmocks.NewMockAuthService(t)
267 containerSvc := inmocks.NewMockContainerService(t)
268 secretSvc := inmocks.NewMockSecretService(t)
269
270 handler := newTestHandler(t, func(d *HandlerDeps) {
271 d.ConfigSvc = configSvc
272 d.AuthSvc = authSvc
273 d.ContainerSvc = containerSvc
274 d.SecretSvc = secretSvc
275 })
276
277 routeJSON := `{"image": "myapp:v2"}`
278
279 tests := []struct {
280 name string
281 scopes []string
282 wantStatus int
283 }{
284 {
285 name: "routes write access granted",
286 scopes: []string{"admin:routes:write"},
287 wantStatus: http.StatusOK,
288 },
289 {
290 name: "only read access denied",
291 scopes: []string{"admin:routes:read"},
292 wantStatus: http.StatusForbidden,
293 },
294 }
295
296 for _, tt := range tests {
297 t.Run(tt.name, func(t *testing.T) {
298 if tt.wantStatus == http.StatusOK {
299 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:v1", HTTPS: true}, nil).Maybe()
300 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
301 }
302
303 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
304 req = req.WithContext(ctxWithScopes(tt.scopes...))
305 rec := httptest.NewRecorder()
306
307 handler.ServeHTTP(rec, req)
308
309 assert.Equal(t, tt.wantStatus, rec.Code)
310 })
311 }
312}
313
314func TestHandler_RoutesPut_InvalidRouteDomain(t *testing.T) {
315 configSvc := inmocks.NewMockConfigService(t)
316 authSvc := inmocks.NewMockAuthService(t)
317 containerSvc := inmocks.NewMockContainerService(t)
318 secretSvc := inmocks.NewMockSecretService(t)
319
320 handler := newTestHandler(t, func(d *HandlerDeps) {
321 d.ConfigSvc = configSvc
322 d.AuthSvc = authSvc
323 d.ContainerSvc = containerSvc
324 d.SecretSvc = secretSvc
325 })
326 server := newScopedTestServer(t, handler, "admin:routes:write")
327
328 configSvc.EXPECT().GetRoute(mock.Anything, "localhost").Return(&domain.Route{Domain: "localhost", Image: "myapp:v1", HTTPS: true}, nil).Once()
329 configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "localhost", Image: "myapp:v2", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once()
330
331 req, err := http.NewRequest(http.MethodPut, server.URL+"/admin/routes/localhost", bytes.NewBufferString(`{"image":"myapp:v2"}`))
332 require.NoError(t, err)
333
334 resp, err := server.Client().Do(req)
335 require.NoError(t, err)
336 defer resp.Body.Close()
337
338 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
339 body, err := io.ReadAll(resp.Body)
340 require.NoError(t, err)
341 assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`, string(body))
342}
343
344func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) {
345 configSvc := inmocks.NewMockConfigService(t)
346 authSvc := inmocks.NewMockAuthService(t)
347 containerSvc := inmocks.NewMockContainerService(t)
348 secretSvc := inmocks.NewMockSecretService(t)
349 registrySvc := inmocks.NewMockRegistryService(t)
350 route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}
351
352 handler := newTestHandler(t, func(d *HandlerDeps) {
353 d.ConfigSvc = configSvc
354 d.AuthSvc = authSvc
355 d.ContainerSvc = containerSvc
356 d.SecretSvc = secretSvc
357 d.RegistrySvc = registrySvc
358 })
359
360 configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()
361
362 server := newScopedTestServer(t, handler, "admin:routes:write")
363 req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
364 require.NoError(t, err)
365
366 resp, err := server.Client().Do(req)
367 require.NoError(t, err)
368 defer resp.Body.Close()
369
370 body, err := io.ReadAll(resp.Body)
371 require.NoError(t, err)
372 assert.Equal(t, http.StatusCreated, resp.StatusCode)
373 assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":true}`, string(body))
374 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
375}
376
377func TestHandleRoutesPost_DefaultsHTTPSWhenOmitted(t *testing.T) {
378 configSvc := inmocks.NewMockConfigService(t)
379 authSvc := inmocks.NewMockAuthService(t)
380 containerSvc := inmocks.NewMockContainerService(t)
381 secretSvc := inmocks.NewMockSecretService(t)
382
383 handler := newTestHandler(t, func(d *HandlerDeps) {
384 d.ConfigSvc = configSvc
385 d.AuthSvc = authSvc
386 d.ContainerSvc = containerSvc
387 d.SecretSvc = secretSvc
388 })
389
390 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil).Once()
391
392 server := newScopedTestServer(t, handler, "admin:routes:write")
393 req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
394 require.NoError(t, err)
395
396 resp, err := server.Client().Do(req)
397 require.NoError(t, err)
398 defer resp.Body.Close()
399
400 body, err := io.ReadAll(resp.Body)
401 require.NoError(t, err)
402 assert.Equal(t, http.StatusCreated, resp.StatusCode)
403 assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":true}`, string(body))
404}
405
406func TestHandleRoutesPut_PreservesStoredHTTPSWhenOmitted(t *testing.T) {
407 configSvc := inmocks.NewMockConfigService(t)
408 authSvc := inmocks.NewMockAuthService(t)
409 containerSvc := inmocks.NewMockContainerService(t)
410 secretSvc := inmocks.NewMockSecretService(t)
411 registrySvc := inmocks.NewMockRegistryService(t)
412 route := domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: false}
413
414 handler := newTestHandler(t, func(d *HandlerDeps) {
415 d.ConfigSvc = configSvc
416 d.AuthSvc = authSvc
417 d.ContainerSvc = containerSvc
418 d.SecretSvc = secretSvc
419 d.RegistrySvc = registrySvc
420 })
421
422 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}, nil).Once()
423 configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()
424
425 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2"}`))
426 req = req.WithContext(ctxWithScopes("admin:routes:write"))
427 rec := httptest.NewRecorder()
428
429 handler.ServeHTTP(rec, req)
430
431 assert.Equal(t, http.StatusOK, rec.Code)
432 assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":false}`, rec.Body.String())
433 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
434}
435
436func TestHandleRoutesPut_OverridesStoredHTTPSWhenProvided(t *testing.T) {
437 configSvc := inmocks.NewMockConfigService(t)
438 authSvc := inmocks.NewMockAuthService(t)
439 containerSvc := inmocks.NewMockContainerService(t)
440 secretSvc := inmocks.NewMockSecretService(t)
441
442 handler := newTestHandler(t, func(d *HandlerDeps) {
443 d.ConfigSvc = configSvc
444 d.AuthSvc = authSvc
445 d.ContainerSvc = containerSvc
446 d.SecretSvc = secretSvc
447 })
448
449 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}, nil).Once()
450 configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(nil).Once()
451
452 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2","https":true}`))
453 req = req.WithContext(ctxWithScopes("admin:routes:write"))
454 rec := httptest.NewRecorder()
455
456 handler.ServeHTTP(rec, req)
457
458 assert.Equal(t, http.StatusOK, rec.Code)
459 assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":true}`, rec.Body.String())
460}
461
462func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) {
463 configSvc := inmocks.NewMockConfigService(t)
464 authSvc := inmocks.NewMockAuthService(t)
465 containerSvc := inmocks.NewMockContainerService(t)
466 secretSvc := inmocks.NewMockSecretService(t)
467
468 handler := newTestHandler(t, func(d *HandlerDeps) {
469 d.ConfigSvc = configSvc
470 d.AuthSvc = authSvc
471 d.ContainerSvc = containerSvc
472 d.SecretSvc = secretSvc
473 })
474
475 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once()
476
477 req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil)
478 req = req.WithContext(ctxWithScopes("admin:config:read"))
479 rec := httptest.NewRecorder()
480
481 handler.ServeHTTP(rec, req)
482
483 assert.Equal(t, http.StatusOK, rec.Code)
484 assert.JSONEq(t, `{"image":"postgres:16","targets":["app.example.com"]}`, rec.Body.String())
485}
486
487func TestHandler_AttachmentsByImageGet_MultipleTargets(t *testing.T) {
488 configSvc := inmocks.NewMockConfigService(t)
489 authSvc := inmocks.NewMockAuthService(t)
490 containerSvc := inmocks.NewMockContainerService(t)
491 secretSvc := inmocks.NewMockSecretService(t)
492
493 handler := newTestHandler(t, func(d *HandlerDeps) {
494 d.ConfigSvc = configSvc
495 d.AuthSvc = authSvc
496 d.ContainerSvc = containerSvc
497 d.SecretSvc = secretSvc
498 })
499
500 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once()
501
502 req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres", nil)
503 req = req.WithContext(ctxWithScopes("admin:config:read"))
504 rec := httptest.NewRecorder()
505
506 handler.ServeHTTP(rec, req)
507
508 assert.Equal(t, http.StatusOK, rec.Code)
509 assert.JSONEq(t, `{"image":"postgres","targets":["app.example.com","workers"]}`, rec.Body.String())
510}
511
512func TestHandler_AttachmentsByImageGet_URLDecodedImage(t *testing.T) {
513 configSvc := inmocks.NewMockConfigService(t)
514 authSvc := inmocks.NewMockAuthService(t)
515 containerSvc := inmocks.NewMockContainerService(t)
516 secretSvc := inmocks.NewMockSecretService(t)
517
518 handler := newTestHandler(t, func(d *HandlerDeps) {
519 d.ConfigSvc = configSvc
520 d.AuthSvc = authSvc
521 d.ContainerSvc = containerSvc
522 d.SecretSvc = secretSvc
523 })
524
525 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once()
526
527 req := httptest.NewRequest("GET", "/admin/attachments/by-image/registry%2Forg%2Fimage:tag", nil)
528 req = req.WithContext(ctxWithScopes("admin:config:read"))
529 rec := httptest.NewRecorder()
530
531 handler.ServeHTTP(rec, req)
532
533 assert.Equal(t, http.StatusOK, rec.Code)
534 assert.JSONEq(t, `{"image":"registry/org/image:tag","targets":["app.example.com"]}`, rec.Body.String())
535}
536
537func TestHandler_AttachmentsByImageGet_EmptyResultSet(t *testing.T) {
538 configSvc := inmocks.NewMockConfigService(t)
539 authSvc := inmocks.NewMockAuthService(t)
540 containerSvc := inmocks.NewMockContainerService(t)
541 secretSvc := inmocks.NewMockSecretService(t)
542
543 handler := newTestHandler(t, func(d *HandlerDeps) {
544 d.ConfigSvc = configSvc
545 d.AuthSvc = authSvc
546 d.ContainerSvc = containerSvc
547 d.SecretSvc = secretSvc
548 })
549
550 configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once()
551
552 req := httptest.NewRequest("GET", "/admin/attachments/by-image/missing:latest", nil)
553 req = req.WithContext(ctxWithScopes("admin:config:read"))
554 rec := httptest.NewRecorder()
555
556 handler.ServeHTTP(rec, req)
557
558 assert.Equal(t, http.StatusOK, rec.Code)
559 assert.JSONEq(t, `{"image":"missing:latest","targets":[]}`, rec.Body.String())
560}
561
562func TestHandler_AttachmentsByImageGet_MissingImagePath(t *testing.T) {
563 configSvc := inmocks.NewMockConfigService(t)
564 authSvc := inmocks.NewMockAuthService(t)
565 containerSvc := inmocks.NewMockContainerService(t)
566 secretSvc := inmocks.NewMockSecretService(t)
567
568 handler := newTestHandler(t, func(d *HandlerDeps) {
569 d.ConfigSvc = configSvc
570 d.AuthSvc = authSvc
571 d.ContainerSvc = containerSvc
572 d.SecretSvc = secretSvc
573 })
574
575 req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil)
576 req = req.WithContext(ctxWithScopes("admin:config:read"))
577 rec := httptest.NewRecorder()
578
579 handler.ServeHTTP(rec, req)
580
581 assert.Equal(t, http.StatusBadRequest, rec.Code)
582 assert.JSONEq(t, `{"error":"image name required in path"}`, rec.Body.String())
583}
584
585func TestHandler_AttachmentsByImageGet_InvalidEncoding(t *testing.T) {
586 configSvc := inmocks.NewMockConfigService(t)
587 authSvc := inmocks.NewMockAuthService(t)
588 containerSvc := inmocks.NewMockContainerService(t)
589 secretSvc := inmocks.NewMockSecretService(t)
590
591 handler := newTestHandler(t, func(d *HandlerDeps) {
592 d.ConfigSvc = configSvc
593 d.AuthSvc = authSvc
594 d.ContainerSvc = containerSvc
595 d.SecretSvc = secretSvc
596 })
597
598 req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil)
599 req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"}
600 req = req.WithContext(ctxWithScopes("admin:config:read"))
601 rec := httptest.NewRecorder()
602
603 handler.ServeHTTP(rec, req)
604
605 assert.Equal(t, http.StatusBadRequest, rec.Code)
606 assert.JSONEq(t, `{"error":"invalid image name encoding"}`, rec.Body.String())
607}
608
609func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) {
610 configSvc := inmocks.NewMockConfigService(t)
611 authSvc := inmocks.NewMockAuthService(t)
612 containerSvc := inmocks.NewMockContainerService(t)
613 secretSvc := inmocks.NewMockSecretService(t)
614
615 handler := newTestHandler(t, func(d *HandlerDeps) {
616 d.ConfigSvc = configSvc
617 d.AuthSvc = authSvc
618 d.ContainerSvc = containerSvc
619 d.SecretSvc = secretSvc
620 })
621
622 req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil)
623 req = req.WithContext(ctxWithScopes("admin:routes:read"))
624 rec := httptest.NewRecorder()
625
626 handler.ServeHTTP(rec, req)
627
628 assert.Equal(t, http.StatusForbidden, rec.Code)
629 assert.JSONEq(t, `{"error":"insufficient permissions for config:read"}`, rec.Body.String())
630}
631
632func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
633 configSvc := inmocks.NewMockConfigService(t)
634 authSvc := inmocks.NewMockAuthService(t)
635 containerSvc := inmocks.NewMockContainerService(t)
636 secretSvc := inmocks.NewMockSecretService(t)
637
638 handler := newTestHandler(t, func(d *HandlerDeps) {
639 d.ConfigSvc = configSvc
640 d.AuthSvc = authSvc
641 d.ContainerSvc = containerSvc
642 d.SecretSvc = secretSvc
643 })
644
645 tests := []struct {
646 name string
647 scopes []string
648 wantStatus int
649 }{
650 {
651 name: "routes write access granted",
652 scopes: []string{"admin:routes:write"},
653 wantStatus: http.StatusOK,
654 },
655 {
656 name: "only read access denied",
657 scopes: []string{"admin:routes:read"},
658 wantStatus: http.StatusForbidden,
659 },
660 }
661
662 for _, tt := range tests {
663 t.Run(tt.name, func(t *testing.T) {
664 if tt.wantStatus == http.StatusOK {
665 configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
666 }
667
668 req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
669 req = req.WithContext(ctxWithScopes(tt.scopes...))
670 rec := httptest.NewRecorder()
671
672 handler.ServeHTTP(rec, req)
673
674 assert.Equal(t, tt.wantStatus, rec.Code)
675 })
676 }
677}
678
679// Secrets endpoint tests
680
681func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
682 configSvc := inmocks.NewMockConfigService(t)
683 authSvc := inmocks.NewMockAuthService(t)
684 containerSvc := inmocks.NewMockContainerService(t)
685 secretSvc := inmocks.NewMockSecretService(t)
686
687 handler := newTestHandler(t, func(d *HandlerDeps) {
688 d.ConfigSvc = configSvc
689 d.AuthSvc = authSvc
690 d.ContainerSvc = containerSvc
691 d.SecretSvc = secretSvc
692 })
693
694 tests := []struct {
695 name string
696 scopes []string
697 wantStatus int
698 }{
699 {
700 name: "secrets read access granted",
701 scopes: []string{"admin:secrets:read"},
702 wantStatus: http.StatusOK,
703 },
704 {
705 name: "secrets wildcard access granted",
706 scopes: []string{"admin:secrets:*"},
707 wantStatus: http.StatusOK,
708 },
709 {
710 name: "all admin access granted",
711 scopes: []string{"admin:*:*"},
712 wantStatus: http.StatusOK,
713 },
714 {
715 name: "only write access denied",
716 scopes: []string{"admin:secrets:write"},
717 wantStatus: http.StatusForbidden,
718 },
719 {
720 name: "wrong resource denied",
721 scopes: []string{"admin:routes:read"},
722 wantStatus: http.StatusForbidden,
723 },
724 }
725
726 for _, tt := range tests {
727 t.Run(tt.name, func(t *testing.T) {
728 if tt.wantStatus == http.StatusOK {
729 secretSvc.EXPECT().ListKeysWithAttachments(mock.Anything, "app.example.com").Return([]string{}, nil, nil).Maybe()
730 }
731
732 req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
733 req = req.WithContext(ctxWithScopes(tt.scopes...))
734 rec := httptest.NewRecorder()
735
736 handler.ServeHTTP(rec, req)
737
738 assert.Equal(t, tt.wantStatus, rec.Code)
739 })
740 }
741}
742
743func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
744 configSvc := inmocks.NewMockConfigService(t)
745 authSvc := inmocks.NewMockAuthService(t)
746 containerSvc := inmocks.NewMockContainerService(t)
747 secretSvc := inmocks.NewMockSecretService(t)
748
749 handler := newTestHandler(t, func(d *HandlerDeps) {
750 d.ConfigSvc = configSvc
751 d.AuthSvc = authSvc
752 d.ContainerSvc = containerSvc
753 d.SecretSvc = secretSvc
754 })
755
756 secretsJSON := `{"API_KEY": "secret123"}`
757
758 tests := []struct {
759 name string
760 scopes []string
761 wantStatus int
762 }{
763 {
764 name: "secrets write access granted",
765 scopes: []string{"admin:secrets:write"},
766 wantStatus: http.StatusOK,
767 },
768 {
769 name: "only read access denied",
770 scopes: []string{"admin:secrets:read"},
771 wantStatus: http.StatusForbidden,
772 },
773 {
774 name: "wrong resource denied",
775 scopes: []string{"admin:routes:write"},
776 wantStatus: http.StatusForbidden,
777 },
778 }
779
780 for _, tt := range tests {
781 t.Run(tt.name, func(t *testing.T) {
782 if tt.wantStatus == http.StatusOK {
783 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe()
784 }
785
786 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
787 req = req.WithContext(ctxWithScopes(tt.scopes...))
788 rec := httptest.NewRecorder()
789
790 handler.ServeHTTP(rec, req)
791
792 assert.Equal(t, tt.wantStatus, rec.Code)
793 })
794 }
795}
796
797func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
798 configSvc := inmocks.NewMockConfigService(t)
799 authSvc := inmocks.NewMockAuthService(t)
800 containerSvc := inmocks.NewMockContainerService(t)
801 secretSvc := inmocks.NewMockSecretService(t)
802
803 handler := newTestHandler(t, func(d *HandlerDeps) {
804 d.ConfigSvc = configSvc
805 d.AuthSvc = authSvc
806 d.ContainerSvc = containerSvc
807 d.SecretSvc = secretSvc
808 })
809
810 tests := []struct {
811 name string
812 scopes []string
813 wantStatus int
814 }{
815 {
816 name: "secrets write access granted",
817 scopes: []string{"admin:secrets:write"},
818 wantStatus: http.StatusOK,
819 },
820 {
821 name: "only read access denied",
822 scopes: []string{"admin:secrets:read"},
823 wantStatus: http.StatusForbidden,
824 },
825 }
826
827 for _, tt := range tests {
828 t.Run(tt.name, func(t *testing.T) {
829 if tt.wantStatus == http.StatusOK {
830 secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe()
831 }
832
833 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
834 req = req.WithContext(ctxWithScopes(tt.scopes...))
835 rec := httptest.NewRecorder()
836
837 handler.ServeHTTP(rec, req)
838
839 assert.Equal(t, tt.wantStatus, rec.Code)
840 })
841 }
842}
843
844// Attachment secrets endpoint tests
845
846func TestHandler_AttachmentSecretsPost(t *testing.T) {
847 configSvc := inmocks.NewMockConfigService(t)
848 authSvc := inmocks.NewMockAuthService(t)
849 containerSvc := inmocks.NewMockContainerService(t)
850 secretSvc := inmocks.NewMockSecretService(t)
851
852 handler := newTestHandler(t, func(d *HandlerDeps) {
853 d.ConfigSvc = configSvc
854 d.AuthSvc = authSvc
855 d.ContainerSvc = containerSvc
856 d.SecretSvc = secretSvc
857 })
858
859 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
860
861 body := `{"REDIS_URL": "redis://localhost:6379"}`
862 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
863 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
864 rec := httptest.NewRecorder()
865
866 handler.ServeHTTP(rec, req)
867
868 assert.Equal(t, http.StatusOK, rec.Code)
869
870 var response struct {
871 Status string `json:"status"`
872 }
873 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
874 assert.Equal(t, "updated", response.Status)
875}
876
877func TestHandler_AttachmentSecretsDelete(t *testing.T) {
878 configSvc := inmocks.NewMockConfigService(t)
879 authSvc := inmocks.NewMockAuthService(t)
880 containerSvc := inmocks.NewMockContainerService(t)
881 secretSvc := inmocks.NewMockSecretService(t)
882
883 handler := newTestHandler(t, func(d *HandlerDeps) {
884 d.ConfigSvc = configSvc
885 d.AuthSvc = authSvc
886 d.ContainerSvc = containerSvc
887 d.SecretSvc = secretSvc
888 })
889
890 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
891
892 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
893 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
894 rec := httptest.NewRecorder()
895
896 handler.ServeHTTP(rec, req)
897
898 assert.Equal(t, http.StatusOK, rec.Code)
899
900 var response struct {
901 Status string `json:"status"`
902 }
903 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
904 assert.Equal(t, "deleted", response.Status)
905}
906
907func TestHandler_Bootstrap_HappyPath(t *testing.T) {
908 configSvc := inmocks.NewMockConfigService(t)
909 authSvc := inmocks.NewMockAuthService(t)
910 containerSvc := inmocks.NewMockContainerService(t)
911 secretSvc := inmocks.NewMockSecretService(t)
912 registrySvc := inmocks.NewMockRegistryService(t)
913
914 handler := newTestHandler(t, func(d *HandlerDeps) {
915 d.ConfigSvc = configSvc
916 d.AuthSvc = authSvc
917 d.ContainerSvc = containerSvc
918 d.SecretSvc = secretSvc
919 d.RegistrySvc = registrySvc
920 })
921
922 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
923 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
924 configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()
925 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
926 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
927
928 body, err := json.Marshal(dto.BootstrapRequest{
929 Domain: "app.example.com",
930 Image: "myapp:latest",
931 Attachments: []string{"postgres:16"},
932 Env: map[string]string{"APP_ENV": "prod"},
933 AttachmentEnv: map[string]map[string]string{
934 "postgres": {"POSTGRES_PASSWORD": "secret"},
935 },
936 })
937 assert.NoError(t, err)
938
939 req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body))
940 req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
941 rec := httptest.NewRecorder()
942
943 handler.ServeHTTP(rec, req)
944
945 assert.Equal(t, http.StatusOK, rec.Code)
946 registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
947
948 var response dto.BootstrapResponse
949 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
950 assert.Equal(t, "app.example.com", response.Domain)
951 assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
952 assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
953 assert.Equal(t, []dto.BootstrapStep{
954 {Name: "route", Status: "configured"},
955 {Name: "attachment:postgres:16", Status: "created"},
956 {Name: "env", Status: "updated"},
957 {Name: "attachment_env:postgres", Status: "updated"},
958 }, response.Steps)
959}
960
961func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
962 configSvc := inmocks.NewMockConfigService(t)
963 authSvc := inmocks.NewMockAuthService(t)
964 containerSvc := inmocks.NewMockContainerService(t)
965 secretSvc := inmocks.NewMockSecretService(t)
966
967 handler := newTestHandler(t, func(d *HandlerDeps) {
968 d.ConfigSvc = configSvc
969 d.AuthSvc = authSvc
970 d.ContainerSvc = containerSvc
971 d.SecretSvc = secretSvc
972 })
973
974 tests := []struct {
975 name string
976 scopes []string
977 body string
978 wantStatus int
979 }{
980 {name: "route only needs routes permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusOK},
981 {name: "env requires secrets permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","env":{"APP_ENV":"prod"}}`, wantStatus: http.StatusForbidden},
982 {name: "attachments require config permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","attachments":["postgres:16"]}`, wantStatus: http.StatusForbidden},
983 {name: "missing routes permission", scopes: []string{"admin:secrets:write", "admin:config:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusForbidden},
984 }
985
986 for _, tt := range tests {
987 t.Run(tt.name, func(t *testing.T) {
988 if tt.wantStatus == http.StatusOK || strings.Contains(tt.body, `"env"`) || strings.Contains(tt.body, `"attachments"`) {
989 configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
990 }
991 if tt.wantStatus == http.StatusOK {
992 configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
993 }
994
995 req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))
996 req = req.WithContext(ctxWithScopes(tt.scopes...))
997 rec := httptest.NewRecorder()
998
999 handler.ServeHTTP(rec, req)
1000
1001 assert.Equal(t, tt.wantStatus, rec.Code)
1002 })
1003 }
1004}
1005
1006// Status endpoint tests
1007
1008func TestHandler_Status_RequiresReadScope(t *testing.T) {
1009 configSvc := inmocks.NewMockConfigService(t)
1010 authSvc := inmocks.NewMockAuthService(t)
1011 containerSvc := inmocks.NewMockContainerService(t)
1012 secretSvc := inmocks.NewMockSecretService(t)
1013
1014 handler := newTestHandler(t, func(d *HandlerDeps) {
1015 d.ConfigSvc = configSvc
1016 d.AuthSvc = authSvc
1017 d.ContainerSvc = containerSvc
1018 d.SecretSvc = secretSvc
1019 })
1020
1021 tests := []struct {
1022 name string
1023 scopes []string
1024 wantStatus int
1025 }{
1026 {
1027 name: "status read access granted",
1028 scopes: []string{"admin:status:read"},
1029 wantStatus: http.StatusOK,
1030 },
1031 {
1032 name: "all admin access granted",
1033 scopes: []string{"admin:*:*"},
1034 wantStatus: http.StatusOK,
1035 },
1036 {
1037 name: "wrong resource denied",
1038 scopes: []string{"admin:routes:read"},
1039 wantStatus: http.StatusForbidden,
1040 },
1041 }
1042
1043 for _, tt := range tests {
1044 t.Run(tt.name, func(t *testing.T) {
1045 if tt.wantStatus == http.StatusOK {
1046 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
1047 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
1048 configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
1049 configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
1050 configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
1051 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
1052 }
1053
1054 req := httptest.NewRequest("GET", "/admin/status", nil)
1055 req = req.WithContext(ctxWithScopes(tt.scopes...))
1056 rec := httptest.NewRecorder()
1057
1058 handler.ServeHTTP(rec, req)
1059
1060 assert.Equal(t, tt.wantStatus, rec.Code)
1061 })
1062 }
1063}
1064
1065// Config endpoint tests
1066
1067func TestHandler_Config_RequiresReadScope(t *testing.T) {
1068 configSvc := inmocks.NewMockConfigService(t)
1069 authSvc := inmocks.NewMockAuthService(t)
1070 containerSvc := inmocks.NewMockContainerService(t)
1071 secretSvc := inmocks.NewMockSecretService(t)
1072
1073 handler := newTestHandler(t, func(d *HandlerDeps) {
1074 d.ConfigSvc = configSvc
1075 d.AuthSvc = authSvc
1076 d.ContainerSvc = containerSvc
1077 d.SecretSvc = secretSvc
1078 })
1079
1080 tests := []struct {
1081 name string
1082 scopes []string
1083 wantStatus int
1084 }{
1085 {
1086 name: "config read access granted",
1087 scopes: []string{"admin:config:read"},
1088 wantStatus: http.StatusOK,
1089 },
1090 {
1091 name: "all admin access granted",
1092 scopes: []string{"admin:*:*"},
1093 wantStatus: http.StatusOK,
1094 },
1095 {
1096 name: "wrong resource denied",
1097 scopes: []string{"admin:routes:read"},
1098 wantStatus: http.StatusForbidden,
1099 },
1100 }
1101
1102 for _, tt := range tests {
1103 t.Run(tt.name, func(t *testing.T) {
1104 if tt.wantStatus == http.StatusOK {
1105 configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
1106 configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
1107 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
1108 configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
1109 configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
1110 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
1111 configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
1112 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
1113 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
1114 }
1115
1116 req := httptest.NewRequest("GET", "/admin/config", nil)
1117 req = req.WithContext(ctxWithScopes(tt.scopes...))
1118 rec := httptest.NewRecorder()
1119
1120 handler.ServeHTTP(rec, req)
1121
1122 assert.Equal(t, tt.wantStatus, rec.Code)
1123 })
1124 }
1125}
1126
1127// Reload endpoint tests
1128
1129func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
1130 configSvc := inmocks.NewMockConfigService(t)
1131 authSvc := inmocks.NewMockAuthService(t)
1132 containerSvc := inmocks.NewMockContainerService(t)
1133 secretSvc := inmocks.NewMockSecretService(t)
1134 reloadTrigger := &reloadTriggerRecorder{}
1135
1136 handler := newTestHandler(t, func(d *HandlerDeps) {
1137 d.ConfigSvc = configSvc
1138 d.AuthSvc = authSvc
1139 d.ContainerSvc = containerSvc
1140 d.SecretSvc = secretSvc
1141 d.ReloadTrigger = reloadTrigger
1142 })
1143
1144 tests := []struct {
1145 name string
1146 scopes []string
1147 wantStatus int
1148 }{
1149 {
1150 name: "config write access granted",
1151 scopes: []string{"admin:config:write"},
1152 wantStatus: http.StatusOK,
1153 },
1154 {
1155 name: "all admin access granted",
1156 scopes: []string{"admin:*:*"},
1157 wantStatus: http.StatusOK,
1158 },
1159 {
1160 name: "only read access denied",
1161 scopes: []string{"admin:config:read"},
1162 wantStatus: http.StatusForbidden,
1163 },
1164 {
1165 name: "wrong resource denied",
1166 scopes: []string{"admin:routes:write"},
1167 wantStatus: http.StatusForbidden,
1168 },
1169 }
1170
1171 for _, tt := range tests {
1172 t.Run(tt.name, func(t *testing.T) {
1173 reloadTrigger.calls = 0
1174
1175 req := httptest.NewRequest("POST", "/admin/reload", nil)
1176 req = req.WithContext(ctxWithScopes(tt.scopes...))
1177 rec := httptest.NewRecorder()
1178
1179 handler.ServeHTTP(rec, req)
1180
1181 assert.Equal(t, tt.wantStatus, rec.Code)
1182 if tt.wantStatus == http.StatusOK {
1183 assert.Equal(t, 1, reloadTrigger.calls)
1184 } else {
1185 assert.Zero(t, reloadTrigger.calls)
1186 }
1187 })
1188 }
1189}
1190
1191func TestHandler_Reload_ReturnsServerErrorOnTriggerFailure(t *testing.T) {
1192 reloadTrigger := &reloadTriggerRecorder{err: errors.New("reload failed")}
1193
1194 handler := newTestHandler(t, func(d *HandlerDeps) {
1195 d.ReloadTrigger = reloadTrigger
1196 })
1197
1198 req := httptest.NewRequest("POST", "/admin/reload", nil)
1199 req = req.WithContext(ctxWithScopes("admin:config:write"))
1200 rec := httptest.NewRecorder()
1201
1202 handler.ServeHTTP(rec, req)
1203
1204 assert.Equal(t, http.StatusInternalServerError, rec.Code)
1205 assert.Equal(t, 1, reloadTrigger.calls)
1206}
1207
1208func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) {
1209 reloadTrigger := &reloadTriggerRecorder{}
1210
1211 handler := newTestHandler(t, func(d *HandlerDeps) {
1212 d.ReloadTrigger = reloadTrigger
1213 })
1214
1215 reqCtx, cancel := context.WithCancel(ctxWithScopes("admin:config:write"))
1216 cancel()
1217
1218 req := httptest.NewRequest("POST", "/admin/reload", nil)
1219 req = req.WithContext(reqCtx)
1220 rec := httptest.NewRecorder()
1221
1222 handler.ServeHTTP(rec, req)
1223
1224 assert.Equal(t, http.StatusOK, rec.Code)
1225 assert.Equal(t, 1, reloadTrigger.calls)
1226 require.NoError(t, reloadTrigger.ctxErrAtCall)
1227 assert.True(t, reloadTrigger.hasDeadline)
1228}
1229
1230// Functional tests
1231
1232func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
1233 configSvc := inmocks.NewMockConfigService(t)
1234 authSvc := inmocks.NewMockAuthService(t)
1235 containerSvc := inmocks.NewMockContainerService(t)
1236 secretSvc := inmocks.NewMockSecretService(t)
1237
1238 handler := newTestHandler(t, func(d *HandlerDeps) {
1239 d.ConfigSvc = configSvc
1240 d.AuthSvc = authSvc
1241 d.ContainerSvc = containerSvc
1242 d.SecretSvc = secretSvc
1243 })
1244
1245 expectedRoutes := []domain.Route{
1246 {Domain: "app1.example.com", Image: "app1:latest"},
1247 {Domain: "app2.example.com", Image: "app2:v1.0"},
1248 }
1249 configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
1250
1251 req := httptest.NewRequest("GET", "/admin/routes", nil)
1252 req = req.WithContext(ctxWithScopes("admin:routes:read"))
1253 rec := httptest.NewRecorder()
1254
1255 handler.ServeHTTP(rec, req)
1256
1257 assert.Equal(t, http.StatusOK, rec.Code)
1258
1259 var response struct {
1260 Routes []struct {
1261 Domain string `json:"domain"`
1262 Image string `json:"image"`
1263 HTTPS bool `json:"https"`
1264 } `json:"routes"`
1265 }
1266 err := json.NewDecoder(rec.Body).Decode(&response)
1267 assert.NoError(t, err)
1268
1269 assert.Len(t, response.Routes, 2)
1270}
1271
1272func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
1273 configSvc := inmocks.NewMockConfigService(t)
1274 authSvc := inmocks.NewMockAuthService(t)
1275 containerSvc := inmocks.NewMockContainerService(t)
1276 secretSvc := inmocks.NewMockSecretService(t)
1277
1278 handler := newTestHandler(t, func(d *HandlerDeps) {
1279 d.ConfigSvc = configSvc
1280 d.AuthSvc = authSvc
1281 d.ContainerSvc = containerSvc
1282 d.SecretSvc = secretSvc
1283 })
1284
1285 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
1286 Domain: "app.example.com", Image: "app:latest",
1287 }, nil)
1288
1289 req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
1290 req = req.WithContext(ctxWithScopes("admin:routes:read"))
1291 rec := httptest.NewRecorder()
1292
1293 handler.ServeHTTP(rec, req)
1294
1295 assert.Equal(t, http.StatusOK, rec.Code)
1296
1297 var route struct {
1298 Domain string `json:"domain"`
1299 Image string `json:"image"`
1300 HTTPS bool `json:"https"`
1301 }
1302 err := json.NewDecoder(rec.Body).Decode(&route)
1303 assert.NoError(t, err)
1304 assert.Equal(t, "app.example.com", route.Domain)
1305 assert.Equal(t, "app:latest", route.Image)
1306}
1307
1308func TestHandler_RoutesGet_NotFound(t *testing.T) {
1309 configSvc := inmocks.NewMockConfigService(t)
1310 authSvc := inmocks.NewMockAuthService(t)
1311 containerSvc := inmocks.NewMockContainerService(t)
1312 secretSvc := inmocks.NewMockSecretService(t)
1313
1314 handler := newTestHandler(t, func(d *HandlerDeps) {
1315 d.ConfigSvc = configSvc
1316 d.AuthSvc = authSvc
1317 d.ContainerSvc = containerSvc
1318 d.SecretSvc = secretSvc
1319 })
1320
1321 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
1322
1323 req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
1324 req = req.WithContext(ctxWithScopes("admin:routes:read"))
1325 rec := httptest.NewRecorder()
1326
1327 handler.ServeHTTP(rec, req)
1328
1329 assert.Equal(t, http.StatusNotFound, rec.Code)
1330}
1331
1332func TestHandler_RoutesPost_MissingFields(t *testing.T) {
1333 tests := []struct {
1334 name string
1335 body string
1336 mockErr error
1337 }{
1338 {
1339 name: "missing domain",
1340 body: `{"image": "app:latest"}`,
1341 mockErr: domain.ErrRouteDomainEmpty,
1342 },
1343 {
1344 name: "missing image",
1345 body: `{"domain": "app.example.com"}`,
1346 mockErr: domain.ErrRouteImageEmpty,
1347 },
1348 {
1349 name: "empty object",
1350 body: `{}`,
1351 mockErr: domain.ErrRouteDomainEmpty,
1352 },
1353 }
1354
1355 for _, tt := range tests {
1356 t.Run(tt.name, func(t *testing.T) {
1357 configSvc := inmocks.NewMockConfigService(t)
1358 authSvc := inmocks.NewMockAuthService(t)
1359 containerSvc := inmocks.NewMockContainerService(t)
1360 secretSvc := inmocks.NewMockSecretService(t)
1361 registrySvc := inmocks.NewMockRegistryService(t)
1362
1363 handler := newTestHandler(t, func(d *HandlerDeps) {
1364 d.ConfigSvc = configSvc
1365 d.AuthSvc = authSvc
1366 d.ContainerSvc = containerSvc
1367 d.SecretSvc = secretSvc
1368 d.RegistrySvc = registrySvc
1369 })
1370
1371 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
1372
1373 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
1374 req = req.WithContext(ctxWithScopes("admin:routes:write"))
1375 rec := httptest.NewRecorder()
1376
1377 handler.ServeHTTP(rec, req)
1378
1379 assert.Equal(t, http.StatusBadRequest, rec.Code)
1380 })
1381 }
1382}
1383
1384func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
1385 configSvc := inmocks.NewMockConfigService(t)
1386 authSvc := inmocks.NewMockAuthService(t)
1387 containerSvc := inmocks.NewMockContainerService(t)
1388 secretSvc := inmocks.NewMockSecretService(t)
1389
1390 handler := newTestHandler(t, func(d *HandlerDeps) {
1391 d.ConfigSvc = configSvc
1392 d.AuthSvc = authSvc
1393 d.ContainerSvc = containerSvc
1394 d.SecretSvc = secretSvc
1395 })
1396
1397 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
1398 req = req.WithContext(ctxWithScopes("admin:routes:write"))
1399 rec := httptest.NewRecorder()
1400
1401 handler.ServeHTTP(rec, req)
1402
1403 assert.Equal(t, http.StatusBadRequest, rec.Code)
1404}
1405
1406func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
1407 configSvc := inmocks.NewMockConfigService(t)
1408 authSvc := inmocks.NewMockAuthService(t)
1409 containerSvc := inmocks.NewMockContainerService(t)
1410 secretSvc := inmocks.NewMockSecretService(t)
1411
1412 handler := newTestHandler(t, func(d *HandlerDeps) {
1413 d.ConfigSvc = configSvc
1414 d.AuthSvc = authSvc
1415 d.ContainerSvc = containerSvc
1416 d.SecretSvc = secretSvc
1417 })
1418
1419 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
1420 req = req.WithContext(ctxWithScopes("admin:routes:write"))
1421 rec := httptest.NewRecorder()
1422
1423 handler.ServeHTTP(rec, req)
1424
1425 assert.Equal(t, http.StatusBadRequest, rec.Code)
1426}
1427
1428func TestHandler_Secrets_MissingDomain(t *testing.T) {
1429 configSvc := inmocks.NewMockConfigService(t)
1430 authSvc := inmocks.NewMockAuthService(t)
1431 containerSvc := inmocks.NewMockContainerService(t)
1432 secretSvc := inmocks.NewMockSecretService(t)
1433
1434 handler := newTestHandler(t, func(d *HandlerDeps) {
1435 d.ConfigSvc = configSvc
1436 d.AuthSvc = authSvc
1437 d.ContainerSvc = containerSvc
1438 d.SecretSvc = secretSvc
1439 })
1440
1441 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
1442 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1443 rec := httptest.NewRecorder()
1444
1445 handler.ServeHTTP(rec, req)
1446
1447 assert.Equal(t, http.StatusBadRequest, rec.Code)
1448}
1449
1450func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
1451 configSvc := inmocks.NewMockConfigService(t)
1452 authSvc := inmocks.NewMockAuthService(t)
1453 containerSvc := inmocks.NewMockContainerService(t)
1454 secretSvc := inmocks.NewMockSecretService(t)
1455
1456 handler := newTestHandler(t, func(d *HandlerDeps) {
1457 d.ConfigSvc = configSvc
1458 d.AuthSvc = authSvc
1459 d.ContainerSvc = containerSvc
1460 d.SecretSvc = secretSvc
1461 })
1462
1463 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
1464 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1465 rec := httptest.NewRecorder()
1466
1467 handler.ServeHTTP(rec, req)
1468
1469 assert.Equal(t, http.StatusBadRequest, rec.Code)
1470}
1471
1472func TestHandler_MethodNotAllowed(t *testing.T) {
1473 configSvc := inmocks.NewMockConfigService(t)
1474 authSvc := inmocks.NewMockAuthService(t)
1475 containerSvc := inmocks.NewMockContainerService(t)
1476 secretSvc := inmocks.NewMockSecretService(t)
1477
1478 handler := newTestHandler(t, func(d *HandlerDeps) {
1479 d.ConfigSvc = configSvc
1480 d.AuthSvc = authSvc
1481 d.ContainerSvc = containerSvc
1482 d.SecretSvc = secretSvc
1483 })
1484
1485 tests := []struct {
1486 method string
1487 path string
1488 }{
1489 {"DELETE", "/admin/status"},
1490 {"PUT", "/admin/status"},
1491 {"GET", "/admin/reload"},
1492 {"DELETE", "/admin/reload"},
1493 {"POST", "/admin/config"},
1494 {"DELETE", "/admin/config"},
1495 }
1496
1497 for _, tt := range tests {
1498 t.Run(tt.method+" "+tt.path, func(t *testing.T) {
1499 req := httptest.NewRequest(tt.method, tt.path, nil)
1500 req = req.WithContext(ctxWithScopes("admin:*:*"))
1501 rec := httptest.NewRecorder()
1502
1503 handler.ServeHTTP(rec, req)
1504
1505 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
1506 })
1507 }
1508}
1509
1510func TestHandler_Restart_InvalidDomain(t *testing.T) {
1511 configSvc := inmocks.NewMockConfigService(t)
1512 authSvc := inmocks.NewMockAuthService(t)
1513 containerSvc := inmocks.NewMockContainerService(t)
1514 secretSvc := inmocks.NewMockSecretService(t)
1515
1516 handler := newTestHandler(t, func(d *HandlerDeps) {
1517 d.ConfigSvc = configSvc
1518 d.AuthSvc = authSvc
1519 d.ContainerSvc = containerSvc
1520 d.SecretSvc = secretSvc
1521 })
1522
1523 tests := []struct {
1524 name string
1525 path string
1526 status int
1527 }{
1528 {"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest},
1529 {"empty domain", "/admin/restart/", http.StatusBadRequest},
1530 }
1531
1532 for _, tt := range tests {
1533 t.Run(tt.name, func(t *testing.T) {
1534 req := httptest.NewRequest("POST", tt.path, nil)
1535 req = req.WithContext(ctxWithScopes("admin:config:write"))
1536 rec := httptest.NewRecorder()
1537
1538 handler.ServeHTTP(rec, req)
1539
1540 assert.Equal(t, tt.status, rec.Code)
1541 })
1542 }
1543}
1544
1545func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1546 configSvc := inmocks.NewMockConfigService(t)
1547 authSvc := inmocks.NewMockAuthService(t)
1548 containerSvc := inmocks.NewMockContainerService(t)
1549 secretSvc := inmocks.NewMockSecretService(t)
1550 route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1551 deployErr := &domain.DeployFailureError{
1552 Summary: "failed to deploy",
1553 Cause: "image pull failed",
1554 Hint: "push the image before retrying",
1555 Logs: []string{
1556 "pulling manifest",
1557 "manifest unknown",
1558 },
1559 Err: errors.New("manifest unknown: internal registry detail"),
1560 }
1561
1562 handler := newTestHandler(t, func(d *HandlerDeps) {
1563 d.ConfigSvc = configSvc
1564 d.AuthSvc = authSvc
1565 d.ContainerSvc = containerSvc
1566 d.SecretSvc = secretSvc
1567 })
1568 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1569 handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))
1570 }))
1571 defer s.Close()
1572
1573 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1574 containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1575
1576 req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil)
1577 assert.NoError(t, err)
1578 resp, err := s.Client().Do(req)
1579 assert.NoError(t, err)
1580 defer resp.Body.Close()
1581 body, err := io.ReadAll(resp.Body)
1582 assert.NoError(t, err)
1583
1584 assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1585 assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, string(body))
1586 assert.NotContains(t, string(body), "internal registry detail")
1587}
1588
1589func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {
1590 configSvc := inmocks.NewMockConfigService(t)
1591 authSvc := inmocks.NewMockAuthService(t)
1592 containerSvc := inmocks.NewMockContainerService(t)
1593 secretSvc := inmocks.NewMockSecretService(t)
1594 route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1595 deployErr := errors.New("manifest unknown: internal registry detail")
1596
1597 handler := newTestHandler(t, func(d *HandlerDeps) {
1598 d.ConfigSvc = configSvc
1599 d.AuthSvc = authSvc
1600 d.ContainerSvc = containerSvc
1601 d.SecretSvc = secretSvc
1602 })
1603 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1604 handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))
1605 }))
1606 defer s.Close()
1607
1608 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1609 containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1610
1611 req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil)
1612 assert.NoError(t, err)
1613 resp, err := s.Client().Do(req)
1614 assert.NoError(t, err)
1615 defer resp.Body.Close()
1616 body, err := io.ReadAll(resp.Body)
1617 assert.NoError(t, err)
1618
1619 assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1620 assert.JSONEq(t, `{"error":"failed to deploy container"}`, string(body))
1621 assert.NotContains(t, string(body), "internal registry detail")
1622}
1623
1624func TestHandler_Restart_ContainerNotFound(t *testing.T) {
1625 configSvc := inmocks.NewMockConfigService(t)
1626 authSvc := inmocks.NewMockAuthService(t)
1627 containerSvc := inmocks.NewMockContainerService(t)
1628 secretSvc := inmocks.NewMockSecretService(t)
1629
1630 handler := newTestHandler(t, func(d *HandlerDeps) {
1631 d.ConfigSvc = configSvc
1632 d.AuthSvc = authSvc
1633 d.ContainerSvc = containerSvc
1634 d.SecretSvc = secretSvc
1635 })
1636
1637 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
1638
1639 req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil)
1640 req = req.WithContext(ctxWithScopes("admin:config:write"))
1641 rec := httptest.NewRecorder()
1642
1643 handler.ServeHTTP(rec, req)
1644
1645 assert.Equal(t, http.StatusNotFound, rec.Code)
1646}
1647
1648func TestHandler_Restart_GenericError(t *testing.T) {
1649 configSvc := inmocks.NewMockConfigService(t)
1650 authSvc := inmocks.NewMockAuthService(t)
1651 containerSvc := inmocks.NewMockContainerService(t)
1652 secretSvc := inmocks.NewMockSecretService(t)
1653
1654 handler := newTestHandler(t, func(d *HandlerDeps) {
1655 d.ConfigSvc = configSvc
1656 d.AuthSvc = authSvc
1657 d.ContainerSvc = containerSvc
1658 d.SecretSvc = secretSvc
1659 })
1660
1661 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
1662
1663 req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil)
1664 req = req.WithContext(ctxWithScopes("admin:config:write"))
1665 rec := httptest.NewRecorder()
1666
1667 handler.ServeHTTP(rec, req)
1668
1669 assert.Equal(t, http.StatusInternalServerError, rec.Code)
1670 // Verify internal error details are NOT leaked
1671 assert.NotContains(t, rec.Body.String(), "docker daemon error")
1672 assert.NotContains(t, rec.Body.String(), "secret details")
1673}
1674
1675func TestHandler_Restart_Success(t *testing.T) {
1676 configSvc := inmocks.NewMockConfigService(t)
1677 authSvc := inmocks.NewMockAuthService(t)
1678 containerSvc := inmocks.NewMockContainerService(t)
1679 secretSvc := inmocks.NewMockSecretService(t)
1680
1681 handler := newTestHandler(t, func(d *HandlerDeps) {
1682 d.ConfigSvc = configSvc
1683 d.AuthSvc = authSvc
1684 d.ContainerSvc = containerSvc
1685 d.SecretSvc = secretSvc
1686 })
1687
1688 tests := []struct {
1689 name string
1690 path string
1691 withAttachments bool
1692 }{
1693 {
1694 name: "without attachments",
1695 path: "/admin/restart/test.example.com",
1696 withAttachments: false,
1697 },
1698 {
1699 name: "with attachments",
1700 path: "/admin/restart/test.example.com?attachments=true",
1701 withAttachments: true,
1702 },
1703 }
1704
1705 for _, tt := range tests {
1706 t.Run(tt.name, func(t *testing.T) {
1707 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
1708
1709 req := httptest.NewRequest("POST", tt.path, nil)
1710 req = req.WithContext(ctxWithScopes("admin:config:write"))
1711 rec := httptest.NewRecorder()
1712
1713 handler.ServeHTTP(rec, req)
1714
1715 assert.Equal(t, http.StatusOK, rec.Code)
1716 var response struct {
1717 Status string `json:"status"`
1718 Domain string `json:"domain"`
1719 }
1720 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1721 assert.Equal(t, "restarted", response.Status)
1722 assert.Equal(t, "test.example.com", response.Domain)
1723 })
1724 }
1725}
1726
1727func TestHandler_Tags_InvalidRepository(t *testing.T) {
1728 configSvc := inmocks.NewMockConfigService(t)
1729 authSvc := inmocks.NewMockAuthService(t)
1730 containerSvc := inmocks.NewMockContainerService(t)
1731 secretSvc := inmocks.NewMockSecretService(t)
1732
1733 handler := newTestHandler(t, func(d *HandlerDeps) {
1734 d.ConfigSvc = configSvc
1735 d.AuthSvc = authSvc
1736 d.ContainerSvc = containerSvc
1737 d.SecretSvc = secretSvc
1738 })
1739
1740 tests := []struct {
1741 name string
1742 path string
1743 status int
1744 }{
1745 {"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
1746 {"empty repo", "/admin/tags/", http.StatusBadRequest},
1747 }
1748
1749 for _, tt := range tests {
1750 t.Run(tt.name, func(t *testing.T) {
1751 req := httptest.NewRequest("GET", tt.path, nil)
1752 req = req.WithContext(ctxWithScopes("admin:status:read"))
1753 rec := httptest.NewRecorder()
1754
1755 handler.ServeHTTP(rec, req)
1756
1757 assert.Equal(t, tt.status, rec.Code)
1758 })
1759 }
1760}
1761
1762func TestHandler_Tags_Success(t *testing.T) {
1763 configSvc := inmocks.NewMockConfigService(t)
1764 authSvc := inmocks.NewMockAuthService(t)
1765 containerSvc := inmocks.NewMockContainerService(t)
1766 secretSvc := inmocks.NewMockSecretService(t)
1767 registrySvc := inmocks.NewMockRegistryService(t)
1768
1769 handler := newTestHandler(t, func(d *HandlerDeps) {
1770 d.ConfigSvc = configSvc
1771 d.AuthSvc = authSvc
1772 d.ContainerSvc = containerSvc
1773 d.SecretSvc = secretSvc
1774 d.RegistrySvc = registrySvc
1775 })
1776
1777 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1778
1779 req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1780 req = req.WithContext(ctxWithScopes("admin:status:read"))
1781 rec := httptest.NewRecorder()
1782
1783 handler.ServeHTTP(rec, req)
1784
1785 assert.Equal(t, http.StatusOK, rec.Code)
1786 var response struct {
1787 Repository string `json:"repository"`
1788 Tags []string `json:"tags"`
1789 }
1790 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1791 assert.Equal(t, "myapp", response.Repository)
1792 assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1793}
1794
1795func TestHandler_Tags_Error(t *testing.T) {
1796 configSvc := inmocks.NewMockConfigService(t)
1797 authSvc := inmocks.NewMockAuthService(t)
1798 containerSvc := inmocks.NewMockContainerService(t)
1799 secretSvc := inmocks.NewMockSecretService(t)
1800 registrySvc := inmocks.NewMockRegistryService(t)
1801
1802 handler := newTestHandler(t, func(d *HandlerDeps) {
1803 d.ConfigSvc = configSvc
1804 d.AuthSvc = authSvc
1805 d.ContainerSvc = containerSvc
1806 d.SecretSvc = secretSvc
1807 d.RegistrySvc = registrySvc
1808 })
1809
1810 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1811
1812 req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1813 req = req.WithContext(ctxWithScopes("admin:status:read"))
1814 rec := httptest.NewRecorder()
1815
1816 handler.ServeHTTP(rec, req)
1817
1818 assert.Equal(t, http.StatusInternalServerError, rec.Code)
1819 assert.NotContains(t, rec.Body.String(), "registry error")
1820}
1821
1822func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
1823 configSvc := inmocks.NewMockConfigService(t)
1824 authSvc := inmocks.NewMockAuthService(t)
1825 containerSvc := inmocks.NewMockContainerService(t)
1826 secretSvc := inmocks.NewMockSecretService(t)
1827
1828 handler := newTestHandler(t, func(d *HandlerDeps) {
1829 d.ConfigSvc = configSvc
1830 d.AuthSvc = authSvc
1831 d.ContainerSvc = containerSvc
1832 d.SecretSvc = secretSvc
1833 })
1834
1835 body := `{"REDIS_URL": "redis://localhost:6379"}`
1836 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1837 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1838 rec := httptest.NewRecorder()
1839
1840 handler.ServeHTTP(rec, req)
1841
1842 assert.Equal(t, http.StatusForbidden, rec.Code)
1843}
1844
1845func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
1846 configSvc := inmocks.NewMockConfigService(t)
1847 authSvc := inmocks.NewMockAuthService(t)
1848 containerSvc := inmocks.NewMockContainerService(t)
1849 secretSvc := inmocks.NewMockSecretService(t)
1850
1851 handler := newTestHandler(t, func(d *HandlerDeps) {
1852 d.ConfigSvc = configSvc
1853 d.AuthSvc = authSvc
1854 d.ContainerSvc = containerSvc
1855 d.SecretSvc = secretSvc
1856 })
1857
1858 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1859 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1860 rec := httptest.NewRecorder()
1861
1862 handler.ServeHTTP(rec, req)
1863
1864 assert.Equal(t, http.StatusForbidden, rec.Code)
1865}
1866
1867func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1868 configSvc := inmocks.NewMockConfigService(t)
1869 authSvc := inmocks.NewMockAuthService(t)
1870 containerSvc := inmocks.NewMockContainerService(t)
1871 secretSvc := inmocks.NewMockSecretService(t)
1872
1873 handler := newTestHandler(t, func(d *HandlerDeps) {
1874 d.ConfigSvc = configSvc
1875 d.AuthSvc = authSvc
1876 d.ContainerSvc = containerSvc
1877 d.SecretSvc = secretSvc
1878 })
1879
1880 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
1881 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1882 rec := httptest.NewRecorder()
1883
1884 handler.ServeHTTP(rec, req)
1885
1886 assert.Equal(t, http.StatusBadRequest, rec.Code)
1887 assert.Contains(t, rec.Body.String(), "invalid attachment path")
1888}
1889
1890func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
1891 configSvc := inmocks.NewMockConfigService(t)
1892 authSvc := inmocks.NewMockAuthService(t)
1893 containerSvc := inmocks.NewMockContainerService(t)
1894 secretSvc := inmocks.NewMockSecretService(t)
1895
1896 handler := newTestHandler(t, func(d *HandlerDeps) {
1897 d.ConfigSvc = configSvc
1898 d.AuthSvc = authSvc
1899 d.ContainerSvc = containerSvc
1900 d.SecretSvc = secretSvc
1901 })
1902
1903 body := `{not valid json`
1904 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1905 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1906 rec := httptest.NewRecorder()
1907
1908 handler.ServeHTTP(rec, req)
1909
1910 assert.Equal(t, http.StatusBadRequest, rec.Code)
1911 assert.Contains(t, rec.Body.String(), "invalid JSON")
1912}
1913
1914func TestHandler_NotFound(t *testing.T) {
1915 configSvc := inmocks.NewMockConfigService(t)
1916 authSvc := inmocks.NewMockAuthService(t)
1917 containerSvc := inmocks.NewMockContainerService(t)
1918 secretSvc := inmocks.NewMockSecretService(t)
1919
1920 handler := newTestHandler(t, func(d *HandlerDeps) {
1921 d.ConfigSvc = configSvc
1922 d.AuthSvc = authSvc
1923 d.ContainerSvc = containerSvc
1924 d.SecretSvc = secretSvc
1925 })
1926
1927 req := httptest.NewRequest("GET", "/admin/unknown", nil)
1928 req = req.WithContext(ctxWithScopes("admin:*:*"))
1929 rec := httptest.NewRecorder()
1930
1931 handler.ServeHTTP(rec, req)
1932
1933 assert.Equal(t, http.StatusNotFound, rec.Code)
1934}
1935
1936func TestHandler_BackupsStatus(t *testing.T) {
1937 configSvc := inmocks.NewMockConfigService(t)
1938 authSvc := inmocks.NewMockAuthService(t)
1939 containerSvc := inmocks.NewMockContainerService(t)
1940 secretSvc := inmocks.NewMockSecretService(t)
1941 backupSvc := inmocks.NewMockBackupService(t)
1942
1943 backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil)
1944
1945 handler := newTestHandler(t, func(d *HandlerDeps) {
1946 d.ConfigSvc = configSvc
1947 d.AuthSvc = authSvc
1948 d.ContainerSvc = containerSvc
1949 d.SecretSvc = secretSvc
1950 d.BackupSvc = backupSvc
1951 })
1952
1953 req := httptest.NewRequest("GET", "/admin/backups/status", nil)
1954 req = req.WithContext(ctxWithScopes("admin:status:read"))
1955 rec := httptest.NewRecorder()
1956
1957 handler.ServeHTTP(rec, req)
1958
1959 assert.Equal(t, http.StatusOK, rec.Code)
1960 assert.Contains(t, rec.Body.String(), "app.example.com")
1961 assert.NotContains(t, rec.Body.String(), "file_path")
1962}
1963
1964func TestHandler_BackupsListDomain(t *testing.T) {
1965 configSvc := inmocks.NewMockConfigService(t)
1966 authSvc := inmocks.NewMockAuthService(t)
1967 containerSvc := inmocks.NewMockContainerService(t)
1968 secretSvc := inmocks.NewMockSecretService(t)
1969 backupSvc := inmocks.NewMockBackupService(t)
1970
1971 backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1972
1973 handler := newTestHandler(t, func(d *HandlerDeps) {
1974 d.ConfigSvc = configSvc
1975 d.AuthSvc = authSvc
1976 d.ContainerSvc = containerSvc
1977 d.SecretSvc = secretSvc
1978 d.BackupSvc = backupSvc
1979 })
1980
1981 req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)
1982 req = req.WithContext(ctxWithScopes("admin:status:read"))
1983 rec := httptest.NewRecorder()
1984
1985 handler.ServeHTTP(rec, req)
1986
1987 assert.Equal(t, http.StatusOK, rec.Code)
1988 assert.Contains(t, rec.Body.String(), "app.example.com")
1989}
1990
1991func TestHandler_BackupsRunDomain(t *testing.T) {
1992 configSvc := inmocks.NewMockConfigService(t)
1993 authSvc := inmocks.NewMockAuthService(t)
1994 containerSvc := inmocks.NewMockContainerService(t)
1995 secretSvc := inmocks.NewMockSecretService(t)
1996 backupSvc := inmocks.NewMockBackupService(t)
1997
1998 backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1999
2000 handler := newTestHandler(t, func(d *HandlerDeps) {
2001 d.ConfigSvc = configSvc
2002 d.AuthSvc = authSvc
2003 d.ContainerSvc = containerSvc
2004 d.SecretSvc = secretSvc
2005 d.BackupSvc = backupSvc
2006 })
2007
2008 body := bytes.NewBufferString(`{"db":"postgres"}`)
2009 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
2010 req = req.WithContext(ctxWithScopes("admin:config:write"))
2011 rec := httptest.NewRecorder()
2012
2013 handler.ServeHTTP(rec, req)
2014
2015 assert.Equal(t, http.StatusOK, rec.Code)
2016 assert.Contains(t, rec.Body.String(), "completed")
2017}
2018
2019func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
2020 configSvc := inmocks.NewMockConfigService(t)
2021 authSvc := inmocks.NewMockAuthService(t)
2022 containerSvc := inmocks.NewMockContainerService(t)
2023 secretSvc := inmocks.NewMockSecretService(t)
2024 backupSvc := inmocks.NewMockBackupService(t)
2025
2026 backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
2027
2028 handler := newTestHandler(t, func(d *HandlerDeps) {
2029 d.ConfigSvc = configSvc
2030 d.AuthSvc = authSvc
2031 d.ContainerSvc = containerSvc
2032 d.SecretSvc = secretSvc
2033 d.BackupSvc = backupSvc
2034 })
2035
2036 body := bytes.NewBufferString(`{"db":"postgres"}`)
2037 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
2038 req.ContentLength = -1
2039 req = req.WithContext(ctxWithScopes("admin:config:write"))
2040 rec := httptest.NewRecorder()
2041
2042 handler.ServeHTTP(rec, req)
2043
2044 assert.Equal(t, http.StatusOK, rec.Code)
2045 assert.Contains(t, rec.Body.String(), "completed")
2046}
2047
2048func TestHandler_BackupsDetectDomain(t *testing.T) {
2049 configSvc := inmocks.NewMockConfigService(t)
2050 authSvc := inmocks.NewMockAuthService(t)
2051 containerSvc := inmocks.NewMockContainerService(t)
2052 secretSvc := inmocks.NewMockSecretService(t)
2053 backupSvc := inmocks.NewMockBackupService(t)
2054
2055 backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
2056
2057 handler := newTestHandler(t, func(d *HandlerDeps) {
2058 d.ConfigSvc = configSvc
2059 d.AuthSvc = authSvc
2060 d.ContainerSvc = containerSvc
2061 d.SecretSvc = secretSvc
2062 d.BackupSvc = backupSvc
2063 })
2064
2065 req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
2066 req = req.WithContext(ctxWithScopes("admin:status:read"))
2067 rec := httptest.NewRecorder()
2068
2069 handler.ServeHTTP(rec, req)
2070
2071 assert.Equal(t, http.StatusOK, rec.Code)
2072 assert.Contains(t, rec.Body.String(), "postgres")
2073}
2074
2075func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
2076 configSvc := inmocks.NewMockConfigService(t)
2077 authSvc := inmocks.NewMockAuthService(t)
2078 containerSvc := inmocks.NewMockContainerService(t)
2079 secretSvc := inmocks.NewMockSecretService(t)
2080
2081 createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
2082 imageSvc := &stubImageService{
2083 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
2084 return []domain.ImageInfo{
2085 {
2086 Repository: "myapp",
2087 Tag: "latest",
2088 Size: 1024,
2089 Created: createdAt,
2090 ID: "sha256:abc123",
2091 Dangling: false,
2092 },
2093 {
2094 Repository: "",
2095 Tag: "",
2096 Size: 512,
2097 Created: createdAt,
2098 ID: "sha256:def456",
2099 Dangling: true,
2100 },
2101 }, nil
2102 },
2103 }
2104
2105 handler := newTestHandler(t, func(d *HandlerDeps) {
2106 d.ConfigSvc = configSvc
2107 d.AuthSvc = authSvc
2108 d.ContainerSvc = containerSvc
2109 d.SecretSvc = secretSvc
2110 d.ImageSvc = imageSvc
2111 })
2112
2113 req := httptest.NewRequest("GET", "/admin/images", nil)
2114 req = req.WithContext(ctxWithScopes("admin:status:read"))
2115 rec := httptest.NewRecorder()
2116
2117 handler.ServeHTTP(rec, req)
2118
2119 assert.Equal(t, http.StatusOK, rec.Code)
2120
2121 var response struct {
2122 Images []struct {
2123 Repository string `json:"repository"`
2124 Tag string `json:"tag"`
2125 Size int64 `json:"size"`
2126 Created time.Time `json:"created"`
2127 ID string `json:"id"`
2128 Dangling bool `json:"dangling"`
2129 } `json:"images"`
2130 }
2131
2132 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
2133 assert.Len(t, response.Images, 2)
2134 assert.Equal(t, "myapp", response.Images[0].Repository)
2135 assert.Equal(t, "latest", response.Images[0].Tag)
2136 assert.Equal(t, int64(1024), response.Images[0].Size)
2137 assert.Equal(t, createdAt, response.Images[0].Created)
2138 assert.Equal(t, "sha256:abc123", response.Images[0].ID)
2139 assert.False(t, response.Images[0].Dangling)
2140 assert.True(t, response.Images[1].Dangling)
2141}
2142
2143func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
2144 configSvc := inmocks.NewMockConfigService(t)
2145 authSvc := inmocks.NewMockAuthService(t)
2146 containerSvc := inmocks.NewMockContainerService(t)
2147 secretSvc := inmocks.NewMockSecretService(t)
2148
2149 tests := []struct {
2150 name string
2151 body string
2152 expectedKeepLast int
2153 }{
2154 {name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast},
2155 {name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
2156 }
2157
2158 for _, tt := range tests {
2159 t.Run(tt.name, func(t *testing.T) {
2160 called := false
2161 imageSvc := &stubImageService{
2162 pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
2163 called = true
2164 assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
2165 return domain.ImagePruneReport{
2166 Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
2167 Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
2168 }, nil
2169 },
2170 }
2171
2172 handler := newTestHandler(t, func(d *HandlerDeps) {
2173 d.ConfigSvc = configSvc
2174 d.AuthSvc = authSvc
2175 d.ContainerSvc = containerSvc
2176 d.SecretSvc = secretSvc
2177 d.ImageSvc = imageSvc
2178 })
2179
2180 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
2181 req = req.WithContext(ctxWithScopes("admin:config:write"))
2182 rec := httptest.NewRecorder()
2183
2184 handler.ServeHTTP(rec, req)
2185
2186 assert.Equal(t, http.StatusOK, rec.Code)
2187 assert.True(t, called)
2188 assert.Contains(t, rec.Body.String(), "deleted_count")
2189 assert.Contains(t, rec.Body.String(), "tags_removed")
2190 })
2191 }
2192}
2193
2194func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
2195 t.Run("list failure maps to internal server error", func(t *testing.T) {
2196 configSvc := inmocks.NewMockConfigService(t)
2197 authSvc := inmocks.NewMockAuthService(t)
2198 containerSvc := inmocks.NewMockContainerService(t)
2199 secretSvc := inmocks.NewMockSecretService(t)
2200
2201 imageSvc := &stubImageService{
2202 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
2203 return nil, errors.New("runtime list failed: confidential details")
2204 },
2205 }
2206
2207 handler := newTestHandler(t, func(d *HandlerDeps) {
2208 d.ConfigSvc = configSvc
2209 d.AuthSvc = authSvc
2210 d.ContainerSvc = containerSvc
2211 d.SecretSvc = secretSvc
2212 d.ImageSvc = imageSvc
2213 })
2214
2215 req := httptest.NewRequest("GET", "/admin/images", nil)
2216 req = req.WithContext(ctxWithScopes("admin:status:read"))
2217 rec := httptest.NewRecorder()
2218
2219 handler.ServeHTTP(rec, req)
2220
2221 assert.Equal(t, http.StatusInternalServerError, rec.Code)
2222 assert.Contains(t, rec.Body.String(), "failed to list images")
2223 assert.NotContains(t, rec.Body.String(), "confidential details")
2224 })
2225
2226 t.Run("prune failure maps to internal server error", func(t *testing.T) {
2227 configSvc := inmocks.NewMockConfigService(t)
2228 authSvc := inmocks.NewMockAuthService(t)
2229 containerSvc := inmocks.NewMockContainerService(t)
2230 secretSvc := inmocks.NewMockSecretService(t)
2231
2232 imageSvc := &stubImageService{
2233 pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
2234 return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
2235 },
2236 }
2237
2238 handler := newTestHandler(t, func(d *HandlerDeps) {
2239 d.ConfigSvc = configSvc
2240 d.AuthSvc = authSvc
2241 d.ContainerSvc = containerSvc
2242 d.SecretSvc = secretSvc
2243 d.ImageSvc = imageSvc
2244 })
2245
2246 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
2247 req = req.WithContext(ctxWithScopes("admin:config:write"))
2248 rec := httptest.NewRecorder()
2249
2250 handler.ServeHTTP(rec, req)
2251
2252 assert.Equal(t, http.StatusInternalServerError, rec.Code)
2253 assert.Contains(t, rec.Body.String(), "failed to prune images")
2254 assert.NotContains(t, rec.Body.String(), "confidential details")
2255 })
2256}
2257
2258func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
2259 t.Run("rejects negative keep_last", func(t *testing.T) {
2260 configSvc := inmocks.NewMockConfigService(t)
2261 authSvc := inmocks.NewMockAuthService(t)
2262 containerSvc := inmocks.NewMockContainerService(t)
2263 secretSvc := inmocks.NewMockSecretService(t)
2264
2265 handler := newTestHandler(t, func(d *HandlerDeps) {
2266 d.ConfigSvc = configSvc
2267 d.AuthSvc = authSvc
2268 d.ContainerSvc = containerSvc
2269 d.SecretSvc = secretSvc
2270 d.ImageSvc = &stubImageService{}
2271 })
2272
2273 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
2274 req = req.WithContext(ctxWithScopes("admin:config:write"))
2275 rec := httptest.NewRecorder()
2276
2277 handler.ServeHTTP(rec, req)
2278
2279 assert.Equal(t, http.StatusBadRequest, rec.Code)
2280 assert.Contains(t, rec.Body.String(), "keep_last must be")
2281 })
2282
2283 t.Run("returns service unavailable when image service missing", func(t *testing.T) {
2284 configSvc := inmocks.NewMockConfigService(t)
2285 authSvc := inmocks.NewMockAuthService(t)
2286 containerSvc := inmocks.NewMockContainerService(t)
2287 secretSvc := inmocks.NewMockSecretService(t)
2288
2289 handler := newTestHandler(t, func(d *HandlerDeps) {
2290 d.ConfigSvc = configSvc
2291 d.AuthSvc = authSvc
2292 d.ContainerSvc = containerSvc
2293 d.SecretSvc = secretSvc
2294 })
2295
2296 req := httptest.NewRequest("GET", "/admin/images", nil)
2297 req = req.WithContext(ctxWithScopes("admin:status:read"))
2298 rec := httptest.NewRecorder()
2299
2300 handler.ServeHTTP(rec, req)
2301
2302 assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
2303 assert.Contains(t, rec.Body.String(), "image service not available")
2304 })
2305}
2306
2307func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
2308 configSvc := inmocks.NewMockConfigService(t)
2309 authSvc := inmocks.NewMockAuthService(t)
2310 containerSvc := inmocks.NewMockContainerService(t)
2311 secretSvc := inmocks.NewMockSecretService(t)
2312
2313 tests := []struct {
2314 name string
2315 body string
2316 expectedPruneDangling bool
2317 expectedPruneRegistry bool
2318 expectedKeepLast int
2319 wantStatus int
2320 }{
2321 {
2322 name: "missing scope fields defaults to both true",
2323 body: `{}`,
2324 expectedPruneDangling: true,
2325 expectedPruneRegistry: true,
2326 expectedKeepLast: domain.DefaultImagePruneKeepLast,
2327 wantStatus: http.StatusOK,
2328 },
2329 {
2330 name: "explicit dangling only",
2331 body: `{"prune_dangling": true, "prune_registry": false}`,
2332 expectedPruneDangling: true,
2333 expectedPruneRegistry: false,
2334 expectedKeepLast: domain.DefaultImagePruneKeepLast,
2335 wantStatus: http.StatusOK,
2336 },
2337 {
2338 name: "explicit registry only",
2339 body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`,
2340 expectedPruneDangling: false,
2341 expectedPruneRegistry: true,
2342 expectedKeepLast: 5,
2343 wantStatus: http.StatusOK,
2344 },
2345 {
2346 name: "both scopes false returns 400",
2347 body: `{"prune_dangling": false, "prune_registry": false}`,
2348 wantStatus: http.StatusBadRequest,
2349 },
2350 }
2351
2352 for _, tt := range tests {
2353 t.Run(tt.name, func(t *testing.T) {
2354 called := false
2355 imageSvc := &stubImageService{
2356 pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
2357 called = true
2358 assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling)
2359 assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry)
2360 assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
2361 return domain.ImagePruneReport{}, nil
2362 },
2363 }
2364
2365 handler := newTestHandler(t, func(d *HandlerDeps) {
2366 d.ConfigSvc = configSvc
2367 d.AuthSvc = authSvc
2368 d.ContainerSvc = containerSvc
2369 d.SecretSvc = secretSvc
2370 d.ImageSvc = imageSvc
2371 })
2372
2373 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
2374 req = req.WithContext(ctxWithScopes("admin:config:write"))
2375 rec := httptest.NewRecorder()
2376
2377 handler.ServeHTTP(rec, req)
2378
2379 assert.Equal(t, tt.wantStatus, rec.Code)
2380 if tt.wantStatus == http.StatusOK {
2381 assert.True(t, called, "prune should have been called")
2382 }
2383 if tt.wantStatus == http.StatusBadRequest {
2384 assert.Contains(t, rec.Body.String(), "at least one prune scope")
2385 }
2386 })
2387 }
2388}
2389
2390func TestHandler_Images_Authorization(t *testing.T) {
2391 configSvc := inmocks.NewMockConfigService(t)
2392 authSvc := inmocks.NewMockAuthService(t)
2393 containerSvc := inmocks.NewMockContainerService(t)
2394 secretSvc := inmocks.NewMockSecretService(t)
2395
2396 handler := newTestHandler(t, func(d *HandlerDeps) {
2397 d.ConfigSvc = configSvc
2398 d.AuthSvc = authSvc
2399 d.ContainerSvc = containerSvc
2400 d.SecretSvc = secretSvc
2401 d.ImageSvc = &stubImageService{}
2402 })
2403
2404 t.Run("images list requires status:read", func(t *testing.T) {
2405 req := httptest.NewRequest("GET", "/admin/images", nil)
2406 req = req.WithContext(ctxWithScopes("admin:config:write"))
2407 rec := httptest.NewRecorder()
2408
2409 handler.ServeHTTP(rec, req)
2410
2411 assert.Equal(t, http.StatusForbidden, rec.Code)
2412 assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
2413 })
2414
2415 t.Run("images prune requires config:write", func(t *testing.T) {
2416 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
2417 req = req.WithContext(ctxWithScopes("admin:status:read"))
2418 rec := httptest.NewRecorder()
2419
2420 handler.ServeHTTP(rec, req)
2421
2422 assert.Equal(t, http.StatusForbidden, rec.Code)
2423 assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
2424 })
2425}