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