v2.30.0-pre4 15d9fe3

handler_test.go RawHistoryFolder

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

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