v2.31.0-dev7 9ef6929

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

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