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