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