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