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