v2.31.0-dev9 062dcae

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

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