1package admin
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "net/http/httptest"
12 "testing"
13 "time"
14
15 "github.com/bnema/zerowrap"
16 "github.com/stretchr/testify/assert"
17 "github.com/stretchr/testify/mock"
18 "github.com/stretchr/testify/require"
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
30type noopReloadTrigger struct{}
31
32func (noopReloadTrigger) Trigger(context.Context) error { return nil }
33
34type reloadTriggerRecorder struct {
35 calls int
36 err error
37 ctxErrAtCall error
38 hasDeadline bool
39}
40
41func (r *reloadTriggerRecorder) Trigger(ctx context.Context) error {
42 r.calls++
43 r.ctxErrAtCall = ctx.Err()
44 _, r.hasDeadline = ctx.Deadline()
45 return r.err
46}
47
48func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
49 if s.listImagesFunc == nil {
50 return nil, nil
51 }
52 return s.listImagesFunc(ctx)
53}
54
55func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
56 if s.pruneFunc == nil {
57 return domain.ImagePruneReport{}, nil
58 }
59 return s.pruneFunc(ctx, opts)
60}
61
62func testLogger() zerowrap.Logger {
63 return zerowrap.Default()
64}
65
66func ctxWithScopes(scopes ...string) context.Context {
67 ctx := context.Background()
68 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
69}
70
71func newScopedTestServer(t *testing.T, handler http.Handler, scopes ...string) *httptest.Server {
72 t.Helper()
73
74 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75 handler.ServeHTTP(w, r.WithContext(ctxWithScopes(scopes...)))
76 }))
77 t.Cleanup(server.Close)
78
79 return server
80}
81
82func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {
83 t.Helper()
84 deps := HandlerDeps{
85 ConfigSvc: inmocks.NewMockConfigService(t),
86 AuthSvc: inmocks.NewMockAuthService(t),
87 ContainerSvc: inmocks.NewMockContainerService(t),
88 HealthSvc: inmocks.NewMockHealthService(t),
89 Log: testLogger(),
90 ReloadTrigger: noopReloadTrigger{},
91 }
92 for _, opt := range opts {
93 opt(&deps)
94 }
95 return NewHandler(deps)
96}
97
98func TestHandler_VolumesGet_RequiresVolumesReadScope(t *testing.T) {
99 volumeSvc := inmocks.NewMockVolumeService(t)
100 handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc })
101
102 tests := []struct {
103 name string
104 scopes []string
105 wantStatus int
106 }{
107 {name: "volumes read access granted", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusOK},
108 {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK},
109 {name: "status read denied", scopes: []string{"admin:status:read"}, wantStatus: http.StatusForbidden},
110 {name: "volumes write denied", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusForbidden},
111 }
112
113 for _, tt := range tests {
114 t.Run(tt.name, func(t *testing.T) {
115 if tt.wantStatus == http.StatusOK {
116 volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{}, nil).Once()
117 }
118 server := newScopedTestServer(t, handler, tt.scopes...)
119 resp, err := http.Get(server.URL + "/admin/volumes")
120 require.NoError(t, err)
121 defer resp.Body.Close()
122 assert.Equal(t, tt.wantStatus, resp.StatusCode)
123 })
124 }
125}
126
127func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) {
128 volumeSvc := inmocks.NewMockVolumeService(t)
129 handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc })
130
131 tests := []struct {
132 name string
133 scopes []string
134 wantStatus int
135 }{
136 {name: "volumes write access granted", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusOK},
137 {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK},
138 {name: "config write denied", scopes: []string{"admin:config:write"}, wantStatus: http.StatusForbidden},
139 {name: "volumes read denied", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusForbidden},
140 }
141
142 for _, tt := range tests {
143 t.Run(tt.name, func(t *testing.T) {
144 if tt.wantStatus == http.StatusOK {
145 volumeSvc.EXPECT().PruneVolumes(mock.Anything, true).Return(&domain.VolumePruneReport{}, []*domain.VolumeInfo{}, nil).Once()
146 }
147 server := newScopedTestServer(t, handler, tt.scopes...)
148 resp, err := http.Post(server.URL+"/admin/volumes/prune", "application/json", bytes.NewBufferString(`{"dry_run":true}`))
149 require.NoError(t, err)
150 defer resp.Body.Close()
151 assert.Equal(t, tt.wantStatus, resp.StatusCode)
152 })
153 }
154}
155
156// Routes endpoint tests
157
158func TestHandler_Status_RequiresReadScope(t *testing.T) {
159 configSvc := inmocks.NewMockConfigService(t)
160 authSvc := inmocks.NewMockAuthService(t)
161 containerSvc := inmocks.NewMockContainerService(t)
162
163 handler := newTestHandler(t, func(d *HandlerDeps) {
164 d.ConfigSvc = configSvc
165 d.AuthSvc = authSvc
166 d.ContainerSvc = containerSvc
167 })
168
169 tests := []struct {
170 name string
171 scopes []string
172 wantStatus int
173 }{
174 {
175 name: "status read access granted",
176 scopes: []string{"admin:status:read"},
177 wantStatus: http.StatusOK,
178 },
179 {
180 name: "all admin access granted",
181 scopes: []string{"admin:*:*"},
182 wantStatus: http.StatusOK,
183 },
184 {
185 name: "wrong resource denied",
186 scopes: []string{"admin:routes:read"},
187 wantStatus: http.StatusForbidden,
188 },
189 }
190
191 for _, tt := range tests {
192 t.Run(tt.name, func(t *testing.T) {
193 if tt.wantStatus == http.StatusOK {
194 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
195 configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
196 configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
197 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
198 }
199
200 req := httptest.NewRequest("GET", "/admin/status", nil)
201 req = req.WithContext(ctxWithScopes(tt.scopes...))
202 rec := httptest.NewRecorder()
203
204 handler.ServeHTTP(rec, req)
205
206 assert.Equal(t, tt.wantStatus, rec.Code)
207 })
208 }
209}
210
211// Config endpoint tests
212
213func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) {
214 configSvc := inmocks.NewMockConfigService(t)
215 handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc })
216
217 configSvc.EXPECT().GetServerPort().Return(8080).Once()
218 configSvc.EXPECT().GetRegistryPort().Return(5000).Once()
219 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once()
220 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Once()
221 configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Once()
222 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{"db.example.com": "http://10.0.0.5:5432"}).Once()
223
224 server := newScopedTestServer(t, handler, "admin:config:read")
225 resp, err := http.Get(server.URL + "/admin/config")
226 require.NoError(t, err)
227 defer resp.Body.Close()
228 bodyBytes, err := io.ReadAll(resp.Body)
229 require.NoError(t, err)
230
231 require.Equal(t, http.StatusOK, resp.StatusCode)
232 body := string(bodyBytes)
233 var raw map[string]any
234 require.NoError(t, json.Unmarshal([]byte(body), &raw))
235 serverConfig := raw["server"].(map[string]any)
236 assert.NotContains(t, serverConfig, "data_dir")
237 assert.NotContains(t, body, "/var/lib/gordon")
238 assert.NotContains(t, body, "10.0.0.5")
239}
240
241func TestHandler_Config_RequiresReadScope(t *testing.T) {
242 configSvc := inmocks.NewMockConfigService(t)
243 authSvc := inmocks.NewMockAuthService(t)
244 containerSvc := inmocks.NewMockContainerService(t)
245
246 handler := newTestHandler(t, func(d *HandlerDeps) {
247 d.ConfigSvc = configSvc
248 d.AuthSvc = authSvc
249 d.ContainerSvc = containerSvc
250 })
251
252 tests := []struct {
253 name string
254 scopes []string
255 wantStatus int
256 }{
257 {
258 name: "config read access granted",
259 scopes: []string{"admin:config:read"},
260 wantStatus: http.StatusOK,
261 },
262 {
263 name: "all admin access granted",
264 scopes: []string{"admin:*:*"},
265 wantStatus: http.StatusOK,
266 },
267 {
268 name: "wrong resource denied",
269 scopes: []string{"admin:routes:read"},
270 wantStatus: http.StatusForbidden,
271 },
272 }
273
274 for _, tt := range tests {
275 t.Run(tt.name, func(t *testing.T) {
276 if tt.wantStatus == http.StatusOK {
277 configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
278 configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
279 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
280 configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
281 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
282 configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
283 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
284 }
285
286 req := httptest.NewRequest("GET", "/admin/config", nil)
287 req = req.WithContext(ctxWithScopes(tt.scopes...))
288 rec := httptest.NewRecorder()
289
290 handler.ServeHTTP(rec, req)
291
292 assert.Equal(t, tt.wantStatus, rec.Code)
293 })
294 }
295}
296
297// Reload endpoint tests
298
299func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
300 configSvc := inmocks.NewMockConfigService(t)
301 authSvc := inmocks.NewMockAuthService(t)
302 containerSvc := inmocks.NewMockContainerService(t)
303 reloadTrigger := &reloadTriggerRecorder{}
304
305 handler := newTestHandler(t, func(d *HandlerDeps) {
306 d.ConfigSvc = configSvc
307 d.AuthSvc = authSvc
308 d.ContainerSvc = containerSvc
309 d.ReloadTrigger = reloadTrigger
310 })
311
312 tests := []struct {
313 name string
314 scopes []string
315 wantStatus int
316 }{
317 {
318 name: "config write access granted",
319 scopes: []string{"admin:config:write"},
320 wantStatus: http.StatusOK,
321 },
322 {
323 name: "all admin access granted",
324 scopes: []string{"admin:*:*"},
325 wantStatus: http.StatusOK,
326 },
327 {
328 name: "only read access denied",
329 scopes: []string{"admin:config:read"},
330 wantStatus: http.StatusForbidden,
331 },
332 {
333 name: "wrong resource denied",
334 scopes: []string{"admin:routes:write"},
335 wantStatus: http.StatusForbidden,
336 },
337 }
338
339 for _, tt := range tests {
340 t.Run(tt.name, func(t *testing.T) {
341 reloadTrigger.calls = 0
342
343 req := httptest.NewRequest("POST", "/admin/reload", nil)
344 req = req.WithContext(ctxWithScopes(tt.scopes...))
345 rec := httptest.NewRecorder()
346
347 handler.ServeHTTP(rec, req)
348
349 assert.Equal(t, tt.wantStatus, rec.Code)
350 if tt.wantStatus == http.StatusOK {
351 assert.Equal(t, 1, reloadTrigger.calls)
352 } else {
353 assert.Zero(t, reloadTrigger.calls)
354 }
355 })
356 }
357}
358
359func TestHandler_Reload_ReturnsServerErrorOnTriggerFailure(t *testing.T) {
360 reloadTrigger := &reloadTriggerRecorder{err: errors.New("reload failed")}
361
362 handler := newTestHandler(t, func(d *HandlerDeps) {
363 d.ReloadTrigger = reloadTrigger
364 })
365
366 req := httptest.NewRequest("POST", "/admin/reload", nil)
367 req = req.WithContext(ctxWithScopes("admin:config:write"))
368 rec := httptest.NewRecorder()
369
370 handler.ServeHTTP(rec, req)
371
372 assert.Equal(t, http.StatusInternalServerError, rec.Code)
373 assert.Equal(t, 1, reloadTrigger.calls)
374}
375
376func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) {
377 reloadTrigger := &reloadTriggerRecorder{}
378
379 handler := newTestHandler(t, func(d *HandlerDeps) {
380 d.ReloadTrigger = reloadTrigger
381 })
382
383 reqCtx, cancel := context.WithCancel(ctxWithScopes("admin:config:write"))
384 cancel()
385
386 req := httptest.NewRequest("POST", "/admin/reload", nil)
387 req = req.WithContext(reqCtx)
388 rec := httptest.NewRecorder()
389
390 handler.ServeHTTP(rec, req)
391
392 assert.Equal(t, http.StatusOK, rec.Code)
393 assert.Equal(t, 1, reloadTrigger.calls)
394 require.NoError(t, reloadTrigger.ctxErrAtCall)
395 assert.True(t, reloadTrigger.hasDeadline)
396}
397
398// Functional tests
399
400func TestHandler_MethodNotAllowed(t *testing.T) {
401 configSvc := inmocks.NewMockConfigService(t)
402 authSvc := inmocks.NewMockAuthService(t)
403 containerSvc := inmocks.NewMockContainerService(t)
404
405 handler := newTestHandler(t, func(d *HandlerDeps) {
406 d.ConfigSvc = configSvc
407 d.AuthSvc = authSvc
408 d.ContainerSvc = containerSvc
409 })
410
411 tests := []struct {
412 method string
413 path string
414 }{
415 {"DELETE", "/admin/status"},
416 {"PUT", "/admin/status"},
417 {"GET", "/admin/reload"},
418 {"DELETE", "/admin/reload"},
419 {"POST", "/admin/config"},
420 {"DELETE", "/admin/config"},
421 }
422
423 for _, tt := range tests {
424 t.Run(tt.method+" "+tt.path, func(t *testing.T) {
425 req := httptest.NewRequest(tt.method, tt.path, nil)
426 req = req.WithContext(ctxWithScopes("admin:*:*"))
427 rec := httptest.NewRecorder()
428
429 handler.ServeHTTP(rec, req)
430
431 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
432 })
433 }
434}
435
436func TestHandler_Tags_InvalidRepository(t *testing.T) {
437 configSvc := inmocks.NewMockConfigService(t)
438 authSvc := inmocks.NewMockAuthService(t)
439 containerSvc := inmocks.NewMockContainerService(t)
440
441 handler := newTestHandler(t, func(d *HandlerDeps) {
442 d.ConfigSvc = configSvc
443 d.AuthSvc = authSvc
444 d.ContainerSvc = containerSvc
445 })
446
447 tests := []struct {
448 name string
449 path string
450 status int
451 }{
452 {"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
453 {"empty repo", "/admin/tags/", http.StatusBadRequest},
454 }
455
456 for _, tt := range tests {
457 t.Run(tt.name, func(t *testing.T) {
458 req := httptest.NewRequest("GET", tt.path, nil)
459 req = req.WithContext(ctxWithScopes("admin:status:read"))
460 rec := httptest.NewRecorder()
461
462 handler.ServeHTTP(rec, req)
463
464 assert.Equal(t, tt.status, rec.Code)
465 })
466 }
467}
468
469func TestHandler_Tags_Success(t *testing.T) {
470 configSvc := inmocks.NewMockConfigService(t)
471 authSvc := inmocks.NewMockAuthService(t)
472 containerSvc := inmocks.NewMockContainerService(t)
473 registrySvc := inmocks.NewMockRegistryService(t)
474
475 handler := newTestHandler(t, func(d *HandlerDeps) {
476 d.ConfigSvc = configSvc
477 d.AuthSvc = authSvc
478 d.ContainerSvc = containerSvc
479 d.RegistrySvc = registrySvc
480 })
481
482 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
483
484 req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
485 req = req.WithContext(ctxWithScopes("admin:status:read"))
486 rec := httptest.NewRecorder()
487
488 handler.ServeHTTP(rec, req)
489
490 assert.Equal(t, http.StatusOK, rec.Code)
491 var response struct {
492 Repository string `json:"repository"`
493 Tags []string `json:"tags"`
494 }
495 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
496 assert.Equal(t, "myapp", response.Repository)
497 assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
498}
499
500func TestHandler_Tags_Error(t *testing.T) {
501 configSvc := inmocks.NewMockConfigService(t)
502 authSvc := inmocks.NewMockAuthService(t)
503 containerSvc := inmocks.NewMockContainerService(t)
504 registrySvc := inmocks.NewMockRegistryService(t)
505
506 handler := newTestHandler(t, func(d *HandlerDeps) {
507 d.ConfigSvc = configSvc
508 d.AuthSvc = authSvc
509 d.ContainerSvc = containerSvc
510 d.RegistrySvc = registrySvc
511 })
512
513 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
514
515 req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
516 req = req.WithContext(ctxWithScopes("admin:status:read"))
517 rec := httptest.NewRecorder()
518
519 handler.ServeHTTP(rec, req)
520
521 assert.Equal(t, http.StatusInternalServerError, rec.Code)
522 assert.NotContains(t, rec.Body.String(), "registry error")
523}
524
525func TestHandler_Tags_URLDecodedRepository(t *testing.T) {
526 configSvc := inmocks.NewMockConfigService(t)
527 authSvc := inmocks.NewMockAuthService(t)
528 containerSvc := inmocks.NewMockContainerService(t)
529 registrySvc := inmocks.NewMockRegistryService(t)
530
531 handler := newTestHandler(t, func(d *HandlerDeps) {
532 d.ConfigSvc = configSvc
533 d.AuthSvc = authSvc
534 d.ContainerSvc = containerSvc
535 d.RegistrySvc = registrySvc
536 })
537
538 registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"latest"}, nil)
539
540 req := httptest.NewRequest("GET", "/admin/tags/repo%2Fapp", nil)
541 req = req.WithContext(ctxWithScopes("admin:status:read"))
542 rec := httptest.NewRecorder()
543
544 handler.ServeHTTP(rec, req)
545
546 assert.Equal(t, http.StatusOK, rec.Code)
547 assert.JSONEq(t, `{"repository":"repo/app","tags":["latest"]}`, rec.Body.String())
548}
549
550func TestHandler_NotFound(t *testing.T) {
551 configSvc := inmocks.NewMockConfigService(t)
552 authSvc := inmocks.NewMockAuthService(t)
553 containerSvc := inmocks.NewMockContainerService(t)
554
555 handler := newTestHandler(t, func(d *HandlerDeps) {
556 d.ConfigSvc = configSvc
557 d.AuthSvc = authSvc
558 d.ContainerSvc = containerSvc
559 })
560
561 req := httptest.NewRequest("GET", "/admin/unknown", nil)
562 req = req.WithContext(ctxWithScopes("admin:*:*"))
563 rec := httptest.NewRecorder()
564
565 handler.ServeHTTP(rec, req)
566
567 assert.Equal(t, http.StatusNotFound, rec.Code)
568}
569
570func TestHandler_BackupsStatus(t *testing.T) {
571 configSvc := inmocks.NewMockConfigService(t)
572 authSvc := inmocks.NewMockAuthService(t)
573 containerSvc := inmocks.NewMockContainerService(t)
574 backupSvc := inmocks.NewMockBackupService(t)
575
576 backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil)
577
578 handler := newTestHandler(t, func(d *HandlerDeps) {
579 d.ConfigSvc = configSvc
580 d.AuthSvc = authSvc
581 d.ContainerSvc = containerSvc
582 d.BackupSvc = backupSvc
583 })
584
585 req := httptest.NewRequest("GET", "/admin/backups/status", nil)
586 req = req.WithContext(ctxWithScopes("admin:status:read"))
587 rec := httptest.NewRecorder()
588
589 handler.ServeHTTP(rec, req)
590
591 assert.Equal(t, http.StatusOK, rec.Code)
592 assert.Contains(t, rec.Body.String(), "shop")
593 assert.Contains(t, rec.Body.String(), "orders")
594 assert.NotContains(t, rec.Body.String(), "file_path")
595}
596
597func TestHandler_BackupsListApp(t *testing.T) {
598 configSvc := inmocks.NewMockConfigService(t)
599 authSvc := inmocks.NewMockAuthService(t)
600 containerSvc := inmocks.NewMockContainerService(t)
601 backupSvc := inmocks.NewMockBackupService(t)
602
603 backupSvc.EXPECT().ListBackups(mock.Anything, "shop").Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)
604
605 handler := newTestHandler(t, func(d *HandlerDeps) {
606 d.ConfigSvc = configSvc
607 d.AuthSvc = authSvc
608 d.ContainerSvc = containerSvc
609 d.BackupSvc = backupSvc
610 })
611
612 req := httptest.NewRequest("GET", "/admin/backups/shop", nil)
613 req = req.WithContext(ctxWithScopes("admin:status:read"))
614 rec := httptest.NewRecorder()
615
616 handler.ServeHTTP(rec, req)
617
618 assert.Equal(t, http.StatusOK, rec.Code)
619 assert.Contains(t, rec.Body.String(), "shop")
620}
621
622func TestHandler_BackupsRunApp(t *testing.T) {
623 configSvc := inmocks.NewMockConfigService(t)
624 authSvc := inmocks.NewMockAuthService(t)
625 containerSvc := inmocks.NewMockContainerService(t)
626 backupSvc := inmocks.NewMockBackupService(t)
627
628 backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)
629
630 handler := newTestHandler(t, func(d *HandlerDeps) {
631 d.ConfigSvc = configSvc
632 d.AuthSvc = authSvc
633 d.ContainerSvc = containerSvc
634 d.BackupSvc = backupSvc
635 })
636
637 body := bytes.NewBufferString(`{"service":"api","database":"orders"}`)
638 req := httptest.NewRequest("POST", "/admin/backups/shop", body)
639 req = req.WithContext(ctxWithScopes("admin:config:write"))
640 rec := httptest.NewRecorder()
641
642 handler.ServeHTTP(rec, req)
643
644 assert.Equal(t, http.StatusOK, rec.Code)
645 assert.Contains(t, rec.Body.String(), "completed")
646}
647
648func TestHandler_VolumeBackupsRunDomain_ReturnsPartialResults(t *testing.T) {
649 volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
650 runErr := errors.New("one volume failed")
651 jobs := []domain.VolumeBackupJob{{ID: "v1", App: "shop", Service: "api", VolumeName: "data", Status: domain.BackupStatusCompleted}}
652 volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "shop", "api", "data").Return(jobs, runErr)
653
654 handler := newTestHandler(t, func(d *HandlerDeps) {
655 d.VolumeBackupSvc = volumeBackupSvc
656 })
657
658 body := bytes.NewBufferString(`{"service":"api","volume":"data"}`)
659 req := httptest.NewRequest("POST", "/admin/backups/volumes/shop", body)
660 req = req.WithContext(ctxWithScopes("admin:config:write"))
661 rec := httptest.NewRecorder()
662
663 handler.ServeHTTP(rec, req)
664
665 require.Equal(t, http.StatusPartialContent, rec.Code)
666 var result dto.VolumeBackupRunResponse
667 require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &result))
668 assert.Equal(t, "partial", result.Status)
669 assert.Equal(t, runErr.Error(), result.Error)
670 require.Len(t, result.Backups, 1)
671 assert.Equal(t, "v1", result.Backups[0].ID)
672}
673
674func TestHandler_VolumeBackupsRunDomain_ReturnsServerErrorWithoutResults(t *testing.T) {
675 volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
676 volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "shop", "", "").Return(nil, errors.New("runtime unavailable"))
677
678 handler := newTestHandler(t, func(d *HandlerDeps) {
679 d.VolumeBackupSvc = volumeBackupSvc
680 })
681
682 req := httptest.NewRequest("POST", "/admin/backups/volumes/shop", bytes.NewBufferString(`{}`))
683 req = req.WithContext(ctxWithScopes("admin:config:write"))
684 rec := httptest.NewRecorder()
685
686 handler.ServeHTTP(rec, req)
687
688 assert.Equal(t, http.StatusInternalServerError, rec.Code)
689 assert.Contains(t, rec.Body.String(), "failed to run volume backups")
690}
691
692func TestHandler_BackupsRunApp_ChunkedBodyIsDecoded(t *testing.T) {
693 configSvc := inmocks.NewMockConfigService(t)
694 authSvc := inmocks.NewMockAuthService(t)
695 containerSvc := inmocks.NewMockContainerService(t)
696 backupSvc := inmocks.NewMockBackupService(t)
697
698 backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)
699
700 handler := newTestHandler(t, func(d *HandlerDeps) {
701 d.ConfigSvc = configSvc
702 d.AuthSvc = authSvc
703 d.ContainerSvc = containerSvc
704 d.BackupSvc = backupSvc
705 })
706
707 body := bytes.NewBufferString(`{"service":"api","database":"orders"}`)
708 req := httptest.NewRequest("POST", "/admin/backups/shop", body)
709 req.ContentLength = -1
710 req = req.WithContext(ctxWithScopes("admin:config:write"))
711 rec := httptest.NewRecorder()
712
713 handler.ServeHTTP(rec, req)
714
715 assert.Equal(t, http.StatusOK, rec.Code)
716 assert.Contains(t, rec.Body.String(), "completed")
717}
718
719func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) {
720 logSvc := inmocks.NewMockLogService(t)
721 handler := newTestHandler(t, func(d *HandlerDeps) {
722 d.LogSvc = logSvc
723 })
724
725 t.Run("status read denied", func(t *testing.T) {
726 server := newScopedTestServer(t, handler, "admin:status:read")
727 resp, err := http.Get(server.URL + "/admin/logs")
728 require.NoError(t, err)
729 defer resp.Body.Close()
730 bodyBytes, err := io.ReadAll(resp.Body)
731 require.NoError(t, err)
732
733 assert.Equal(t, http.StatusForbidden, resp.StatusCode)
734 assert.Contains(t, string(bodyBytes), "insufficient permissions for logs:read")
735 })
736
737 t.Run("logs read allowed and redacted", func(t *testing.T) {
738 logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once()
739
740 server := newScopedTestServer(t, handler, "admin:logs:read")
741 resp, err := http.Get(server.URL + "/admin/logs")
742 require.NoError(t, err)
743 defer resp.Body.Close()
744 bodyBytes, err := io.ReadAll(resp.Body)
745 require.NoError(t, err)
746 body := string(bodyBytes)
747
748 assert.Equal(t, http.StatusOK, resp.StatusCode)
749 assert.Contains(t, body, "PASSWORD=[REDACTED]")
750 assert.Contains(t, body, `\"API_KEY\":\"[REDACTED]\"`)
751 assert.NotContains(t, body, "hunter2")
752 assert.NotContains(t, body, "abc123")
753 })
754}
755
756func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
757 configSvc := inmocks.NewMockConfigService(t)
758 authSvc := inmocks.NewMockAuthService(t)
759 containerSvc := inmocks.NewMockContainerService(t)
760
761 createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
762 imageSvc := &stubImageService{
763 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
764 return []domain.ImageInfo{
765 {
766 Repository: "myapp",
767 Tag: "latest",
768 Size: 1024,
769 Created: createdAt,
770 ID: "sha256:abc123",
771 Dangling: false,
772 },
773 {
774 Repository: "",
775 Tag: "",
776 Size: 512,
777 Created: createdAt,
778 ID: "sha256:def456",
779 Dangling: true,
780 },
781 }, nil
782 },
783 }
784
785 handler := newTestHandler(t, func(d *HandlerDeps) {
786 d.ConfigSvc = configSvc
787 d.AuthSvc = authSvc
788 d.ContainerSvc = containerSvc
789 d.ImageSvc = imageSvc
790 })
791
792 req := httptest.NewRequest("GET", "/admin/images", nil)
793 req = req.WithContext(ctxWithScopes("admin:status:read"))
794 rec := httptest.NewRecorder()
795
796 handler.ServeHTTP(rec, req)
797
798 assert.Equal(t, http.StatusOK, rec.Code)
799
800 var response struct {
801 Images []struct {
802 Repository string `json:"repository"`
803 Tag string `json:"tag"`
804 Size int64 `json:"size"`
805 Created time.Time `json:"created"`
806 ID string `json:"id"`
807 Dangling bool `json:"dangling"`
808 } `json:"images"`
809 }
810
811 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
812 assert.Len(t, response.Images, 2)
813 assert.Equal(t, "myapp", response.Images[0].Repository)
814 assert.Equal(t, "latest", response.Images[0].Tag)
815 assert.Equal(t, int64(1024), response.Images[0].Size)
816 assert.Equal(t, createdAt, response.Images[0].Created)
817 assert.Equal(t, "sha256:abc123", response.Images[0].ID)
818 assert.False(t, response.Images[0].Dangling)
819 assert.True(t, response.Images[1].Dangling)
820}
821
822func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
823 configSvc := inmocks.NewMockConfigService(t)
824 authSvc := inmocks.NewMockAuthService(t)
825 containerSvc := inmocks.NewMockContainerService(t)
826
827 tests := []struct {
828 name string
829 body string
830 expectedKeepLast int
831 }{
832 {name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast},
833 {name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
834 }
835
836 for _, tt := range tests {
837 t.Run(tt.name, func(t *testing.T) {
838 called := false
839 imageSvc := &stubImageService{
840 pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
841 called = true
842 assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
843 return domain.ImagePruneReport{
844 Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
845 Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
846 }, nil
847 },
848 }
849
850 handler := newTestHandler(t, func(d *HandlerDeps) {
851 d.ConfigSvc = configSvc
852 d.AuthSvc = authSvc
853 d.ContainerSvc = containerSvc
854 d.ImageSvc = imageSvc
855 })
856
857 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
858 req = req.WithContext(ctxWithScopes("admin:config:write"))
859 rec := httptest.NewRecorder()
860
861 handler.ServeHTTP(rec, req)
862
863 assert.Equal(t, http.StatusOK, rec.Code)
864 assert.True(t, called)
865 assert.Contains(t, rec.Body.String(), "deleted_count")
866 assert.Contains(t, rec.Body.String(), "tags_removed")
867 })
868 }
869}
870
871func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
872 t.Run("list failure maps to internal server error", func(t *testing.T) {
873 configSvc := inmocks.NewMockConfigService(t)
874 authSvc := inmocks.NewMockAuthService(t)
875 containerSvc := inmocks.NewMockContainerService(t)
876
877 imageSvc := &stubImageService{
878 listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
879 return nil, errors.New("runtime list failed: confidential details")
880 },
881 }
882
883 handler := newTestHandler(t, func(d *HandlerDeps) {
884 d.ConfigSvc = configSvc
885 d.AuthSvc = authSvc
886 d.ContainerSvc = containerSvc
887 d.ImageSvc = imageSvc
888 })
889
890 req := httptest.NewRequest("GET", "/admin/images", nil)
891 req = req.WithContext(ctxWithScopes("admin:status:read"))
892 rec := httptest.NewRecorder()
893
894 handler.ServeHTTP(rec, req)
895
896 assert.Equal(t, http.StatusInternalServerError, rec.Code)
897 assert.Contains(t, rec.Body.String(), "failed to list images")
898 assert.NotContains(t, rec.Body.String(), "confidential details")
899 })
900
901 t.Run("prune failure maps to internal server error", func(t *testing.T) {
902 configSvc := inmocks.NewMockConfigService(t)
903 authSvc := inmocks.NewMockAuthService(t)
904 containerSvc := inmocks.NewMockContainerService(t)
905
906 imageSvc := &stubImageService{
907 pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
908 return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
909 },
910 }
911
912 handler := newTestHandler(t, func(d *HandlerDeps) {
913 d.ConfigSvc = configSvc
914 d.AuthSvc = authSvc
915 d.ContainerSvc = containerSvc
916 d.ImageSvc = imageSvc
917 })
918
919 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
920 req = req.WithContext(ctxWithScopes("admin:config:write"))
921 rec := httptest.NewRecorder()
922
923 handler.ServeHTTP(rec, req)
924
925 assert.Equal(t, http.StatusInternalServerError, rec.Code)
926 assert.Contains(t, rec.Body.String(), "failed to prune images")
927 assert.NotContains(t, rec.Body.String(), "confidential details")
928 })
929}
930
931func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
932 t.Run("rejects negative keep_last", func(t *testing.T) {
933 configSvc := inmocks.NewMockConfigService(t)
934 authSvc := inmocks.NewMockAuthService(t)
935 containerSvc := inmocks.NewMockContainerService(t)
936
937 handler := newTestHandler(t, func(d *HandlerDeps) {
938 d.ConfigSvc = configSvc
939 d.AuthSvc = authSvc
940 d.ContainerSvc = containerSvc
941 d.ImageSvc = &stubImageService{}
942 })
943
944 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
945 req = req.WithContext(ctxWithScopes("admin:config:write"))
946 rec := httptest.NewRecorder()
947
948 handler.ServeHTTP(rec, req)
949
950 assert.Equal(t, http.StatusBadRequest, rec.Code)
951 assert.Contains(t, rec.Body.String(), "keep_last must be")
952 })
953
954 t.Run("returns service unavailable when image service missing", func(t *testing.T) {
955 configSvc := inmocks.NewMockConfigService(t)
956 authSvc := inmocks.NewMockAuthService(t)
957 containerSvc := inmocks.NewMockContainerService(t)
958
959 handler := newTestHandler(t, func(d *HandlerDeps) {
960 d.ConfigSvc = configSvc
961 d.AuthSvc = authSvc
962 d.ContainerSvc = containerSvc
963 })
964
965 req := httptest.NewRequest("GET", "/admin/images", nil)
966 req = req.WithContext(ctxWithScopes("admin:status:read"))
967 rec := httptest.NewRecorder()
968
969 handler.ServeHTTP(rec, req)
970
971 assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
972 assert.Contains(t, rec.Body.String(), "image service not available")
973 })
974}
975
976func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
977 configSvc := inmocks.NewMockConfigService(t)
978 authSvc := inmocks.NewMockAuthService(t)
979 containerSvc := inmocks.NewMockContainerService(t)
980
981 tests := []struct {
982 name string
983 body string
984 expectedPruneDangling bool
985 expectedPruneRegistry bool
986 expectedKeepLast int
987 wantStatus int
988 }{
989 {
990 name: "missing scope fields defaults to both true",
991 body: `{}`,
992 expectedPruneDangling: true,
993 expectedPruneRegistry: true,
994 expectedKeepLast: domain.DefaultImagePruneKeepLast,
995 wantStatus: http.StatusOK,
996 },
997 {
998 name: "explicit dangling only",
999 body: `{"prune_dangling": true, "prune_registry": false}`,
1000 expectedPruneDangling: true,
1001 expectedPruneRegistry: false,
1002 expectedKeepLast: domain.DefaultImagePruneKeepLast,
1003 wantStatus: http.StatusOK,
1004 },
1005 {
1006 name: "explicit registry only",
1007 body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`,
1008 expectedPruneDangling: false,
1009 expectedPruneRegistry: true,
1010 expectedKeepLast: 5,
1011 wantStatus: http.StatusOK,
1012 },
1013 {
1014 name: "both scopes false returns 400",
1015 body: `{"prune_dangling": false, "prune_registry": false}`,
1016 wantStatus: http.StatusBadRequest,
1017 },
1018 }
1019
1020 for _, tt := range tests {
1021 t.Run(tt.name, func(t *testing.T) {
1022 called := false
1023 imageSvc := &stubImageService{
1024 pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
1025 called = true
1026 assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling)
1027 assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry)
1028 assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
1029 return domain.ImagePruneReport{}, nil
1030 },
1031 }
1032
1033 handler := newTestHandler(t, func(d *HandlerDeps) {
1034 d.ConfigSvc = configSvc
1035 d.AuthSvc = authSvc
1036 d.ContainerSvc = containerSvc
1037 d.ImageSvc = imageSvc
1038 })
1039
1040 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1041 req = req.WithContext(ctxWithScopes("admin:config:write"))
1042 rec := httptest.NewRecorder()
1043
1044 handler.ServeHTTP(rec, req)
1045
1046 assert.Equal(t, tt.wantStatus, rec.Code)
1047 if tt.wantStatus == http.StatusOK {
1048 assert.True(t, called, "prune should have been called")
1049 }
1050 if tt.wantStatus == http.StatusBadRequest {
1051 assert.Contains(t, rec.Body.String(), "at least one prune scope")
1052 }
1053 })
1054 }
1055}
1056
1057func TestHandler_Images_Authorization(t *testing.T) {
1058 configSvc := inmocks.NewMockConfigService(t)
1059 authSvc := inmocks.NewMockAuthService(t)
1060 containerSvc := inmocks.NewMockContainerService(t)
1061
1062 handler := newTestHandler(t, func(d *HandlerDeps) {
1063 d.ConfigSvc = configSvc
1064 d.AuthSvc = authSvc
1065 d.ContainerSvc = containerSvc
1066 d.ImageSvc = &stubImageService{}
1067 })
1068
1069 t.Run("images list requires status:read", func(t *testing.T) {
1070 req := httptest.NewRequest("GET", "/admin/images", nil)
1071 req = req.WithContext(ctxWithScopes("admin:config:write"))
1072 rec := httptest.NewRecorder()
1073
1074 handler.ServeHTTP(rec, req)
1075
1076 assert.Equal(t, http.StatusForbidden, rec.Code)
1077 assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
1078 })
1079
1080 t.Run("images prune requires config:write", func(t *testing.T) {
1081 req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
1082 req = req.WithContext(ctxWithScopes("admin:status:read"))
1083 rec := httptest.NewRecorder()
1084
1085 handler.ServeHTTP(rec, req)
1086
1087 assert.Equal(t, http.StatusForbidden, rec.Code)
1088 assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
1089 })
1090}
1091
1092func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
1093 handler := newTestHandler(t, func(d *HandlerDeps) {
1094 d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
1095 })
1096
1097 server := newScopedTestServer(t, handler, "admin:config:write")
1098 resp, err := http.Get(server.URL + "/admin/tls/status")
1099 require.NoError(t, err)
1100 defer resp.Body.Close()
1101
1102 body, err := io.ReadAll(resp.Body)
1103 require.NoError(t, err)
1104
1105 assert.Equal(t, http.StatusForbidden, resp.StatusCode)
1106 assert.Contains(t, string(body), "insufficient permissions for status:read")
1107}
1108
1109func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
1110 now := time.Now()
1111 tlsStatus := domain.PublicTLSStatus{
1112 ACMEEnabled: true,
1113 ConfiguredMode: domain.ACMEChallengeAuto,
1114 EffectiveMode: domain.ACMEChallengeHTTP01,
1115 SelectionReason: "auto-selected",
1116 TokenSource: domain.ACMETokenSourcePass,
1117 Certificates: []domain.ManagedCertificate{
1118 {
1119 ID: "cert-1",
1120 Names: []string{"example.com"},
1121 Challenge: domain.ACMEChallengeHTTP01,
1122 Status: domain.TLSCertificateStatusValid,
1123 NotAfter: now.Add(60 * 24 * time.Hour),
1124 },
1125 },
1126 Routes: []domain.TLSRouteCoverage{
1127 {
1128 Domain: "example.com",
1129 Covered: true,
1130 CoveredBy: "cert-1",
1131 RequiredACME: true,
1132 },
1133 },
1134 }
1135
1136 handler := newTestHandler(t, func(d *HandlerDeps) {
1137 fakeSvc := inmocks.NewMockPublicTLSService(t)
1138 fakeSvc.EXPECT().Status(mock.Anything).Return(tlsStatus)
1139 d.PublicTLSSvc = fakeSvc
1140 })
1141
1142 server := newScopedTestServer(t, handler, "admin:status:read")
1143 resp, err := http.Get(server.URL + "/admin/tls/status")
1144 require.NoError(t, err)
1145 defer resp.Body.Close()
1146
1147 assert.Equal(t, http.StatusOK, resp.StatusCode)
1148
1149 var body dto.TLSStatusResponse
1150 err = json.NewDecoder(resp.Body).Decode(&body)
1151 require.NoError(t, err)
1152
1153 assert.True(t, body.ACMEEnabled)
1154 assert.Equal(t, "auto", body.ConfiguredMode)
1155 assert.Equal(t, "http-01", body.EffectiveMode)
1156 assert.Equal(t, "auto-selected", body.SelectionReason)
1157 assert.Equal(t, "pass", body.TokenSource)
1158 require.Len(t, body.Certificates, 1)
1159 assert.Equal(t, "cert-1", body.Certificates[0].ID)
1160 assert.Equal(t, "valid", body.Certificates[0].Status)
1161 assert.Equal(t, "http-01", body.Certificates[0].Challenge)
1162 require.Len(t, body.Routes, 1)
1163 assert.Equal(t, "example.com", body.Routes[0].Domain)
1164 assert.True(t, body.Routes[0].Covered)
1165}
1166
1167func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
1168 handler := newTestHandler(t, func(d *HandlerDeps) {
1169 d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
1170 })
1171
1172 server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write")
1173 resp, err := http.Post(server.URL+"/admin/tls/status", "application/json", nil)
1174 require.NoError(t, err)
1175 defer resp.Body.Close()
1176
1177 assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
1178}