v3.3.1 31f1eec

handler_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close