1package admin
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "net/http"
10 "net/http/httptest"
11 "testing"
12 "time"
13
14 "github.com/bnema/zerowrap"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/mock"
17
18 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
19 "github.com/bnema/gordon/internal/domain"
20)
21
22type stubImageService struct {
23 listImagesFunc func(context.Context) ([]domain.ImageInfo, error)
24 pruneFunc func(context.Context, int) (domain.ImagePruneReport, error)
25}
26
27func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
28 if s.listImagesFunc == nil {
29 return nil, nil
30 }
31 return s.listImagesFunc(ctx)
32}
33
34func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
35 if s.pruneFunc == nil {
36 return domain.ImagePruneReport{}, nil
37 }
38 return s.pruneFunc(ctx, keepLast)
39}
40
41func testLogger() zerowrap.Logger {
42 return zerowrap.Default()
43}
44
45func ctxWithScopes(scopes ...string) context.Context {
46 ctx := context.Background()
47 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
48}
49
50// Routes endpoint tests
51
52func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
53 configSvc := inmocks.NewMockConfigService(t)
54 authSvc := inmocks.NewMockAuthService(t)
55 containerSvc := inmocks.NewMockContainerService(t)
56 secretSvc := inmocks.NewMockSecretService(t)
57
58 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
59
60 tests := []struct {
61 name string
62 scopes []string
63 wantStatus int
64 }{
65 {
66 name: "routes read access granted",
67 scopes: []string{"admin:routes:read"},
68 wantStatus: http.StatusOK,
69 },
70 {
71 name: "routes wildcard access granted",
72 scopes: []string{"admin:routes:*"},
73 wantStatus: http.StatusOK,
74 },
75 {
76 name: "all admin access granted",
77 scopes: []string{"admin:*:*"},
78 wantStatus: http.StatusOK,
79 },
80 {
81 name: "only write access denied",
82 scopes: []string{"admin:routes:write"},
83 wantStatus: http.StatusForbidden,
84 },
85 {
86 name: "wrong resource denied",
87 scopes: []string{"admin:secrets:read"},
88 wantStatus: http.StatusForbidden,
89 },
90 {
91 name: "no scopes denied",
92 scopes: []string{},
93 wantStatus: http.StatusForbidden,
94 },
95 }
96
97 for _, tt := range tests {
98 t.Run(tt.name, func(t *testing.T) {
99 if tt.wantStatus == http.StatusOK {
100 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
101 }
102
103 req := httptest.NewRequest("GET", "/admin/routes", nil)
104 req = req.WithContext(ctxWithScopes(tt.scopes...))
105 rec := httptest.NewRecorder()
106
107 handler.ServeHTTP(rec, req)
108
109 assert.Equal(t, tt.wantStatus, rec.Code)
110 })
111 }
112}
113
114func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
115 configSvc := inmocks.NewMockConfigService(t)
116 authSvc := inmocks.NewMockAuthService(t)
117 containerSvc := inmocks.NewMockContainerService(t)
118 secretSvc := inmocks.NewMockSecretService(t)
119 registrySvc := inmocks.NewMockRegistryService(t)
120
121 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
122
123 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
124
125 tests := []struct {
126 name string
127 scopes []string
128 wantStatus int
129 }{
130 {
131 name: "routes write access granted",
132 scopes: []string{"admin:routes:write"},
133 wantStatus: http.StatusCreated,
134 },
135 {
136 name: "routes wildcard access granted",
137 scopes: []string{"admin:routes:*"},
138 wantStatus: http.StatusCreated,
139 },
140 {
141 name: "all admin access granted",
142 scopes: []string{"admin:*:*"},
143 wantStatus: http.StatusCreated,
144 },
145 {
146 name: "only read access denied",
147 scopes: []string{"admin:routes:read"},
148 wantStatus: http.StatusForbidden,
149 },
150 {
151 name: "wrong resource denied",
152 scopes: []string{"admin:secrets:write"},
153 wantStatus: http.StatusForbidden,
154 },
155 }
156
157 for _, tt := range tests {
158 t.Run(tt.name, func(t *testing.T) {
159 if tt.wantStatus == http.StatusCreated {
160 configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
161 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe()
162 }
163
164 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
165 req = req.WithContext(ctxWithScopes(tt.scopes...))
166 rec := httptest.NewRecorder()
167
168 handler.ServeHTTP(rec, req)
169
170 assert.Equal(t, tt.wantStatus, rec.Code)
171 })
172 }
173}
174
175func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
176 configSvc := inmocks.NewMockConfigService(t)
177 authSvc := inmocks.NewMockAuthService(t)
178 containerSvc := inmocks.NewMockContainerService(t)
179 secretSvc := inmocks.NewMockSecretService(t)
180 registrySvc := inmocks.NewMockRegistryService(t)
181
182 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
183
184 routeJSON := `{"image": "myapp:v2"}`
185
186 tests := []struct {
187 name string
188 scopes []string
189 wantStatus int
190 }{
191 {
192 name: "routes write access granted",
193 scopes: []string{"admin:routes:write"},
194 wantStatus: http.StatusOK,
195 },
196 {
197 name: "only read access denied",
198 scopes: []string{"admin:routes:read"},
199 wantStatus: http.StatusForbidden,
200 },
201 }
202
203 for _, tt := range tests {
204 t.Run(tt.name, func(t *testing.T) {
205 if tt.wantStatus == http.StatusOK {
206 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
207 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe()
208 }
209
210 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
211 req = req.WithContext(ctxWithScopes(tt.scopes...))
212 rec := httptest.NewRecorder()
213
214 handler.ServeHTTP(rec, req)
215
216 assert.Equal(t, tt.wantStatus, rec.Code)
217 })
218 }
219}
220
221func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
222 configSvc := inmocks.NewMockConfigService(t)
223 authSvc := inmocks.NewMockAuthService(t)
224 containerSvc := inmocks.NewMockContainerService(t)
225 secretSvc := inmocks.NewMockSecretService(t)
226
227 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
228
229 tests := []struct {
230 name string
231 scopes []string
232 wantStatus int
233 }{
234 {
235 name: "routes write access granted",
236 scopes: []string{"admin:routes:write"},
237 wantStatus: http.StatusOK,
238 },
239 {
240 name: "only read access denied",
241 scopes: []string{"admin:routes:read"},
242 wantStatus: http.StatusForbidden,
243 },
244 }
245
246 for _, tt := range tests {
247 t.Run(tt.name, func(t *testing.T) {
248 if tt.wantStatus == http.StatusOK {
249 configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
250 }
251
252 req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
253 req = req.WithContext(ctxWithScopes(tt.scopes...))
254 rec := httptest.NewRecorder()
255
256 handler.ServeHTTP(rec, req)
257
258 assert.Equal(t, tt.wantStatus, rec.Code)
259 })
260 }
261}
262
263// Secrets endpoint tests
264
265func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
266 configSvc := inmocks.NewMockConfigService(t)
267 authSvc := inmocks.NewMockAuthService(t)
268 containerSvc := inmocks.NewMockContainerService(t)
269 secretSvc := inmocks.NewMockSecretService(t)
270
271 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
272
273 tests := []struct {
274 name string
275 scopes []string
276 wantStatus int
277 }{
278 {
279 name: "secrets read access granted",
280 scopes: []string{"admin:secrets:read"},
281 wantStatus: http.StatusOK,
282 },
283 {
284 name: "secrets wildcard access granted",
285 scopes: []string{"admin:secrets:*"},
286 wantStatus: http.StatusOK,
287 },
288 {
289 name: "all admin access granted",
290 scopes: []string{"admin:*:*"},
291 wantStatus: http.StatusOK,
292 },
293 {
294 name: "only write access denied",
295 scopes: []string{"admin:secrets:write"},
296 wantStatus: http.StatusForbidden,
297 },
298 {
299 name: "wrong resource denied",
300 scopes: []string{"admin:routes:read"},
301 wantStatus: http.StatusForbidden,
302 },
303 }
304
305 for _, tt := range tests {
306 t.Run(tt.name, func(t *testing.T) {
307 if tt.wantStatus == http.StatusOK {
308 secretSvc.EXPECT().ListKeysWithAttachments(mock.Anything, "app.example.com").Return([]string{}, nil, nil).Maybe()
309 }
310
311 req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
312 req = req.WithContext(ctxWithScopes(tt.scopes...))
313 rec := httptest.NewRecorder()
314
315 handler.ServeHTTP(rec, req)
316
317 assert.Equal(t, tt.wantStatus, rec.Code)
318 })
319 }
320}
321
322func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
323 configSvc := inmocks.NewMockConfigService(t)
324 authSvc := inmocks.NewMockAuthService(t)
325 containerSvc := inmocks.NewMockContainerService(t)
326 secretSvc := inmocks.NewMockSecretService(t)
327
328 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
329
330 secretsJSON := `{"API_KEY": "secret123"}`
331
332 tests := []struct {
333 name string
334 scopes []string
335 wantStatus int
336 }{
337 {
338 name: "secrets write access granted",
339 scopes: []string{"admin:secrets:write"},
340 wantStatus: http.StatusOK,
341 },
342 {
343 name: "only read access denied",
344 scopes: []string{"admin:secrets:read"},
345 wantStatus: http.StatusForbidden,
346 },
347 {
348 name: "wrong resource denied",
349 scopes: []string{"admin:routes:write"},
350 wantStatus: http.StatusForbidden,
351 },
352 }
353
354 for _, tt := range tests {
355 t.Run(tt.name, func(t *testing.T) {
356 if tt.wantStatus == http.StatusOK {
357 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe()
358 }
359
360 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
361 req = req.WithContext(ctxWithScopes(tt.scopes...))
362 rec := httptest.NewRecorder()
363
364 handler.ServeHTTP(rec, req)
365
366 assert.Equal(t, tt.wantStatus, rec.Code)
367 })
368 }
369}
370
371func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
372 configSvc := inmocks.NewMockConfigService(t)
373 authSvc := inmocks.NewMockAuthService(t)
374 containerSvc := inmocks.NewMockContainerService(t)
375 secretSvc := inmocks.NewMockSecretService(t)
376
377 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
378
379 tests := []struct {
380 name string
381 scopes []string
382 wantStatus int
383 }{
384 {
385 name: "secrets write access granted",
386 scopes: []string{"admin:secrets:write"},
387 wantStatus: http.StatusOK,
388 },
389 {
390 name: "only read access denied",
391 scopes: []string{"admin:secrets:read"},
392 wantStatus: http.StatusForbidden,
393 },
394 }
395
396 for _, tt := range tests {
397 t.Run(tt.name, func(t *testing.T) {
398 if tt.wantStatus == http.StatusOK {
399 secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe()
400 }
401
402 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
403 req = req.WithContext(ctxWithScopes(tt.scopes...))
404 rec := httptest.NewRecorder()
405
406 handler.ServeHTTP(rec, req)
407
408 assert.Equal(t, tt.wantStatus, rec.Code)
409 })
410 }
411}
412
413// Attachment secrets endpoint tests
414
415func TestHandler_AttachmentSecretsPost(t *testing.T) {
416 configSvc := inmocks.NewMockConfigService(t)
417 authSvc := inmocks.NewMockAuthService(t)
418 containerSvc := inmocks.NewMockContainerService(t)
419 secretSvc := inmocks.NewMockSecretService(t)
420
421 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
422
423 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
424
425 body := `{"REDIS_URL": "redis://localhost:6379"}`
426 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
427 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
428 rec := httptest.NewRecorder()
429
430 handler.ServeHTTP(rec, req)
431
432 assert.Equal(t, http.StatusOK, rec.Code)
433
434 var response struct {
435 Status string `json:"status"`
436 }
437 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
438 assert.Equal(t, "updated", response.Status)
439}
440
441func TestHandler_AttachmentSecretsDelete(t *testing.T) {
442 configSvc := inmocks.NewMockConfigService(t)
443 authSvc := inmocks.NewMockAuthService(t)
444 containerSvc := inmocks.NewMockContainerService(t)
445 secretSvc := inmocks.NewMockSecretService(t)
446
447 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
448
449 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
450
451 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
452 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
453 rec := httptest.NewRecorder()
454
455 handler.ServeHTTP(rec, req)
456
457 assert.Equal(t, http.StatusOK, rec.Code)
458
459 var response struct {
460 Status string `json:"status"`
461 }
462 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
463 assert.Equal(t, "deleted", response.Status)
464}
465
466// Status endpoint tests
467
468func TestHandler_Status_RequiresReadScope(t *testing.T) {
469 configSvc := inmocks.NewMockConfigService(t)
470 authSvc := inmocks.NewMockAuthService(t)
471 containerSvc := inmocks.NewMockContainerService(t)
472 secretSvc := inmocks.NewMockSecretService(t)
473
474 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
475
476 tests := []struct {
477 name string
478 scopes []string
479 wantStatus int
480 }{
481 {
482 name: "status read access granted",
483 scopes: []string{"admin:status:read"},
484 wantStatus: http.StatusOK,
485 },
486 {
487 name: "all admin access granted",
488 scopes: []string{"admin:*:*"},
489 wantStatus: http.StatusOK,
490 },
491 {
492 name: "wrong resource denied",
493 scopes: []string{"admin:routes:read"},
494 wantStatus: http.StatusForbidden,
495 },
496 }
497
498 for _, tt := range tests {
499 t.Run(tt.name, func(t *testing.T) {
500 if tt.wantStatus == http.StatusOK {
501 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
502 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
503 configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
504 configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
505 configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
506 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
507 }
508
509 req := httptest.NewRequest("GET", "/admin/status", nil)
510 req = req.WithContext(ctxWithScopes(tt.scopes...))
511 rec := httptest.NewRecorder()
512
513 handler.ServeHTTP(rec, req)
514
515 assert.Equal(t, tt.wantStatus, rec.Code)
516 })
517 }
518}
519
520// Config endpoint tests
521
522func TestHandler_Config_RequiresReadScope(t *testing.T) {
523 configSvc := inmocks.NewMockConfigService(t)
524 authSvc := inmocks.NewMockAuthService(t)
525 containerSvc := inmocks.NewMockContainerService(t)
526 secretSvc := inmocks.NewMockSecretService(t)
527
528 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
529
530 tests := []struct {
531 name string
532 scopes []string
533 wantStatus int
534 }{
535 {
536 name: "config read access granted",
537 scopes: []string{"admin:config:read"},
538 wantStatus: http.StatusOK,
539 },
540 {
541 name: "all admin access granted",
542 scopes: []string{"admin:*:*"},
543 wantStatus: http.StatusOK,
544 },
545 {
546 name: "wrong resource denied",
547 scopes: []string{"admin:routes:read"},
548 wantStatus: http.StatusForbidden,
549 },
550 }
551
552 for _, tt := range tests {
553 t.Run(tt.name, func(t *testing.T) {
554 if tt.wantStatus == http.StatusOK {
555 configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
556 configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
557 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
558 configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
559 configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
560 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
561 configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
562 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
563 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
564 }
565
566 req := httptest.NewRequest("GET", "/admin/config", nil)
567 req = req.WithContext(ctxWithScopes(tt.scopes...))
568 rec := httptest.NewRecorder()
569
570 handler.ServeHTTP(rec, req)
571
572 assert.Equal(t, tt.wantStatus, rec.Code)
573 })
574 }
575}
576
577// Reload endpoint tests
578
579func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
580 configSvc := inmocks.NewMockConfigService(t)
581 authSvc := inmocks.NewMockAuthService(t)
582 containerSvc := inmocks.NewMockContainerService(t)
583 secretSvc := inmocks.NewMockSecretService(t)
584
585 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
586
587 tests := []struct {
588 name string
589 scopes []string
590 wantStatus int
591 }{
592 {
593 name: "config write access granted",
594 scopes: []string{"admin:config:write"},
595 wantStatus: http.StatusOK,
596 },
597 {
598 name: "all admin access granted",
599 scopes: []string{"admin:*:*"},
600 wantStatus: http.StatusOK,
601 },
602 {
603 name: "only read access denied",
604 scopes: []string{"admin:config:read"},
605 wantStatus: http.StatusForbidden,
606 },
607 {
608 name: "wrong resource denied",
609 scopes: []string{"admin:routes:write"},
610 wantStatus: http.StatusForbidden,
611 },
612 }
613
614 for _, tt := range tests {
615 t.Run(tt.name, func(t *testing.T) {
616 if tt.wantStatus == http.StatusOK {
617 configSvc.EXPECT().Reload(mock.Anything).Return(nil).Maybe()
618 }
619
620 req := httptest.NewRequest("POST", "/admin/reload", nil)
621 req = req.WithContext(ctxWithScopes(tt.scopes...))
622 rec := httptest.NewRecorder()
623
624 handler.ServeHTTP(rec, req)
625
626 assert.Equal(t, tt.wantStatus, rec.Code)
627 })
628 }
629}
630
631// Functional tests
632
633func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
634 configSvc := inmocks.NewMockConfigService(t)
635 authSvc := inmocks.NewMockAuthService(t)
636 containerSvc := inmocks.NewMockContainerService(t)
637 secretSvc := inmocks.NewMockSecretService(t)
638
639 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
640
641 expectedRoutes := []domain.Route{
642 {Domain: "app1.example.com", Image: "app1:latest"},
643 {Domain: "app2.example.com", Image: "app2:v1.0"},
644 }
645 configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
646
647 req := httptest.NewRequest("GET", "/admin/routes", nil)
648 req = req.WithContext(ctxWithScopes("admin:routes:read"))
649 rec := httptest.NewRecorder()
650
651 handler.ServeHTTP(rec, req)
652
653 assert.Equal(t, http.StatusOK, rec.Code)
654
655 var response struct {
656 Routes []struct {
657 Domain string `json:"domain"`
658 Image string `json:"image"`
659 HTTPS bool `json:"https"`
660 } `json:"routes"`
661 }
662 err := json.NewDecoder(rec.Body).Decode(&response)
663 assert.NoError(t, err)
664
665 assert.Len(t, response.Routes, 2)
666}
667
668func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
669 configSvc := inmocks.NewMockConfigService(t)
670 authSvc := inmocks.NewMockAuthService(t)
671 containerSvc := inmocks.NewMockContainerService(t)
672 secretSvc := inmocks.NewMockSecretService(t)
673
674 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
675
676 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
677 Domain: "app.example.com", Image: "app:latest",
678 }, nil)
679
680 req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
681 req = req.WithContext(ctxWithScopes("admin:routes:read"))
682 rec := httptest.NewRecorder()
683
684 handler.ServeHTTP(rec, req)
685
686 assert.Equal(t, http.StatusOK, rec.Code)
687
688 var route struct {
689 Domain string `json:"domain"`
690 Image string `json:"image"`
691 HTTPS bool `json:"https"`
692 }
693 err := json.NewDecoder(rec.Body).Decode(&route)
694 assert.NoError(t, err)
695 assert.Equal(t, "app.example.com", route.Domain)
696 assert.Equal(t, "app:latest", route.Image)
697}
698
699func TestHandler_RoutesGet_NotFound(t *testing.T) {
700 configSvc := inmocks.NewMockConfigService(t)
701 authSvc := inmocks.NewMockAuthService(t)
702 containerSvc := inmocks.NewMockContainerService(t)
703 secretSvc := inmocks.NewMockSecretService(t)
704
705 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
706
707 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
708
709 req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
710 req = req.WithContext(ctxWithScopes("admin:routes:read"))
711 rec := httptest.NewRecorder()
712
713 handler.ServeHTTP(rec, req)
714
715 assert.Equal(t, http.StatusNotFound, rec.Code)
716}
717
718func TestHandler_RoutesPost_MissingFields(t *testing.T) {
719 tests := []struct {
720 name string
721 body string
722 mockErr error
723 }{
724 {
725 name: "missing domain",
726 body: `{"image": "app:latest"}`,
727 mockErr: domain.ErrRouteDomainEmpty,
728 },
729 {
730 name: "missing image",
731 body: `{"domain": "app.example.com"}`,
732 mockErr: domain.ErrRouteImageEmpty,
733 },
734 {
735 name: "empty object",
736 body: `{}`,
737 mockErr: domain.ErrRouteDomainEmpty,
738 },
739 }
740
741 for _, tt := range tests {
742 t.Run(tt.name, func(t *testing.T) {
743 configSvc := inmocks.NewMockConfigService(t)
744 authSvc := inmocks.NewMockAuthService(t)
745 containerSvc := inmocks.NewMockContainerService(t)
746 secretSvc := inmocks.NewMockSecretService(t)
747 registrySvc := inmocks.NewMockRegistryService(t)
748
749 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
750
751 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
752 registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
753
754 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
755 req = req.WithContext(ctxWithScopes("admin:routes:write"))
756 rec := httptest.NewRecorder()
757
758 handler.ServeHTTP(rec, req)
759
760 assert.Equal(t, http.StatusBadRequest, rec.Code)
761 })
762 }
763}
764
765func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
766 configSvc := inmocks.NewMockConfigService(t)
767 authSvc := inmocks.NewMockAuthService(t)
768 containerSvc := inmocks.NewMockContainerService(t)
769 secretSvc := inmocks.NewMockSecretService(t)
770 registrySvc := inmocks.NewMockRegistryService(t)
771
772 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
773
774 tests := []struct {
775 name string
776 image string
777 body string
778 manifestName string
779 manifestRef string
780 }{
781 {
782 name: "tagged image not found",
783 image: "myapp:latest",
784 body: `{"domain": "app.example.com", "image": "myapp:latest"}`,
785 manifestName: "myapp",
786 manifestRef: "latest",
787 },
788 {
789 name: "digest image not found",
790 image: "myapp@sha256:abc123",
791 body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
792 manifestName: "myapp",
793 manifestRef: "sha256:abc123",
794 },
795 {
796 name: "implicit latest tag not found",
797 image: "myapp",
798 body: `{"domain": "app.example.com", "image": "myapp"}`,
799 manifestName: "myapp",
800 manifestRef: "latest",
801 },
802 }
803
804 for _, tt := range tests {
805 t.Run(tt.name, func(t *testing.T) {
806 registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound)
807
808 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
809 req = req.WithContext(ctxWithScopes("admin:routes:write"))
810 rec := httptest.NewRecorder()
811
812 handler.ServeHTTP(rec, req)
813
814 assert.Equal(t, http.StatusBadRequest, rec.Code)
815 assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
816 })
817 }
818}
819
820func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
821 configSvc := inmocks.NewMockConfigService(t)
822 authSvc := inmocks.NewMockAuthService(t)
823 containerSvc := inmocks.NewMockContainerService(t)
824 secretSvc := inmocks.NewMockSecretService(t)
825
826 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
827
828 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
829 req = req.WithContext(ctxWithScopes("admin:routes:write"))
830 rec := httptest.NewRecorder()
831
832 handler.ServeHTTP(rec, req)
833
834 assert.Equal(t, http.StatusBadRequest, rec.Code)
835}
836
837func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
838 configSvc := inmocks.NewMockConfigService(t)
839 authSvc := inmocks.NewMockAuthService(t)
840 containerSvc := inmocks.NewMockContainerService(t)
841 secretSvc := inmocks.NewMockSecretService(t)
842
843 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
844
845 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
846 req = req.WithContext(ctxWithScopes("admin:routes:write"))
847 rec := httptest.NewRecorder()
848
849 handler.ServeHTTP(rec, req)
850
851 assert.Equal(t, http.StatusBadRequest, rec.Code)
852}
853
854func TestHandler_Secrets_MissingDomain(t *testing.T) {
855 configSvc := inmocks.NewMockConfigService(t)
856 authSvc := inmocks.NewMockAuthService(t)
857 containerSvc := inmocks.NewMockContainerService(t)
858 secretSvc := inmocks.NewMockSecretService(t)
859
860 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
861
862 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
863 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
864 rec := httptest.NewRecorder()
865
866 handler.ServeHTTP(rec, req)
867
868 assert.Equal(t, http.StatusBadRequest, rec.Code)
869}
870
871func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
872 configSvc := inmocks.NewMockConfigService(t)
873 authSvc := inmocks.NewMockAuthService(t)
874 containerSvc := inmocks.NewMockContainerService(t)
875 secretSvc := inmocks.NewMockSecretService(t)
876
877 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
878
879 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
880 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
881 rec := httptest.NewRecorder()
882
883 handler.ServeHTTP(rec, req)
884
885 assert.Equal(t, http.StatusBadRequest, rec.Code)
886}
887
888func TestHandler_MethodNotAllowed(t *testing.T) {
889 configSvc := inmocks.NewMockConfigService(t)
890 authSvc := inmocks.NewMockAuthService(t)
891 containerSvc := inmocks.NewMockContainerService(t)
892 secretSvc := inmocks.NewMockSecretService(t)
893
894 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
895
896 tests := []struct {
897 method string
898 path string
899 }{
900 {"DELETE", "/admin/status"},
901 {"PUT", "/admin/status"},
902 {"GET", "/admin/reload"},
903 {"DELETE", "/admin/reload"},
904 {"POST", "/admin/config"},
905 {"DELETE", "/admin/config"},
906 }
907
908 for _, tt := range tests {
909 t.Run(tt.method+" "+tt.path, func(t *testing.T) {
910 req := httptest.NewRequest(tt.method, tt.path, nil)
911 req = req.WithContext(ctxWithScopes("admin:*:*"))
912 rec := httptest.NewRecorder()
913
914 handler.ServeHTTP(rec, req)
915
916 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
917 })
918 }
919}
920
921func TestHandler_Restart_InvalidDomain(t *testing.T) {
922 configSvc := inmocks.NewMockConfigService(t)
923 authSvc := inmocks.NewMockAuthService(t)
924 containerSvc := inmocks.NewMockContainerService(t)
925 secretSvc := inmocks.NewMockSecretService(t)
926
927 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
928
929 tests := []struct {
930 name string
931 path string
932 status int
933 }{
934 {"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest},
935 {"empty domain", "/admin/restart/", http.StatusBadRequest},
936 }
937
938 for _, tt := range tests {
939 t.Run(tt.name, func(t *testing.T) {
940 req := httptest.NewRequest("POST", tt.path, nil)
941 req = req.WithContext(ctxWithScopes("admin:config:write"))
942 rec := httptest.NewRecorder()
943
944 handler.ServeHTTP(rec, req)
945
946 assert.Equal(t, tt.status, rec.Code)
947 })
948 }
949}
950
951func TestHandler_Restart_ContainerNotFound(t *testing.T) {
952 configSvc := inmocks.NewMockConfigService(t)
953 authSvc := inmocks.NewMockAuthService(t)
954 containerSvc := inmocks.NewMockContainerService(t)
955 secretSvc := inmocks.NewMockSecretService(t)
956
957 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
958
959 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
960
961 req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil)
962 req = req.WithContext(ctxWithScopes("admin:config:write"))
963 rec := httptest.NewRecorder()
964
965 handler.ServeHTTP(rec, req)
966
967 assert.Equal(t, http.StatusNotFound, rec.Code)
968}
969
970func TestHandler_Restart_GenericError(t *testing.T) {
971 configSvc := inmocks.NewMockConfigService(t)
972 authSvc := inmocks.NewMockAuthService(t)
973 containerSvc := inmocks.NewMockContainerService(t)
974 secretSvc := inmocks.NewMockSecretService(t)
975
976 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
977
978 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
979
980 req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil)
981 req = req.WithContext(ctxWithScopes("admin:config:write"))
982 rec := httptest.NewRecorder()
983
984 handler.ServeHTTP(rec, req)
985
986 assert.Equal(t, http.StatusInternalServerError, rec.Code)
987 // Verify internal error details are NOT leaked
988 assert.NotContains(t, rec.Body.String(), "docker daemon error")
989 assert.NotContains(t, rec.Body.String(), "secret details")
990}
991
992func TestHandler_Restart_Success(t *testing.T) {
993 configSvc := inmocks.NewMockConfigService(t)
994 authSvc := inmocks.NewMockAuthService(t)
995 containerSvc := inmocks.NewMockContainerService(t)
996 secretSvc := inmocks.NewMockSecretService(t)
997
998 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
999
1000 tests := []struct {
1001 name string
1002 path string
1003 withAttachments bool
1004 }{
1005 {
1006 name: "without attachments",
1007 path: "/admin/restart/test.example.com",
1008 withAttachments: false,
1009 },
1010 {
1011 name: "with attachments",
1012 path: "/admin/restart/test.example.com?attachments=true",
1013 withAttachments: true,
1014 },
1015 }
1016
1017 for _, tt := range tests {
1018 t.Run(tt.name, func(t *testing.T) {
1019 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
1020
1021 req := httptest.NewRequest("POST", tt.path, nil)
1022 req = req.WithContext(ctxWithScopes("admin:config:write"))
1023 rec := httptest.NewRecorder()
1024
1025 handler.ServeHTTP(rec, req)
1026
1027 assert.Equal(t, http.StatusOK, rec.Code)
1028 var response struct {
1029 Status string `json:"status"`
1030 Domain string `json:"domain"`
1031 }
1032 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1033 assert.Equal(t, "restarted", response.Status)
1034 assert.Equal(t, "test.example.com", response.Domain)
1035 })
1036 }
1037}
1038
1039func TestHandler_Tags_InvalidRepository(t *testing.T) {
1040 configSvc := inmocks.NewMockConfigService(t)
1041 authSvc := inmocks.NewMockAuthService(t)
1042 containerSvc := inmocks.NewMockContainerService(t)
1043 secretSvc := inmocks.NewMockSecretService(t)
1044
1045 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1046
1047 tests := []struct {
1048 name string
1049 path string
1050 status int
1051 }{
1052 {"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
1053 {"empty repo", "/admin/tags/", http.StatusBadRequest},
1054 }
1055
1056 for _, tt := range tests {
1057 t.Run(tt.name, func(t *testing.T) {
1058 req := httptest.NewRequest("GET", tt.path, nil)
1059 req = req.WithContext(ctxWithScopes("admin:status:read"))
1060 rec := httptest.NewRecorder()
1061
1062 handler.ServeHTTP(rec, req)
1063
1064 assert.Equal(t, tt.status, rec.Code)
1065 })
1066 }
1067}
1068
1069func TestHandler_Tags_Success(t *testing.T) {
1070 configSvc := inmocks.NewMockConfigService(t)
1071 authSvc := inmocks.NewMockAuthService(t)
1072 containerSvc := inmocks.NewMockContainerService(t)
1073 secretSvc := inmocks.NewMockSecretService(t)
1074 registrySvc := inmocks.NewMockRegistryService(t)
1075
1076 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
1077
1078 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1079
1080 req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1081 req = req.WithContext(ctxWithScopes("admin:status:read"))
1082 rec := httptest.NewRecorder()
1083
1084 handler.ServeHTTP(rec, req)
1085
1086 assert.Equal(t, http.StatusOK, rec.Code)
1087 var response struct {
1088 Repository string `json:"repository"`
1089 Tags []string `json:"tags"`
1090 }
1091 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1092 assert.Equal(t, "myapp", response.Repository)
1093 assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1094}
1095
1096func TestHandler_Tags_Error(t *testing.T) {
1097 configSvc := inmocks.NewMockConfigService(t)
1098 authSvc := inmocks.NewMockAuthService(t)
1099 containerSvc := inmocks.NewMockContainerService(t)
1100 secretSvc := inmocks.NewMockSecretService(t)
1101 registrySvc := inmocks.NewMockRegistryService(t)
1102
1103 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
1104
1105 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1106
1107 req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1108 req = req.WithContext(ctxWithScopes("admin:status:read"))
1109 rec := httptest.NewRecorder()
1110
1111 handler.ServeHTTP(rec, req)
1112
1113 assert.Equal(t, http.StatusInternalServerError, rec.Code)
1114 assert.NotContains(t, rec.Body.String(), "registry error")
1115}
1116
1117func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
1118 configSvc := inmocks.NewMockConfigService(t)
1119 authSvc := inmocks.NewMockAuthService(t)
1120 containerSvc := inmocks.NewMockContainerService(t)
1121 secretSvc := inmocks.NewMockSecretService(t)
1122
1123 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1124
1125 body := `{"REDIS_URL": "redis://localhost:6379"}`
1126 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1127 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1128 rec := httptest.NewRecorder()
1129
1130 handler.ServeHTTP(rec, req)
1131
1132 assert.Equal(t, http.StatusForbidden, rec.Code)
1133}
1134
1135func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
1136 configSvc := inmocks.NewMockConfigService(t)
1137 authSvc := inmocks.NewMockAuthService(t)
1138 containerSvc := inmocks.NewMockContainerService(t)
1139 secretSvc := inmocks.NewMockSecretService(t)
1140
1141 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1142
1143 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1144 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1145 rec := httptest.NewRecorder()
1146
1147 handler.ServeHTTP(rec, req)
1148
1149 assert.Equal(t, http.StatusForbidden, rec.Code)
1150}
1151
1152func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1153 configSvc := inmocks.NewMockConfigService(t)
1154 authSvc := inmocks.NewMockAuthService(t)
1155 containerSvc := inmocks.NewMockContainerService(t)
1156 secretSvc := inmocks.NewMockSecretService(t)
1157
1158 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1159
1160 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
1161 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1162 rec := httptest.NewRecorder()
1163
1164 handler.ServeHTTP(rec, req)
1165
1166 assert.Equal(t, http.StatusBadRequest, rec.Code)
1167 assert.Contains(t, rec.Body.String(), "invalid attachment path")
1168}
1169
1170func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
1171 configSvc := inmocks.NewMockConfigService(t)
1172 authSvc := inmocks.NewMockAuthService(t)
1173 containerSvc := inmocks.NewMockContainerService(t)
1174 secretSvc := inmocks.NewMockSecretService(t)
1175
1176 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1177
1178 body := `{not valid json`
1179 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1180 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1181 rec := httptest.NewRecorder()
1182
1183 handler.ServeHTTP(rec, req)
1184
1185 assert.Equal(t, http.StatusBadRequest, rec.Code)
1186 assert.Contains(t, rec.Body.String(), "invalid JSON")
1187}
1188
1189func TestHandler_NotFound(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 := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1196
1197 req := httptest.NewRequest("GET", "/admin/unknown", nil)
1198 req = req.WithContext(ctxWithScopes("admin:*:*"))
1199 rec := httptest.NewRecorder()
1200
1201 handler.ServeHTTP(rec, req)
1202
1203 assert.Equal(t, http.StatusNotFound, rec.Code)
1204}
1205
1206func TestHandler_BackupsStatus(t *testing.T) {
1207 configSvc := inmocks.NewMockConfigService(t)
1208 authSvc := inmocks.NewMockAuthService(t)
1209 containerSvc := inmocks.NewMockContainerService(t)
1210 secretSvc := inmocks.NewMockSecretService(t)
1211 backupSvc := inmocks.NewMockBackupService(t)
1212
1213 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)
1214
1215 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1216
1217 req := httptest.NewRequest("GET", "/admin/backups/status", nil)
1218 req = req.WithContext(ctxWithScopes("admin:status:read"))
1219 rec := httptest.NewRecorder()
1220
1221 handler.ServeHTTP(rec, req)
1222
1223 assert.Equal(t, http.StatusOK, rec.Code)
1224 assert.Contains(t, rec.Body.String(), "app.example.com")
1225 assert.NotContains(t, rec.Body.String(), "file_path")
1226}
1227
1228func TestHandler_BackupsListDomain(t *testing.T) {
1229 configSvc := inmocks.NewMockConfigService(t)
1230 authSvc := inmocks.NewMockAuthService(t)
1231 containerSvc := inmocks.NewMockContainerService(t)
1232 secretSvc := inmocks.NewMockSecretService(t)
1233 backupSvc := inmocks.NewMockBackupService(t)
1234
1235 backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1236
1237 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1238
1239 req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)
1240 req = req.WithContext(ctxWithScopes("admin:status:read"))
1241 rec := httptest.NewRecorder()
1242
1243 handler.ServeHTTP(rec, req)
1244
1245 assert.Equal(t, http.StatusOK, rec.Code)
1246 assert.Contains(t, rec.Body.String(), "app.example.com")
1247}
1248
1249func TestHandler_BackupsRunDomain(t *testing.T) {
1250 configSvc := inmocks.NewMockConfigService(t)
1251 authSvc := inmocks.NewMockAuthService(t)
1252 containerSvc := inmocks.NewMockContainerService(t)
1253 secretSvc := inmocks.NewMockSecretService(t)
1254 backupSvc := inmocks.NewMockBackupService(t)
1255
1256 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)
1257
1258 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1259
1260 body := bytes.NewBufferString(`{"db":"postgres"}`)
1261 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
1262 req = req.WithContext(ctxWithScopes("admin:config:write"))
1263 rec := httptest.NewRecorder()
1264
1265 handler.ServeHTTP(rec, req)
1266
1267 assert.Equal(t, http.StatusOK, rec.Code)
1268 assert.Contains(t, rec.Body.String(), "completed")
1269}
1270
1271func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
1272 configSvc := inmocks.NewMockConfigService(t)
1273 authSvc := inmocks.NewMockAuthService(t)
1274 containerSvc := inmocks.NewMockContainerService(t)
1275 secretSvc := inmocks.NewMockSecretService(t)
1276 backupSvc := inmocks.NewMockBackupService(t)
1277
1278 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)
1279
1280 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1281
1282 body := bytes.NewBufferString(`{"db":"postgres"}`)
1283 req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
1284 req.ContentLength = -1
1285 req = req.WithContext(ctxWithScopes("admin:config:write"))
1286 rec := httptest.NewRecorder()
1287
1288 handler.ServeHTTP(rec, req)
1289
1290 assert.Equal(t, http.StatusOK, rec.Code)
1291 assert.Contains(t, rec.Body.String(), "completed")
1292}
1293
1294func TestHandler_BackupsDetectDomain(t *testing.T) {
1295 configSvc := inmocks.NewMockConfigService(t)
1296 authSvc := inmocks.NewMockAuthService(t)
1297 containerSvc := inmocks.NewMockContainerService(t)
1298 secretSvc := inmocks.NewMockSecretService(t)
1299 backupSvc := inmocks.NewMockBackupService(t)
1300
1301 backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
1302
1303 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1304
1305 req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
1306 req = req.WithContext(ctxWithScopes("admin:status:read"))
1307 rec := httptest.NewRecorder()
1308
1309 handler.ServeHTTP(rec, req)
1310
1311 assert.Equal(t, http.StatusOK, rec.Code)
1312 assert.Contains(t, rec.Body.String(), "postgres")
1313}
1314
1315func TestHandler_ImagesGet_ReturnsMappedList(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 createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
1322 imageSvc := &stubImageService{
1323 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1324 return []domain.ImageInfo{
1325 {
1326 Repository: "myapp",
1327 Tag: "latest",
1328 Size: 1024,
1329 Created: createdAt,
1330 ID: "sha256:abc123",
1331 Dangling: false,
1332 },
1333 {
1334 Repository: "",
1335 Tag: "",
1336 Size: 512,
1337 Created: createdAt,
1338 ID: "sha256:def456",
1339 Dangling: true,
1340 },
1341 }, nil
1342 },
1343 }
1344
1345 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1346
1347 req := httptest.NewRequest("GET", "/admin/images", nil)
1348 req = req.WithContext(ctxWithScopes("admin:status:read"))
1349 rec := httptest.NewRecorder()
1350
1351 handler.ServeHTTP(rec, req)
1352
1353 assert.Equal(t, http.StatusOK, rec.Code)
1354
1355 var response struct {
1356 Images []struct {
1357 Repository string `json:"repository"`
1358 Tag string `json:"tag"`
1359 Size int64 `json:"size"`
1360 Created time.Time `json:"created"`
1361 ID string `json:"id"`
1362 Dangling bool `json:"dangling"`
1363 } `json:"images"`
1364 }
1365
1366 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1367 assert.Len(t, response.Images, 2)
1368 assert.Equal(t, "myapp", response.Images[0].Repository)
1369 assert.Equal(t, "latest", response.Images[0].Tag)
1370 assert.Equal(t, int64(1024), response.Images[0].Size)
1371 assert.Equal(t, createdAt, response.Images[0].Created)
1372 assert.Equal(t, "sha256:abc123", response.Images[0].ID)
1373 assert.False(t, response.Images[0].Dangling)
1374 assert.True(t, response.Images[1].Dangling)
1375}
1376
1377func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1378 configSvc := inmocks.NewMockConfigService(t)
1379 authSvc := inmocks.NewMockAuthService(t)
1380 containerSvc := inmocks.NewMockContainerService(t)
1381 secretSvc := inmocks.NewMockSecretService(t)
1382
1383 tests := []struct {
1384 name string
1385 body string
1386 expectedKeepLast int
1387 }{
1388 {name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast},
1389 {name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
1390 }
1391
1392 for _, tt := range tests {
1393 t.Run(tt.name, func(t *testing.T) {
1394 called := false
1395 imageSvc := &stubImageService{
1396 pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) {
1397 called = true
1398 assert.Equal(t, tt.expectedKeepLast, keepLast)
1399 return domain.ImagePruneReport{
1400 Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
1401 Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
1402 }, nil
1403 },
1404 }
1405
1406 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1407
1408 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1409 req = req.WithContext(ctxWithScopes("admin:config:write"))
1410 rec := httptest.NewRecorder()
1411
1412 handler.ServeHTTP(rec, req)
1413
1414 assert.Equal(t, http.StatusOK, rec.Code)
1415 assert.True(t, called)
1416 assert.Contains(t, rec.Body.String(), "deleted_count")
1417 assert.Contains(t, rec.Body.String(), "tags_removed")
1418 })
1419 }
1420}
1421
1422func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1423 t.Run("list failure maps to internal server error", func(t *testing.T) {
1424 configSvc := inmocks.NewMockConfigService(t)
1425 authSvc := inmocks.NewMockAuthService(t)
1426 containerSvc := inmocks.NewMockContainerService(t)
1427 secretSvc := inmocks.NewMockSecretService(t)
1428
1429 imageSvc := &stubImageService{
1430 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1431 return nil, errors.New("runtime list failed: confidential details")
1432 },
1433 }
1434
1435 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1436
1437 req := httptest.NewRequest("GET", "/admin/images", nil)
1438 req = req.WithContext(ctxWithScopes("admin:status:read"))
1439 rec := httptest.NewRecorder()
1440
1441 handler.ServeHTTP(rec, req)
1442
1443 assert.Equal(t, http.StatusInternalServerError, rec.Code)
1444 assert.Contains(t, rec.Body.String(), "failed to list images")
1445 assert.NotContains(t, rec.Body.String(), "confidential details")
1446 })
1447
1448 t.Run("prune failure maps to internal server error", func(t *testing.T) {
1449 configSvc := inmocks.NewMockConfigService(t)
1450 authSvc := inmocks.NewMockAuthService(t)
1451 containerSvc := inmocks.NewMockContainerService(t)
1452 secretSvc := inmocks.NewMockSecretService(t)
1453
1454 imageSvc := &stubImageService{
1455 pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) {
1456 return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
1457 },
1458 }
1459
1460 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1461
1462 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
1463 req = req.WithContext(ctxWithScopes("admin:config:write"))
1464 rec := httptest.NewRecorder()
1465
1466 handler.ServeHTTP(rec, req)
1467
1468 assert.Equal(t, http.StatusInternalServerError, rec.Code)
1469 assert.Contains(t, rec.Body.String(), "failed to prune images")
1470 assert.NotContains(t, rec.Body.String(), "confidential details")
1471 })
1472}
1473
1474func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1475 t.Run("rejects negative keep_last", func(t *testing.T) {
1476 configSvc := inmocks.NewMockConfigService(t)
1477 authSvc := inmocks.NewMockAuthService(t)
1478 containerSvc := inmocks.NewMockContainerService(t)
1479 secretSvc := inmocks.NewMockSecretService(t)
1480
1481 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1482
1483 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
1484 req = req.WithContext(ctxWithScopes("admin:config:write"))
1485 rec := httptest.NewRecorder()
1486
1487 handler.ServeHTTP(rec, req)
1488
1489 assert.Equal(t, http.StatusBadRequest, rec.Code)
1490 assert.Contains(t, rec.Body.String(), "keep_last must be")
1491 })
1492
1493 t.Run("returns service unavailable when image service missing", func(t *testing.T) {
1494 configSvc := inmocks.NewMockConfigService(t)
1495 authSvc := inmocks.NewMockAuthService(t)
1496 containerSvc := inmocks.NewMockContainerService(t)
1497 secretSvc := inmocks.NewMockSecretService(t)
1498
1499 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1500
1501 req := httptest.NewRequest("GET", "/admin/images", nil)
1502 req = req.WithContext(ctxWithScopes("admin:status:read"))
1503 rec := httptest.NewRecorder()
1504
1505 handler.ServeHTTP(rec, req)
1506
1507 assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
1508 assert.Contains(t, rec.Body.String(), "image service not available")
1509 })
1510}
1511
1512func TestHandler_Images_Authorization(t *testing.T) {
1513 configSvc := inmocks.NewMockConfigService(t)
1514 authSvc := inmocks.NewMockAuthService(t)
1515 containerSvc := inmocks.NewMockContainerService(t)
1516 secretSvc := inmocks.NewMockSecretService(t)
1517
1518 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1519
1520 t.Run("images list requires status:read", func(t *testing.T) {
1521 req := httptest.NewRequest("GET", "/admin/images", nil)
1522 req = req.WithContext(ctxWithScopes("admin:config:write"))
1523 rec := httptest.NewRecorder()
1524
1525 handler.ServeHTTP(rec, req)
1526
1527 assert.Equal(t, http.StatusForbidden, rec.Code)
1528 assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
1529 })
1530
1531 t.Run("images prune requires config:write", func(t *testing.T) {
1532 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
1533 req = req.WithContext(ctxWithScopes("admin:status:read"))
1534 rec := httptest.NewRecorder()
1535
1536 handler.ServeHTTP(rec, req)
1537
1538 assert.Equal(t, http.StatusForbidden, rec.Code)
1539 assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
1540 })
1541}