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_VolumeBackupsRunDomain_ReturnsPartialResults(t *testing.T) {
2413 volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2414 runErr := errors.New("one volume failed")
2415 jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
2416 volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(jobs, runErr)
2417
2418 handler := newTestHandler(t, func(d *HandlerDeps) {
2419 d.VolumeBackupSvc = volumeBackupSvc
2420 })
2421
2422 body := bytes.NewBufferString(`{"volume":"gordon-app-data"}`)
2423 req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", body)
2424 req = req.WithContext(ctxWithScopes("admin:config:write"))
2425 rec := httptest.NewRecorder()
2426
2427 handler.ServeHTTP(rec, req)
2428
2429 require.Equal(t, http.StatusPartialContent, rec.Code)
2430 var result dto.VolumeBackupRunResponse
2431 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &result))
2432 assert.Equal(t, "partial", result.Status)
2433 assert.Equal(t, runErr.Error(), result.Error)
2434 require.Len(t, result.Backups, 1)
2435 assert.Equal(t, "v1", result.Backups[0].ID)
2436}
2437
2438func TestHandler_VolumeBackupsRunDomain_ReturnsServerErrorWithoutResults(t *testing.T) {
2439 volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2440 volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(nil, errors.New("runtime unavailable"))
2441
2442 handler := newTestHandler(t, func(d *HandlerDeps) {
2443 d.VolumeBackupSvc = volumeBackupSvc
2444 })
2445
2446 req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", bytes.NewBufferString(`{}`))
2447 req = req.WithContext(ctxWithScopes("admin:config:write"))
2448 rec := httptest.NewRecorder()
2449
2450 handler.ServeHTTP(rec, req)
2451
2452 assert.Equal(t, http.StatusInternalServerError, rec.Code)
2453 assert.Contains(t, rec.Body.String(), "failed to run volume backups")
2454}
2455
2456func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
2457 configSvc := inmocks.NewMockConfigService(t)
2458 authSvc := inmocks.NewMockAuthService(t)
2459 containerSvc := inmocks.NewMockContainerService(t)
2460 secretSvc := inmocks.NewMockSecretService(t)
2461 backupSvc := inmocks.NewMockBackupService(t)
2462
2463 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)
2464
2465 handler := newTestHandler(t, func(d *HandlerDeps) {
2466 d.ConfigSvc = configSvc
2467 d.AuthSvc = authSvc
2468 d.ContainerSvc = containerSvc
2469 d.SecretSvc = secretSvc
2470 d.BackupSvc = backupSvc
2471 })
2472
2473 body := bytes.NewBufferString(`{"db":"postgres"}`)
2474 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
2475 req.ContentLength = -1
2476 req = req.WithContext(ctxWithScopes("admin:config:write"))
2477 rec := httptest.NewRecorder()
2478
2479 handler.ServeHTTP(rec, req)
2480
2481 assert.Equal(t, http.StatusOK, rec.Code)
2482 assert.Contains(t, rec.Body.String(), "completed")
2483}
2484
2485func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) {
2486 logSvc := inmocks.NewMockLogService(t)
2487 handler := newTestHandler(t, func(d *HandlerDeps) {
2488 d.LogSvc = logSvc
2489 })
2490
2491 t.Run("status read denied", func(t *testing.T) {
2492 server := newScopedTestServer(t, handler, "admin:status:read")
2493 resp, err := http.Get(server.URL + "/admin/logs")
2494 require.NoError(t, err)
2495 defer resp.Body.Close()
2496 bodyBytes, err := io.ReadAll(resp.Body)
2497 require.NoError(t, err)
2498
2499 assert.Equal(t, http.StatusForbidden, resp.StatusCode)
2500 assert.Contains(t, string(bodyBytes), "insufficient permissions for logs:read")
2501 })
2502
2503 t.Run("logs read allowed and redacted", func(t *testing.T) {
2504 logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once()
2505
2506 server := newScopedTestServer(t, handler, "admin:logs:read")
2507 resp, err := http.Get(server.URL + "/admin/logs")
2508 require.NoError(t, err)
2509 defer resp.Body.Close()
2510 bodyBytes, err := io.ReadAll(resp.Body)
2511 require.NoError(t, err)
2512 body := string(bodyBytes)
2513
2514 assert.Equal(t, http.StatusOK, resp.StatusCode)
2515 assert.Contains(t, body, "PASSWORD=[REDACTED]")
2516 assert.Contains(t, body, `\"API_KEY\":\"[REDACTED]\"`)
2517 assert.NotContains(t, body, "hunter2")
2518 assert.NotContains(t, body, "abc123")
2519 })
2520}
2521
2522func TestHandler_BackupsDetectDomain(t *testing.T) {
2523 configSvc := inmocks.NewMockConfigService(t)
2524 authSvc := inmocks.NewMockAuthService(t)
2525 containerSvc := inmocks.NewMockContainerService(t)
2526 secretSvc := inmocks.NewMockSecretService(t)
2527 backupSvc := inmocks.NewMockBackupService(t)
2528
2529 backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
2530
2531 handler := newTestHandler(t, func(d *HandlerDeps) {
2532 d.ConfigSvc = configSvc
2533 d.AuthSvc = authSvc
2534 d.ContainerSvc = containerSvc
2535 d.SecretSvc = secretSvc
2536 d.BackupSvc = backupSvc
2537 })
2538
2539 req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
2540 req = req.WithContext(ctxWithScopes("admin:status:read"))
2541 rec := httptest.NewRecorder()
2542
2543 handler.ServeHTTP(rec, req)
2544
2545 assert.Equal(t, http.StatusOK, rec.Code)
2546 assert.Contains(t, rec.Body.String(), "postgres")
2547}
2548
2549func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
2550 configSvc := inmocks.NewMockConfigService(t)
2551 authSvc := inmocks.NewMockAuthService(t)
2552 containerSvc := inmocks.NewMockContainerService(t)
2553 secretSvc := inmocks.NewMockSecretService(t)
2554
2555 createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
2556 imageSvc := &stubImageService{
2557 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
2558 return []domain.ImageInfo{
2559 {
2560 Repository: "myapp",
2561 Tag: "latest",
2562 Size: 1024,
2563 Created: createdAt,
2564 ID: "sha256:abc123",
2565 Dangling: false,
2566 },
2567 {
2568 Repository: "",
2569 Tag: "",
2570 Size: 512,
2571 Created: createdAt,
2572 ID: "sha256:def456",
2573 Dangling: true,
2574 },
2575 }, nil
2576 },
2577 }
2578
2579 handler := newTestHandler(t, func(d *HandlerDeps) {
2580 d.ConfigSvc = configSvc
2581 d.AuthSvc = authSvc
2582 d.ContainerSvc = containerSvc
2583 d.SecretSvc = secretSvc
2584 d.ImageSvc = imageSvc
2585 })
2586
2587 req := httptest.NewRequest("GET", "/admin/images", nil)
2588 req = req.WithContext(ctxWithScopes("admin:status:read"))
2589 rec := httptest.NewRecorder()
2590
2591 handler.ServeHTTP(rec, req)
2592
2593 assert.Equal(t, http.StatusOK, rec.Code)
2594
2595 var response struct {
2596 Images []struct {
2597 Repository string `json:"repository"`
2598 Tag string `json:"tag"`
2599 Size int64 `json:"size"`
2600 Created time.Time `json:"created"`
2601 ID string `json:"id"`
2602 Dangling bool `json:"dangling"`
2603 } `json:"images"`
2604 }
2605
2606 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
2607 assert.Len(t, response.Images, 2)
2608 assert.Equal(t, "myapp", response.Images[0].Repository)
2609 assert.Equal(t, "latest", response.Images[0].Tag)
2610 assert.Equal(t, int64(1024), response.Images[0].Size)
2611 assert.Equal(t, createdAt, response.Images[0].Created)
2612 assert.Equal(t, "sha256:abc123", response.Images[0].ID)
2613 assert.False(t, response.Images[0].Dangling)
2614 assert.True(t, response.Images[1].Dangling)
2615}
2616
2617func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
2618 configSvc := inmocks.NewMockConfigService(t)
2619 authSvc := inmocks.NewMockAuthService(t)
2620 containerSvc := inmocks.NewMockContainerService(t)
2621 secretSvc := inmocks.NewMockSecretService(t)
2622
2623 tests := []struct {
2624 name string
2625 body string
2626 expectedKeepLast int
2627 }{
2628 {name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast},
2629 {name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
2630 }
2631
2632 for _, tt := range tests {
2633 t.Run(tt.name, func(t *testing.T) {
2634 called := false
2635 imageSvc := &stubImageService{
2636 pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
2637 called = true
2638 assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
2639 return domain.ImagePruneReport{
2640 Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
2641 Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
2642 }, nil
2643 },
2644 }
2645
2646 handler := newTestHandler(t, func(d *HandlerDeps) {
2647 d.ConfigSvc = configSvc
2648 d.AuthSvc = authSvc
2649 d.ContainerSvc = containerSvc
2650 d.SecretSvc = secretSvc
2651 d.ImageSvc = imageSvc
2652 })
2653
2654 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
2655 req = req.WithContext(ctxWithScopes("admin:config:write"))
2656 rec := httptest.NewRecorder()
2657
2658 handler.ServeHTTP(rec, req)
2659
2660 assert.Equal(t, http.StatusOK, rec.Code)
2661 assert.True(t, called)
2662 assert.Contains(t, rec.Body.String(), "deleted_count")
2663 assert.Contains(t, rec.Body.String(), "tags_removed")
2664 })
2665 }
2666}
2667
2668func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
2669 t.Run("list failure maps to internal server error", func(t *testing.T) {
2670 configSvc := inmocks.NewMockConfigService(t)
2671 authSvc := inmocks.NewMockAuthService(t)
2672 containerSvc := inmocks.NewMockContainerService(t)
2673 secretSvc := inmocks.NewMockSecretService(t)
2674
2675 imageSvc := &stubImageService{
2676 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
2677 return nil, errors.New("runtime list failed: confidential details")
2678 },
2679 }
2680
2681 handler := newTestHandler(t, func(d *HandlerDeps) {
2682 d.ConfigSvc = configSvc
2683 d.AuthSvc = authSvc
2684 d.ContainerSvc = containerSvc
2685 d.SecretSvc = secretSvc
2686 d.ImageSvc = imageSvc
2687 })
2688
2689 req := httptest.NewRequest("GET", "/admin/images", nil)
2690 req = req.WithContext(ctxWithScopes("admin:status:read"))
2691 rec := httptest.NewRecorder()
2692
2693 handler.ServeHTTP(rec, req)
2694
2695 assert.Equal(t, http.StatusInternalServerError, rec.Code)
2696 assert.Contains(t, rec.Body.String(), "failed to list images")
2697 assert.NotContains(t, rec.Body.String(), "confidential details")
2698 })
2699
2700 t.Run("prune failure maps to internal server error", func(t *testing.T) {
2701 configSvc := inmocks.NewMockConfigService(t)
2702 authSvc := inmocks.NewMockAuthService(t)
2703 containerSvc := inmocks.NewMockContainerService(t)
2704 secretSvc := inmocks.NewMockSecretService(t)
2705
2706 imageSvc := &stubImageService{
2707 pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
2708 return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
2709 },
2710 }
2711
2712 handler := newTestHandler(t, func(d *HandlerDeps) {
2713 d.ConfigSvc = configSvc
2714 d.AuthSvc = authSvc
2715 d.ContainerSvc = containerSvc
2716 d.SecretSvc = secretSvc
2717 d.ImageSvc = imageSvc
2718 })
2719
2720 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
2721 req = req.WithContext(ctxWithScopes("admin:config:write"))
2722 rec := httptest.NewRecorder()
2723
2724 handler.ServeHTTP(rec, req)
2725
2726 assert.Equal(t, http.StatusInternalServerError, rec.Code)
2727 assert.Contains(t, rec.Body.String(), "failed to prune images")
2728 assert.NotContains(t, rec.Body.String(), "confidential details")
2729 })
2730}
2731
2732func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
2733 t.Run("rejects negative keep_last", func(t *testing.T) {
2734 configSvc := inmocks.NewMockConfigService(t)
2735 authSvc := inmocks.NewMockAuthService(t)
2736 containerSvc := inmocks.NewMockContainerService(t)
2737 secretSvc := inmocks.NewMockSecretService(t)
2738
2739 handler := newTestHandler(t, func(d *HandlerDeps) {
2740 d.ConfigSvc = configSvc
2741 d.AuthSvc = authSvc
2742 d.ContainerSvc = containerSvc
2743 d.SecretSvc = secretSvc
2744 d.ImageSvc = &stubImageService{}
2745 })
2746
2747 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
2748 req = req.WithContext(ctxWithScopes("admin:config:write"))
2749 rec := httptest.NewRecorder()
2750
2751 handler.ServeHTTP(rec, req)
2752
2753 assert.Equal(t, http.StatusBadRequest, rec.Code)
2754 assert.Contains(t, rec.Body.String(), "keep_last must be")
2755 })
2756
2757 t.Run("returns service unavailable when image service missing", func(t *testing.T) {
2758 configSvc := inmocks.NewMockConfigService(t)
2759 authSvc := inmocks.NewMockAuthService(t)
2760 containerSvc := inmocks.NewMockContainerService(t)
2761 secretSvc := inmocks.NewMockSecretService(t)
2762
2763 handler := newTestHandler(t, func(d *HandlerDeps) {
2764 d.ConfigSvc = configSvc
2765 d.AuthSvc = authSvc
2766 d.ContainerSvc = containerSvc
2767 d.SecretSvc = secretSvc
2768 })
2769
2770 req := httptest.NewRequest("GET", "/admin/images", nil)
2771 req = req.WithContext(ctxWithScopes("admin:status:read"))
2772 rec := httptest.NewRecorder()
2773
2774 handler.ServeHTTP(rec, req)
2775
2776 assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
2777 assert.Contains(t, rec.Body.String(), "image service not available")
2778 })
2779}
2780
2781func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
2782 configSvc := inmocks.NewMockConfigService(t)
2783 authSvc := inmocks.NewMockAuthService(t)
2784 containerSvc := inmocks.NewMockContainerService(t)
2785 secretSvc := inmocks.NewMockSecretService(t)
2786
2787 tests := []struct {
2788 name string
2789 body string
2790 expectedPruneDangling bool
2791 expectedPruneRegistry bool
2792 expectedKeepLast int
2793 wantStatus int
2794 }{
2795 {
2796 name: "missing scope fields defaults to both true",
2797 body: `{}`,
2798 expectedPruneDangling: true,
2799 expectedPruneRegistry: true,
2800 expectedKeepLast: domain.DefaultImagePruneKeepLast,
2801 wantStatus: http.StatusOK,
2802 },
2803 {
2804 name: "explicit dangling only",
2805 body: `{"prune_dangling": true, "prune_registry": false}`,
2806 expectedPruneDangling: true,
2807 expectedPruneRegistry: false,
2808 expectedKeepLast: domain.DefaultImagePruneKeepLast,
2809 wantStatus: http.StatusOK,
2810 },
2811 {
2812 name: "explicit registry only",
2813 body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`,
2814 expectedPruneDangling: false,
2815 expectedPruneRegistry: true,
2816 expectedKeepLast: 5,
2817 wantStatus: http.StatusOK,
2818 },
2819 {
2820 name: "both scopes false returns 400",
2821 body: `{"prune_dangling": false, "prune_registry": false}`,
2822 wantStatus: http.StatusBadRequest,
2823 },
2824 }
2825
2826 for _, tt := range tests {
2827 t.Run(tt.name, func(t *testing.T) {
2828 called := false
2829 imageSvc := &stubImageService{
2830 pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
2831 called = true
2832 assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling)
2833 assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry)
2834 assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
2835 return domain.ImagePruneReport{}, nil
2836 },
2837 }
2838
2839 handler := newTestHandler(t, func(d *HandlerDeps) {
2840 d.ConfigSvc = configSvc
2841 d.AuthSvc = authSvc
2842 d.ContainerSvc = containerSvc
2843 d.SecretSvc = secretSvc
2844 d.ImageSvc = imageSvc
2845 })
2846
2847 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
2848 req = req.WithContext(ctxWithScopes("admin:config:write"))
2849 rec := httptest.NewRecorder()
2850
2851 handler.ServeHTTP(rec, req)
2852
2853 assert.Equal(t, tt.wantStatus, rec.Code)
2854 if tt.wantStatus == http.StatusOK {
2855 assert.True(t, called, "prune should have been called")
2856 }
2857 if tt.wantStatus == http.StatusBadRequest {
2858 assert.Contains(t, rec.Body.String(), "at least one prune scope")
2859 }
2860 })
2861 }
2862}
2863
2864func TestHandler_Images_Authorization(t *testing.T) {
2865 configSvc := inmocks.NewMockConfigService(t)
2866 authSvc := inmocks.NewMockAuthService(t)
2867 containerSvc := inmocks.NewMockContainerService(t)
2868 secretSvc := inmocks.NewMockSecretService(t)
2869
2870 handler := newTestHandler(t, func(d *HandlerDeps) {
2871 d.ConfigSvc = configSvc
2872 d.AuthSvc = authSvc
2873 d.ContainerSvc = containerSvc
2874 d.SecretSvc = secretSvc
2875 d.ImageSvc = &stubImageService{}
2876 })
2877
2878 t.Run("images list requires status:read", func(t *testing.T) {
2879 req := httptest.NewRequest("GET", "/admin/images", nil)
2880 req = req.WithContext(ctxWithScopes("admin:config:write"))
2881 rec := httptest.NewRecorder()
2882
2883 handler.ServeHTTP(rec, req)
2884
2885 assert.Equal(t, http.StatusForbidden, rec.Code)
2886 assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
2887 })
2888
2889 t.Run("images prune requires config:write", func(t *testing.T) {
2890 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
2891 req = req.WithContext(ctxWithScopes("admin:status:read"))
2892 rec := httptest.NewRecorder()
2893
2894 handler.ServeHTTP(rec, req)
2895
2896 assert.Equal(t, http.StatusForbidden, rec.Code)
2897 assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
2898 })
2899}
2900
2901func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
2902 handler := newTestHandler(t, func(d *HandlerDeps) {
2903 d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
2904 })
2905
2906 server := newScopedTestServer(t, handler, "admin:config:write")
2907 resp, err := http.Get(server.URL + "/admin/tls/status")
2908 require.NoError(t, err)
2909 defer resp.Body.Close()
2910
2911 body, err := io.ReadAll(resp.Body)
2912 require.NoError(t, err)
2913
2914 assert.Equal(t, http.StatusForbidden, resp.StatusCode)
2915 assert.Contains(t, string(body), "insufficient permissions for status:read")
2916}
2917
2918func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
2919 now := time.Now()
2920 tlsStatus := domain.PublicTLSStatus{
2921 ACMEEnabled: true,
2922 ConfiguredMode: domain.ACMEChallengeAuto,
2923 EffectiveMode: domain.ACMEChallengeHTTP01,
2924 SelectionReason: "auto-selected",
2925 TokenSource: domain.ACMETokenSourcePass,
2926 Certificates: []domain.ManagedCertificate{
2927 {
2928 ID: "cert-1",
2929 Names: []string{"example.com"},
2930 Challenge: domain.ACMEChallengeHTTP01,
2931 Status: domain.TLSCertificateStatusValid,
2932 NotAfter: now.Add(60 * 24 * time.Hour),
2933 },
2934 },
2935 Routes: []domain.TLSRouteCoverage{
2936 {
2937 Domain: "example.com",
2938 Covered: true,
2939 CoveredBy: "cert-1",
2940 RequiredACME: true,
2941 },
2942 },
2943 }
2944
2945 handler := newTestHandler(t, func(d *HandlerDeps) {
2946 fakeSvc := inmocks.NewMockPublicTLSService(t)
2947 fakeSvc.EXPECT().Status(mock.Anything).Return(tlsStatus)
2948 d.PublicTLSSvc = fakeSvc
2949 })
2950
2951 server := newScopedTestServer(t, handler, "admin:status:read")
2952 resp, err := http.Get(server.URL + "/admin/tls/status")
2953 require.NoError(t, err)
2954 defer resp.Body.Close()
2955
2956 assert.Equal(t, http.StatusOK, resp.StatusCode)
2957
2958 var body dto.TLSStatusResponse
2959 err = json.NewDecoder(resp.Body).Decode(&body)
2960 require.NoError(t, err)
2961
2962 assert.True(t, body.ACMEEnabled)
2963 assert.Equal(t, "auto", body.ConfiguredMode)
2964 assert.Equal(t, "http-01", body.EffectiveMode)
2965 assert.Equal(t, "auto-selected", body.SelectionReason)
2966 assert.Equal(t, "pass", body.TokenSource)
2967 require.Len(t, body.Certificates, 1)
2968 assert.Equal(t, "cert-1", body.Certificates[0].ID)
2969 assert.Equal(t, "valid", body.Certificates[0].Status)
2970 assert.Equal(t, "http-01", body.Certificates[0].Challenge)
2971 require.Len(t, body.Routes, 1)
2972 assert.Equal(t, "example.com", body.Routes[0].Domain)
2973 assert.True(t, body.Routes[0].Covered)
2974}
2975
2976func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
2977 handler := newTestHandler(t, func(d *HandlerDeps) {
2978 d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
2979 })
2980
2981 server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write")
2982 resp, err := http.Post(server.URL+"/admin/tls/status", "application/json", nil)
2983 require.NoError(t, err)
2984 defer resp.Body.Close()
2985
2986 assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
2987}
2988
2989func TestHandler_AttachmentOrphans_ReturnsOrphanedAttachments(t *testing.T) {
2990 configSvc := inmocks.NewMockConfigService(t)
2991 authSvc := inmocks.NewMockAuthService(t)
2992 containerSvc := inmocks.NewMockContainerService(t)
2993 secretSvc := inmocks.NewMockSecretService(t)
2994 attachments := []domain.CleanupAttachment{{ContainerID: "attachment-1", Name: "postgres", Owner: "app.example.com"}}
2995 containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(attachments, nil).Once()
2996
2997 handler := newTestHandler(t, func(d *HandlerDeps) {
2998 d.ConfigSvc = configSvc
2999 d.AuthSvc = authSvc
3000 d.ContainerSvc = containerSvc
3001 d.SecretSvc = secretSvc
3002 })
3003
3004 req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil)
3005 req = req.WithContext(ctxWithScopes("admin:config:read"))
3006 rec := httptest.NewRecorder()
3007
3008 handler.ServeHTTP(rec, req)
3009
3010 require.Equal(t, http.StatusOK, rec.Code)
3011 var resp struct {
3012 Attachments []domain.CleanupAttachment `json:"attachments"`
3013 }
3014 require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
3015 require.Len(t, resp.Attachments, 1)
3016 assert.Equal(t, "app.example.com", resp.Attachments[0].Owner)
3017}
3018
3019func TestHandler_AttachmentPrune_StopsOnlyWhenRequested(t *testing.T) {
3020 configSvc := inmocks.NewMockConfigService(t)
3021 authSvc := inmocks.NewMockAuthService(t)
3022 containerSvc := inmocks.NewMockContainerService(t)
3023 secretSvc := inmocks.NewMockSecretService(t)
3024 report := &domain.CleanupReport{RemovedContainers: []domain.CleanupContainer{{ID: "attachment-1"}}}
3025 containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(report, nil).Once()
3026
3027 handler := newTestHandler(t, func(d *HandlerDeps) {
3028 d.ConfigSvc = configSvc
3029 d.AuthSvc = authSvc
3030 d.ContainerSvc = containerSvc
3031 d.SecretSvc = secretSvc
3032 })
3033
3034 req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil)
3035 req = req.WithContext(ctxWithScopes("admin:config:write"))
3036 rec := httptest.NewRecorder()
3037
3038 handler.ServeHTTP(rec, req)
3039
3040 require.Equal(t, http.StatusOK, rec.Code)
3041 var got domain.CleanupReport
3042 require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
3043 require.Len(t, got.RemovedContainers, 1)
3044 assert.Equal(t, "attachment-1", got.RemovedContainers[0].ID)
3045}
3046
3047func TestHandler_AttachmentOrphans_ReturnsServerErrorOnListFailure(t *testing.T) {
3048 configSvc := inmocks.NewMockConfigService(t)
3049 authSvc := inmocks.NewMockAuthService(t)
3050 containerSvc := inmocks.NewMockContainerService(t)
3051 secretSvc := inmocks.NewMockSecretService(t)
3052 containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(nil, errors.New("boom")).Once()
3053
3054 handler := newTestHandler(t, func(d *HandlerDeps) {
3055 d.ConfigSvc = configSvc
3056 d.AuthSvc = authSvc
3057 d.ContainerSvc = containerSvc
3058 d.SecretSvc = secretSvc
3059 })
3060
3061 req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil)
3062 req = req.WithContext(ctxWithScopes("admin:config:read"))
3063 rec := httptest.NewRecorder()
3064
3065 handler.ServeHTTP(rec, req)
3066
3067 assert.Equal(t, http.StatusInternalServerError, rec.Code)
3068}
3069
3070func TestHandler_AttachmentOrphans_PostReturns405(t *testing.T) {
3071 handler := newTestHandler(t, func(d *HandlerDeps) {
3072 d.ContainerSvc = inmocks.NewMockContainerService(t)
3073 })
3074 req := httptest.NewRequest("POST", "/admin/attachments/orphans", nil)
3075 req = req.WithContext(ctxWithScopes("admin:config:read"))
3076 rec := httptest.NewRecorder()
3077
3078 handler.ServeHTTP(rec, req)
3079
3080 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
3081}
3082
3083func TestHandler_AttachmentPrune_ReturnsServerErrorOnCleanupFailure(t *testing.T) {
3084 configSvc := inmocks.NewMockConfigService(t)
3085 authSvc := inmocks.NewMockAuthService(t)
3086 containerSvc := inmocks.NewMockContainerService(t)
3087 secretSvc := inmocks.NewMockSecretService(t)
3088 containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(nil, errors.New("boom")).Once()
3089
3090 handler := newTestHandler(t, func(d *HandlerDeps) {
3091 d.ConfigSvc = configSvc
3092 d.AuthSvc = authSvc
3093 d.ContainerSvc = containerSvc
3094 d.SecretSvc = secretSvc
3095 })
3096
3097 req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil)
3098 req = req.WithContext(ctxWithScopes("admin:config:write"))
3099 rec := httptest.NewRecorder()
3100
3101 handler.ServeHTTP(rec, req)
3102
3103 assert.Equal(t, http.StatusInternalServerError, rec.Code)
3104}
3105
3106func TestHandler_AttachmentPrune_GetReturns405(t *testing.T) {
3107 handler := newTestHandler(t, func(d *HandlerDeps) {
3108 d.ContainerSvc = inmocks.NewMockContainerService(t)
3109 })
3110 req := httptest.NewRequest("GET", "/admin/attachments/prune", nil)
3111 req = req.WithContext(ctxWithScopes("admin:config:write"))
3112 rec := httptest.NewRecorder()
3113
3114 handler.ServeHTTP(rec, req)
3115
3116 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
3117}