v2.31.1 f37fe67

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 previewContainerService struct {
33	*inmocks.MockContainerService
34	preview func(context.Context, string) (*domain.CleanupReport, error)
35}
36
37func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) {
38	if s.preview == nil {
39		return nil, nil
40	}
41	return s.preview(ctx, routeDomain)
42}
43
44type noopReloadTrigger struct{}
45
46func (noopReloadTrigger) Trigger(context.Context) error { return nil }
47
48type reloadTriggerRecorder struct {
49	calls        int
50	err          error
51	ctxErrAtCall error
52	hasDeadline  bool
53}
54
55func (r *reloadTriggerRecorder) Trigger(ctx context.Context) error {
56	r.calls++
57	r.ctxErrAtCall = ctx.Err()
58	_, r.hasDeadline = ctx.Deadline()
59	return r.err
60}
61
62func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
63	if s.listImagesFunc == nil {
64		return nil, nil
65	}
66	return s.listImagesFunc(ctx)
67}
68
69func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
70	if s.pruneFunc == nil {
71		return domain.ImagePruneReport{}, nil
72	}
73	return s.pruneFunc(ctx, opts)
74}
75
76func testLogger() zerowrap.Logger {
77	return zerowrap.Default()
78}
79
80func ctxWithScopes(scopes ...string) context.Context {
81	ctx := context.Background()
82	return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
83}
84
85func newScopedTestServer(t *testing.T, handler http.Handler, scopes ...string) *httptest.Server {
86	t.Helper()
87
88	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89		handler.ServeHTTP(w, r.WithContext(ctxWithScopes(scopes...)))
90	}))
91	t.Cleanup(server.Close)
92
93	return server
94}
95
96func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {
97	t.Helper()
98	deps := HandlerDeps{
99		ConfigSvc:     inmocks.NewMockConfigService(t),
100		AuthSvc:       inmocks.NewMockAuthService(t),
101		ContainerSvc:  inmocks.NewMockContainerService(t),
102		HealthSvc:     inmocks.NewMockHealthService(t),
103		SecretSvc:     inmocks.NewMockSecretService(t),
104		Log:           testLogger(),
105		ReloadTrigger: noopReloadTrigger{},
106	}
107	for _, opt := range opts {
108		opt(&deps)
109	}
110	return NewHandler(deps)
111}
112
113func TestHandler_VolumesGet_RequiresVolumesReadScope(t *testing.T) {
114	volumeSvc := inmocks.NewMockVolumeService(t)
115	handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc })
116
117	tests := []struct {
118		name       string
119		scopes     []string
120		wantStatus int
121	}{
122		{name: "volumes read access granted", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusOK},
123		{name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK},
124		{name: "status read denied", scopes: []string{"admin:status:read"}, wantStatus: http.StatusForbidden},
125		{name: "volumes write denied", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusForbidden},
126	}
127
128	for _, tt := range tests {
129		t.Run(tt.name, func(t *testing.T) {
130			if tt.wantStatus == http.StatusOK {
131				volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{}, nil).Once()
132			}
133			server := newScopedTestServer(t, handler, tt.scopes...)
134			resp, err := http.Get(server.URL + "/admin/volumes")
135			require.NoError(t, err)
136			defer resp.Body.Close()
137			assert.Equal(t, tt.wantStatus, resp.StatusCode)
138		})
139	}
140}
141
142func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) {
143	volumeSvc := inmocks.NewMockVolumeService(t)
144	handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc })
145
146	tests := []struct {
147		name       string
148		scopes     []string
149		wantStatus int
150	}{
151		{name: "volumes write access granted", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusOK},
152		{name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK},
153		{name: "config write denied", scopes: []string{"admin:config:write"}, wantStatus: http.StatusForbidden},
154		{name: "volumes read denied", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusForbidden},
155	}
156
157	for _, tt := range tests {
158		t.Run(tt.name, func(t *testing.T) {
159			if tt.wantStatus == http.StatusOK {
160				volumeSvc.EXPECT().PruneVolumes(mock.Anything, true).Return(&domain.VolumePruneReport{}, []*domain.VolumeInfo{}, nil).Once()
161			}
162			server := newScopedTestServer(t, handler, tt.scopes...)
163			resp, err := http.Post(server.URL+"/admin/volumes/prune", "application/json", bytes.NewBufferString(`{"dry_run":true}`))
164			require.NoError(t, err)
165			defer resp.Body.Close()
166			assert.Equal(t, tt.wantStatus, resp.StatusCode)
167		})
168	}
169}
170
171// Routes endpoint tests
172
173func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
174	configSvc := inmocks.NewMockConfigService(t)
175	authSvc := inmocks.NewMockAuthService(t)
176	containerSvc := inmocks.NewMockContainerService(t)
177	secretSvc := inmocks.NewMockSecretService(t)
178
179	handler := newTestHandler(t, func(d *HandlerDeps) {
180		d.ConfigSvc = configSvc
181		d.AuthSvc = authSvc
182		d.ContainerSvc = containerSvc
183		d.SecretSvc = secretSvc
184		d.RegistrySvc = inmocks.NewMockRegistryService(t)
185	})
186
187	tests := []struct {
188		name       string
189		scopes     []string
190		wantStatus int
191	}{
192		{
193			name:       "routes read access granted",
194			scopes:     []string{"admin:routes:read"},
195			wantStatus: http.StatusOK,
196		},
197		{
198			name:       "routes wildcard access granted",
199			scopes:     []string{"admin:routes:*"},
200			wantStatus: http.StatusOK,
201		},
202		{
203			name:       "all admin access granted",
204			scopes:     []string{"admin:*:*"},
205			wantStatus: http.StatusOK,
206		},
207		{
208			name:       "only write access denied",
209			scopes:     []string{"admin:routes:write"},
210			wantStatus: http.StatusForbidden,
211		},
212		{
213			name:       "wrong resource denied",
214			scopes:     []string{"admin:secrets:read"},
215			wantStatus: http.StatusForbidden,
216		},
217		{
218			name:       "no scopes denied",
219			scopes:     []string{},
220			wantStatus: http.StatusForbidden,
221		},
222	}
223
224	for _, tt := range tests {
225		t.Run(tt.name, func(t *testing.T) {
226			if tt.wantStatus == http.StatusOK {
227				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
228			}
229
230			req := httptest.NewRequest("GET", "/admin/routes", nil)
231			req = req.WithContext(ctxWithScopes(tt.scopes...))
232			rec := httptest.NewRecorder()
233
234			handler.ServeHTTP(rec, req)
235
236			assert.Equal(t, tt.wantStatus, rec.Code)
237		})
238	}
239}
240
241func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
242	configSvc := inmocks.NewMockConfigService(t)
243	authSvc := inmocks.NewMockAuthService(t)
244	containerSvc := inmocks.NewMockContainerService(t)
245	secretSvc := inmocks.NewMockSecretService(t)
246
247	handler := newTestHandler(t, func(d *HandlerDeps) {
248		d.ConfigSvc = configSvc
249		d.AuthSvc = authSvc
250		d.ContainerSvc = containerSvc
251		d.SecretSvc = secretSvc
252	})
253
254	routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
255
256	tests := []struct {
257		name       string
258		scopes     []string
259		wantStatus int
260	}{
261		{
262			name:       "routes write access granted",
263			scopes:     []string{"admin:routes:write"},
264			wantStatus: http.StatusCreated,
265		},
266		{
267			name:       "routes wildcard access granted",
268			scopes:     []string{"admin:routes:*"},
269			wantStatus: http.StatusCreated,
270		},
271		{
272			name:       "all admin access granted",
273			scopes:     []string{"admin:*:*"},
274			wantStatus: http.StatusCreated,
275		},
276		{
277			name:       "only read access denied",
278			scopes:     []string{"admin:routes:read"},
279			wantStatus: http.StatusForbidden,
280		},
281		{
282			name:       "wrong resource denied",
283			scopes:     []string{"admin:secrets:write"},
284			wantStatus: http.StatusForbidden,
285		},
286	}
287
288	for _, tt := range tests {
289		t.Run(tt.name, func(t *testing.T) {
290			if tt.wantStatus == http.StatusCreated {
291				configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
292			}
293
294			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
295			req = req.WithContext(ctxWithScopes(tt.scopes...))
296			rec := httptest.NewRecorder()
297
298			handler.ServeHTTP(rec, req)
299
300			assert.Equal(t, tt.wantStatus, rec.Code)
301		})
302	}
303}
304
305func TestHandler_RoutesPost_InvalidRouteDomain(t *testing.T) {
306	configSvc := inmocks.NewMockConfigService(t)
307	authSvc := inmocks.NewMockAuthService(t)
308	containerSvc := inmocks.NewMockContainerService(t)
309	secretSvc := inmocks.NewMockSecretService(t)
310
311	handler := newTestHandler(t, func(d *HandlerDeps) {
312		d.ConfigSvc = configSvc
313		d.AuthSvc = authSvc
314		d.ContainerSvc = containerSvc
315		d.SecretSvc = secretSvc
316	})
317	server := newScopedTestServer(t, handler, "admin:routes:write")
318
319	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "localhost", Image: "myapp:latest", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once()
320
321	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"localhost","image":"myapp:latest"}`))
322	require.NoError(t, err)
323
324	resp, err := server.Client().Do(req)
325	require.NoError(t, err)
326	defer resp.Body.Close()
327
328	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
329	body, err := io.ReadAll(resp.Body)
330	require.NoError(t, err)
331	assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`, string(body))
332}
333
334func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
335	configSvc := inmocks.NewMockConfigService(t)
336	authSvc := inmocks.NewMockAuthService(t)
337	containerSvc := inmocks.NewMockContainerService(t)
338	secretSvc := inmocks.NewMockSecretService(t)
339
340	handler := newTestHandler(t, func(d *HandlerDeps) {
341		d.ConfigSvc = configSvc
342		d.AuthSvc = authSvc
343		d.ContainerSvc = containerSvc
344		d.SecretSvc = secretSvc
345	})
346
347	routeJSON := `{"image": "myapp:v2"}`
348
349	tests := []struct {
350		name       string
351		scopes     []string
352		wantStatus int
353	}{
354		{
355			name:       "routes write access granted",
356			scopes:     []string{"admin:routes:write"},
357			wantStatus: http.StatusOK,
358		},
359		{
360			name:       "only read access denied",
361			scopes:     []string{"admin:routes:read"},
362			wantStatus: http.StatusForbidden,
363		},
364	}
365
366	for _, tt := range tests {
367		t.Run(tt.name, func(t *testing.T) {
368			if tt.wantStatus == http.StatusOK {
369				configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:v1", HTTPS: true}, nil).Maybe()
370				configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
371			}
372
373			req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
374			req = req.WithContext(ctxWithScopes(tt.scopes...))
375			rec := httptest.NewRecorder()
376
377			handler.ServeHTTP(rec, req)
378
379			assert.Equal(t, tt.wantStatus, rec.Code)
380		})
381	}
382}
383
384func TestHandler_RoutesPut_InvalidRouteDomain(t *testing.T) {
385	configSvc := inmocks.NewMockConfigService(t)
386	authSvc := inmocks.NewMockAuthService(t)
387	containerSvc := inmocks.NewMockContainerService(t)
388	secretSvc := inmocks.NewMockSecretService(t)
389
390	handler := newTestHandler(t, func(d *HandlerDeps) {
391		d.ConfigSvc = configSvc
392		d.AuthSvc = authSvc
393		d.ContainerSvc = containerSvc
394		d.SecretSvc = secretSvc
395	})
396	server := newScopedTestServer(t, handler, "admin:routes:write")
397
398	configSvc.EXPECT().GetRoute(mock.Anything, "localhost").Return(&domain.Route{Domain: "localhost", Image: "myapp:v1", HTTPS: true}, nil).Once()
399	configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "localhost", Image: "myapp:v2", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once()
400
401	req, err := http.NewRequest(http.MethodPut, server.URL+"/admin/routes/localhost", bytes.NewBufferString(`{"image":"myapp:v2"}`))
402	require.NoError(t, err)
403
404	resp, err := server.Client().Do(req)
405	require.NoError(t, err)
406	defer resp.Body.Close()
407
408	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
409	body, err := io.ReadAll(resp.Body)
410	require.NoError(t, err)
411	assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`, string(body))
412}
413
414func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) {
415	configSvc := inmocks.NewMockConfigService(t)
416	authSvc := inmocks.NewMockAuthService(t)
417	containerSvc := inmocks.NewMockContainerService(t)
418	secretSvc := inmocks.NewMockSecretService(t)
419	registrySvc := inmocks.NewMockRegistryService(t)
420	route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}
421
422	handler := newTestHandler(t, func(d *HandlerDeps) {
423		d.ConfigSvc = configSvc
424		d.AuthSvc = authSvc
425		d.ContainerSvc = containerSvc
426		d.SecretSvc = secretSvc
427		d.RegistrySvc = registrySvc
428	})
429
430	configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once()
431
432	server := newScopedTestServer(t, handler, "admin:routes:write")
433	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
434	require.NoError(t, err)
435
436	resp, err := server.Client().Do(req)
437	require.NoError(t, err)
438	defer resp.Body.Close()
439
440	body, err := io.ReadAll(resp.Body)
441	require.NoError(t, err)
442	assert.Equal(t, http.StatusCreated, resp.StatusCode)
443	assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":true}`, string(body))
444	registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
445}
446
447func TestHandleRoutesPost_DefaultsHTTPSWhenOmitted(t *testing.T) {
448	configSvc := inmocks.NewMockConfigService(t)
449	authSvc := inmocks.NewMockAuthService(t)
450	containerSvc := inmocks.NewMockContainerService(t)
451	secretSvc := inmocks.NewMockSecretService(t)
452
453	handler := newTestHandler(t, func(d *HandlerDeps) {
454		d.ConfigSvc = configSvc
455		d.AuthSvc = authSvc
456		d.ContainerSvc = containerSvc
457		d.SecretSvc = secretSvc
458	})
459
460	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil).Once()
461
462	server := newScopedTestServer(t, handler, "admin:routes:write")
463	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`))
464	require.NoError(t, err)
465
466	resp, err := server.Client().Do(req)
467	require.NoError(t, err)
468	defer resp.Body.Close()
469
470	body, err := io.ReadAll(resp.Body)
471	require.NoError(t, err)
472	assert.Equal(t, http.StatusCreated, resp.StatusCode)
473	assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":true}`, string(body))
474}
475
476func TestHandleRoutesPut_PreservesStoredHTTPSWhenOmitted(t *testing.T) {
477	configSvc := inmocks.NewMockConfigService(t)
478	authSvc := inmocks.NewMockAuthService(t)
479	containerSvc := inmocks.NewMockContainerService(t)
480	secretSvc := inmocks.NewMockSecretService(t)
481	registrySvc := inmocks.NewMockRegistryService(t)
482	route := domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: false}
483
484	handler := newTestHandler(t, func(d *HandlerDeps) {
485		d.ConfigSvc = configSvc
486		d.AuthSvc = authSvc
487		d.ContainerSvc = containerSvc
488		d.SecretSvc = secretSvc
489		d.RegistrySvc = registrySvc
490	})
491
492	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}, nil).Once()
493	configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once()
494
495	req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2"}`))
496	req = req.WithContext(ctxWithScopes("admin:routes:write"))
497	rec := httptest.NewRecorder()
498
499	handler.ServeHTTP(rec, req)
500
501	assert.Equal(t, http.StatusOK, rec.Code)
502	assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":false}`, rec.Body.String())
503	registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
504}
505
506func TestHandleRoutesPut_OverridesStoredHTTPSWhenProvided(t *testing.T) {
507	configSvc := inmocks.NewMockConfigService(t)
508	authSvc := inmocks.NewMockAuthService(t)
509	containerSvc := inmocks.NewMockContainerService(t)
510	secretSvc := inmocks.NewMockSecretService(t)
511
512	handler := newTestHandler(t, func(d *HandlerDeps) {
513		d.ConfigSvc = configSvc
514		d.AuthSvc = authSvc
515		d.ContainerSvc = containerSvc
516		d.SecretSvc = secretSvc
517	})
518
519	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}, nil).Once()
520	configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(nil).Once()
521
522	req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2","https":true}`))
523	req = req.WithContext(ctxWithScopes("admin:routes:write"))
524	rec := httptest.NewRecorder()
525
526	handler.ServeHTTP(rec, req)
527
528	assert.Equal(t, http.StatusOK, rec.Code)
529	assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":true}`, rec.Body.String())
530}
531
532func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) {
533	configSvc := inmocks.NewMockConfigService(t)
534	authSvc := inmocks.NewMockAuthService(t)
535	containerSvc := inmocks.NewMockContainerService(t)
536	secretSvc := inmocks.NewMockSecretService(t)
537
538	handler := newTestHandler(t, func(d *HandlerDeps) {
539		d.ConfigSvc = configSvc
540		d.AuthSvc = authSvc
541		d.ContainerSvc = containerSvc
542		d.SecretSvc = secretSvc
543	})
544
545	configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once()
546
547	req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil)
548	req = req.WithContext(ctxWithScopes("admin:config:read"))
549	rec := httptest.NewRecorder()
550
551	handler.ServeHTTP(rec, req)
552
553	assert.Equal(t, http.StatusOK, rec.Code)
554	assert.JSONEq(t, `{"image":"postgres:16","targets":["app.example.com"]}`, rec.Body.String())
555}
556
557func TestHandler_AttachmentsByImageGet_MultipleTargets(t *testing.T) {
558	configSvc := inmocks.NewMockConfigService(t)
559	authSvc := inmocks.NewMockAuthService(t)
560	containerSvc := inmocks.NewMockContainerService(t)
561	secretSvc := inmocks.NewMockSecretService(t)
562
563	handler := newTestHandler(t, func(d *HandlerDeps) {
564		d.ConfigSvc = configSvc
565		d.AuthSvc = authSvc
566		d.ContainerSvc = containerSvc
567		d.SecretSvc = secretSvc
568	})
569
570	configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once()
571
572	req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres", nil)
573	req = req.WithContext(ctxWithScopes("admin:config:read"))
574	rec := httptest.NewRecorder()
575
576	handler.ServeHTTP(rec, req)
577
578	assert.Equal(t, http.StatusOK, rec.Code)
579	assert.JSONEq(t, `{"image":"postgres","targets":["app.example.com","workers"]}`, rec.Body.String())
580}
581
582func TestHandler_AttachmentsByImageGet_URLDecodedImage(t *testing.T) {
583	configSvc := inmocks.NewMockConfigService(t)
584	authSvc := inmocks.NewMockAuthService(t)
585	containerSvc := inmocks.NewMockContainerService(t)
586	secretSvc := inmocks.NewMockSecretService(t)
587
588	handler := newTestHandler(t, func(d *HandlerDeps) {
589		d.ConfigSvc = configSvc
590		d.AuthSvc = authSvc
591		d.ContainerSvc = containerSvc
592		d.SecretSvc = secretSvc
593	})
594
595	configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once()
596
597	req := httptest.NewRequest("GET", "/admin/attachments/by-image/registry%2Forg%2Fimage:tag", nil)
598	req = req.WithContext(ctxWithScopes("admin:config:read"))
599	rec := httptest.NewRecorder()
600
601	handler.ServeHTTP(rec, req)
602
603	assert.Equal(t, http.StatusOK, rec.Code)
604	assert.JSONEq(t, `{"image":"registry/org/image:tag","targets":["app.example.com"]}`, rec.Body.String())
605}
606
607func TestHandler_AttachmentsByImageGet_EmptyResultSet(t *testing.T) {
608	configSvc := inmocks.NewMockConfigService(t)
609	authSvc := inmocks.NewMockAuthService(t)
610	containerSvc := inmocks.NewMockContainerService(t)
611	secretSvc := inmocks.NewMockSecretService(t)
612
613	handler := newTestHandler(t, func(d *HandlerDeps) {
614		d.ConfigSvc = configSvc
615		d.AuthSvc = authSvc
616		d.ContainerSvc = containerSvc
617		d.SecretSvc = secretSvc
618	})
619
620	configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once()
621
622	req := httptest.NewRequest("GET", "/admin/attachments/by-image/missing:latest", nil)
623	req = req.WithContext(ctxWithScopes("admin:config:read"))
624	rec := httptest.NewRecorder()
625
626	handler.ServeHTTP(rec, req)
627
628	assert.Equal(t, http.StatusOK, rec.Code)
629	assert.JSONEq(t, `{"image":"missing:latest","targets":[]}`, rec.Body.String())
630}
631
632func TestHandler_AttachmentsByImageGet_MissingImagePath(t *testing.T) {
633	configSvc := inmocks.NewMockConfigService(t)
634	authSvc := inmocks.NewMockAuthService(t)
635	containerSvc := inmocks.NewMockContainerService(t)
636	secretSvc := inmocks.NewMockSecretService(t)
637
638	handler := newTestHandler(t, func(d *HandlerDeps) {
639		d.ConfigSvc = configSvc
640		d.AuthSvc = authSvc
641		d.ContainerSvc = containerSvc
642		d.SecretSvc = secretSvc
643	})
644
645	req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil)
646	req = req.WithContext(ctxWithScopes("admin:config:read"))
647	rec := httptest.NewRecorder()
648
649	handler.ServeHTTP(rec, req)
650
651	assert.Equal(t, http.StatusBadRequest, rec.Code)
652	assert.JSONEq(t, `{"error":"image name required in path"}`, rec.Body.String())
653}
654
655func TestHandler_AttachmentsByImageGet_InvalidEncoding(t *testing.T) {
656	configSvc := inmocks.NewMockConfigService(t)
657	authSvc := inmocks.NewMockAuthService(t)
658	containerSvc := inmocks.NewMockContainerService(t)
659	secretSvc := inmocks.NewMockSecretService(t)
660
661	handler := newTestHandler(t, func(d *HandlerDeps) {
662		d.ConfigSvc = configSvc
663		d.AuthSvc = authSvc
664		d.ContainerSvc = containerSvc
665		d.SecretSvc = secretSvc
666	})
667
668	req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil)
669	req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"}
670	req = req.WithContext(ctxWithScopes("admin:config:read"))
671	rec := httptest.NewRecorder()
672
673	handler.ServeHTTP(rec, req)
674
675	assert.Equal(t, http.StatusBadRequest, rec.Code)
676	assert.JSONEq(t, `{"error":"invalid image name encoding"}`, rec.Body.String())
677}
678
679func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) {
680	configSvc := inmocks.NewMockConfigService(t)
681	authSvc := inmocks.NewMockAuthService(t)
682	containerSvc := inmocks.NewMockContainerService(t)
683	secretSvc := inmocks.NewMockSecretService(t)
684
685	handler := newTestHandler(t, func(d *HandlerDeps) {
686		d.ConfigSvc = configSvc
687		d.AuthSvc = authSvc
688		d.ContainerSvc = containerSvc
689		d.SecretSvc = secretSvc
690	})
691
692	req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil)
693	req = req.WithContext(ctxWithScopes("admin:routes:read"))
694	rec := httptest.NewRecorder()
695
696	handler.ServeHTTP(rec, req)
697
698	assert.Equal(t, http.StatusForbidden, rec.Code)
699	assert.JSONEq(t, `{"error":"insufficient permissions for config:read"}`, rec.Body.String())
700}
701
702func TestHandler_RoutesByImageGet_URLDecodedImage(t *testing.T) {
703	configSvc := inmocks.NewMockConfigService(t)
704	authSvc := inmocks.NewMockAuthService(t)
705	containerSvc := inmocks.NewMockContainerService(t)
706	secretSvc := inmocks.NewMockSecretService(t)
707
708	handler := newTestHandler(t, func(d *HandlerDeps) {
709		d.ConfigSvc = configSvc
710		d.AuthSvc = authSvc
711		d.ContainerSvc = containerSvc
712		d.SecretSvc = secretSvc
713	})
714
715	configSvc.EXPECT().FindRoutesByImage(mock.Anything, "registry/org/image:tag").Return([]domain.Route{{
716		Domain: "app.example.com",
717		Image:  "registry/org/image:tag",
718	}}).Once()
719
720	req := httptest.NewRequest("GET", "/admin/routes/by-image/registry%2Forg%2Fimage:tag", nil)
721	req = req.WithContext(ctxWithScopes("admin:routes:read"))
722	rec := httptest.NewRecorder()
723
724	handler.ServeHTTP(rec, req)
725
726	assert.Equal(t, http.StatusOK, rec.Code)
727	assert.JSONEq(t, `{"image":"registry/org/image:tag","routes":[{"domain":"app.example.com","image":"registry/org/image:tag","https":false}]}`, rec.Body.String())
728}
729
730func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
731	configSvc := inmocks.NewMockConfigService(t)
732	authSvc := inmocks.NewMockAuthService(t)
733	containerSvc := inmocks.NewMockContainerService(t)
734	secretSvc := inmocks.NewMockSecretService(t)
735
736	handler := newTestHandler(t, func(d *HandlerDeps) {
737		d.ConfigSvc = configSvc
738		d.AuthSvc = authSvc
739		d.ContainerSvc = containerSvc
740		d.SecretSvc = secretSvc
741	})
742
743	tests := []struct {
744		name       string
745		scopes     []string
746		wantStatus int
747	}{
748		{
749			name:       "routes write access granted",
750			scopes:     []string{"admin:routes:write"},
751			wantStatus: http.StatusOK,
752		},
753		{
754			name:       "only read access denied",
755			scopes:     []string{"admin:routes:read"},
756			wantStatus: http.StatusForbidden,
757		},
758	}
759
760	for _, tt := range tests {
761		t.Run(tt.name, func(t *testing.T) {
762			if tt.wantStatus == http.StatusOK {
763				configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once()
764				containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Once()
765			}
766
767			req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
768			req = req.WithContext(ctxWithScopes(tt.scopes...))
769			rec := httptest.NewRecorder()
770
771			handler.ServeHTTP(rec, req)
772
773			assert.Equal(t, tt.wantStatus, rec.Code)
774		})
775	}
776}
777
778func TestHandler_RoutesDelete_ReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) {
779	configSvc := inmocks.NewMockConfigService(t)
780	authSvc := inmocks.NewMockAuthService(t)
781	containerSvc := inmocks.NewMockContainerService(t)
782	secretSvc := inmocks.NewMockSecretService(t)
783	report := &domain.CleanupReport{Domain: "app.example.com"}
784
785	configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(domain.ErrRouteNotFound).Once()
786	containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once()
787
788	handler := newTestHandler(t, func(d *HandlerDeps) {
789		d.ConfigSvc = configSvc
790		d.AuthSvc = authSvc
791		d.ContainerSvc = containerSvc
792		d.SecretSvc = secretSvc
793	})
794	server := newScopedTestServer(t, handler, "admin:routes:write")
795
796	req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil)
797	require.NoError(t, err)
798	respHTTP, err := http.DefaultClient.Do(req)
799	require.NoError(t, err)
800	defer respHTTP.Body.Close()
801
802	require.Equal(t, http.StatusOK, respHTTP.StatusCode)
803	var resp dto.RouteDeleteResponse
804	require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp))
805	require.NotNil(t, resp.Cleanup)
806	assert.Equal(t, "app.example.com", resp.Cleanup.Domain)
807}
808
809func TestHandler_RoutesDelete_ReturnsServerErrorWhenRuntimeCleanupFails(t *testing.T) {
810	configSvc := inmocks.NewMockConfigService(t)
811	authSvc := inmocks.NewMockAuthService(t)
812	containerSvc := inmocks.NewMockContainerService(t)
813	secretSvc := inmocks.NewMockSecretService(t)
814
815	configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once()
816	containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(nil, errors.New("boom")).Once()
817
818	handler := newTestHandler(t, func(d *HandlerDeps) {
819		d.ConfigSvc = configSvc
820		d.AuthSvc = authSvc
821		d.ContainerSvc = containerSvc
822		d.SecretSvc = secretSvc
823	})
824	server := newScopedTestServer(t, handler, "admin:routes:write")
825
826	req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil)
827	require.NoError(t, err)
828	resp, err := http.DefaultClient.Do(req)
829	require.NoError(t, err)
830	defer resp.Body.Close()
831	body, err := io.ReadAll(resp.Body)
832	require.NoError(t, err)
833
834	assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
835	assert.Contains(t, string(body), "route removed but runtime cleanup failed")
836}
837
838func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testing.T) {
839	configSvc := inmocks.NewMockConfigService(t)
840	authSvc := inmocks.NewMockAuthService(t)
841	containerSvc := inmocks.NewMockContainerService(t)
842	secretSvc := inmocks.NewMockSecretService(t)
843
844	report := &domain.CleanupReport{
845		Domain: "app.example.com",
846		RemovedContainers: []domain.CleanupContainer{{
847			ID:   "container-1",
848			Name: "gordon-app.example.com",
849		}},
850		PreservedAttachments: []domain.CleanupAttachment{{
851			Name:        "postgres",
852			ContainerID: "attachment-1",
853			Status:      "running",
854		}},
855	}
856
857	removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once()
858	cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once()
859	mock.InOrder(removeCall, cleanupCall)
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	server := newScopedTestServer(t, handler, "admin:routes:write")
868
869	req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil)
870	require.NoError(t, err)
871	respHTTP, err := http.DefaultClient.Do(req)
872	require.NoError(t, err)
873	defer respHTTP.Body.Close()
874
875	require.Equal(t, http.StatusOK, respHTTP.StatusCode)
876	var resp dto.RouteDeleteResponse
877	require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp))
878	assert.Equal(t, "removed", resp.Status)
879	require.NotNil(t, resp.Cleanup)
880	assert.Equal(t, "app.example.com", resp.Cleanup.Domain)
881	require.Len(t, resp.Cleanup.RemovedContainers, 1)
882	assert.Equal(t, "container-1", resp.Cleanup.RemovedContainers[0].ID)
883	require.Len(t, resp.Cleanup.PreservedAttachments, 1)
884	assert.Equal(t, "attachment-1", resp.Cleanup.PreservedAttachments[0].ContainerID)
885}
886
887// Secrets endpoint tests
888
889func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
890	configSvc := inmocks.NewMockConfigService(t)
891	authSvc := inmocks.NewMockAuthService(t)
892	containerSvc := inmocks.NewMockContainerService(t)
893	secretSvc := inmocks.NewMockSecretService(t)
894
895	handler := newTestHandler(t, func(d *HandlerDeps) {
896		d.ConfigSvc = configSvc
897		d.AuthSvc = authSvc
898		d.ContainerSvc = containerSvc
899		d.SecretSvc = secretSvc
900	})
901
902	tests := []struct {
903		name       string
904		scopes     []string
905		wantStatus int
906	}{
907		{
908			name:       "secrets read access granted",
909			scopes:     []string{"admin:secrets:read"},
910			wantStatus: http.StatusOK,
911		},
912		{
913			name:       "secrets wildcard access granted",
914			scopes:     []string{"admin:secrets:*"},
915			wantStatus: http.StatusOK,
916		},
917		{
918			name:       "all admin access granted",
919			scopes:     []string{"admin:*:*"},
920			wantStatus: http.StatusOK,
921		},
922		{
923			name:       "only write access denied",
924			scopes:     []string{"admin:secrets:write"},
925			wantStatus: http.StatusForbidden,
926		},
927		{
928			name:       "wrong resource denied",
929			scopes:     []string{"admin:routes:read"},
930			wantStatus: http.StatusForbidden,
931		},
932	}
933
934	for _, tt := range tests {
935		t.Run(tt.name, func(t *testing.T) {
936			if tt.wantStatus == http.StatusOK {
937				secretSvc.EXPECT().ListKeysWithAttachments(mock.Anything, "app.example.com").Return([]string{}, nil, nil).Maybe()
938			}
939
940			req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
941			req = req.WithContext(ctxWithScopes(tt.scopes...))
942			rec := httptest.NewRecorder()
943
944			handler.ServeHTTP(rec, req)
945
946			assert.Equal(t, tt.wantStatus, rec.Code)
947		})
948	}
949}
950
951func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
952	configSvc := inmocks.NewMockConfigService(t)
953	authSvc := inmocks.NewMockAuthService(t)
954	containerSvc := inmocks.NewMockContainerService(t)
955	secretSvc := inmocks.NewMockSecretService(t)
956
957	handler := newTestHandler(t, func(d *HandlerDeps) {
958		d.ConfigSvc = configSvc
959		d.AuthSvc = authSvc
960		d.ContainerSvc = containerSvc
961		d.SecretSvc = secretSvc
962	})
963
964	secretsJSON := `{"API_KEY": "secret123"}`
965
966	tests := []struct {
967		name       string
968		scopes     []string
969		wantStatus int
970	}{
971		{
972			name:       "secrets write access granted",
973			scopes:     []string{"admin:secrets:write"},
974			wantStatus: http.StatusOK,
975		},
976		{
977			name:       "only read access denied",
978			scopes:     []string{"admin:secrets:read"},
979			wantStatus: http.StatusForbidden,
980		},
981		{
982			name:       "wrong resource denied",
983			scopes:     []string{"admin:routes:write"},
984			wantStatus: http.StatusForbidden,
985		},
986	}
987
988	for _, tt := range tests {
989		t.Run(tt.name, func(t *testing.T) {
990			if tt.wantStatus == http.StatusOK {
991				secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe()
992			}
993
994			req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
995			req = req.WithContext(ctxWithScopes(tt.scopes...))
996			rec := httptest.NewRecorder()
997
998			handler.ServeHTTP(rec, req)
999
1000			assert.Equal(t, tt.wantStatus, rec.Code)
1001		})
1002	}
1003}
1004
1005func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
1006	configSvc := inmocks.NewMockConfigService(t)
1007	authSvc := inmocks.NewMockAuthService(t)
1008	containerSvc := inmocks.NewMockContainerService(t)
1009	secretSvc := inmocks.NewMockSecretService(t)
1010
1011	handler := newTestHandler(t, func(d *HandlerDeps) {
1012		d.ConfigSvc = configSvc
1013		d.AuthSvc = authSvc
1014		d.ContainerSvc = containerSvc
1015		d.SecretSvc = secretSvc
1016	})
1017
1018	tests := []struct {
1019		name       string
1020		scopes     []string
1021		wantStatus int
1022	}{
1023		{
1024			name:       "secrets write access granted",
1025			scopes:     []string{"admin:secrets:write"},
1026			wantStatus: http.StatusOK,
1027		},
1028		{
1029			name:       "only read access denied",
1030			scopes:     []string{"admin:secrets:read"},
1031			wantStatus: http.StatusForbidden,
1032		},
1033	}
1034
1035	for _, tt := range tests {
1036		t.Run(tt.name, func(t *testing.T) {
1037			if tt.wantStatus == http.StatusOK {
1038				secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe()
1039			}
1040
1041			req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
1042			req = req.WithContext(ctxWithScopes(tt.scopes...))
1043			rec := httptest.NewRecorder()
1044
1045			handler.ServeHTTP(rec, req)
1046
1047			assert.Equal(t, tt.wantStatus, rec.Code)
1048		})
1049	}
1050}
1051
1052// Attachment secrets endpoint tests
1053
1054func TestHandler_AttachmentSecretsPost(t *testing.T) {
1055	configSvc := inmocks.NewMockConfigService(t)
1056	authSvc := inmocks.NewMockAuthService(t)
1057	containerSvc := inmocks.NewMockContainerService(t)
1058	secretSvc := inmocks.NewMockSecretService(t)
1059
1060	handler := newTestHandler(t, func(d *HandlerDeps) {
1061		d.ConfigSvc = configSvc
1062		d.AuthSvc = authSvc
1063		d.ContainerSvc = containerSvc
1064		d.SecretSvc = secretSvc
1065	})
1066
1067	secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
1068
1069	body := `{"REDIS_URL": "redis://localhost:6379"}`
1070	req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1071	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1072	rec := httptest.NewRecorder()
1073
1074	handler.ServeHTTP(rec, req)
1075
1076	assert.Equal(t, http.StatusOK, rec.Code)
1077
1078	var response struct {
1079		Status string `json:"status"`
1080	}
1081	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1082	assert.Equal(t, "updated", response.Status)
1083}
1084
1085func TestHandler_AttachmentSecretsDelete(t *testing.T) {
1086	configSvc := inmocks.NewMockConfigService(t)
1087	authSvc := inmocks.NewMockAuthService(t)
1088	containerSvc := inmocks.NewMockContainerService(t)
1089	secretSvc := inmocks.NewMockSecretService(t)
1090
1091	handler := newTestHandler(t, func(d *HandlerDeps) {
1092		d.ConfigSvc = configSvc
1093		d.AuthSvc = authSvc
1094		d.ContainerSvc = containerSvc
1095		d.SecretSvc = secretSvc
1096	})
1097
1098	secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
1099
1100	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1101	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1102	rec := httptest.NewRecorder()
1103
1104	handler.ServeHTTP(rec, req)
1105
1106	assert.Equal(t, http.StatusOK, rec.Code)
1107
1108	var response struct {
1109		Status string `json:"status"`
1110	}
1111	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1112	assert.Equal(t, "deleted", response.Status)
1113}
1114
1115func TestHandler_Bootstrap_HappyPath(t *testing.T) {
1116	configSvc := inmocks.NewMockConfigService(t)
1117	authSvc := inmocks.NewMockAuthService(t)
1118	containerSvc := inmocks.NewMockContainerService(t)
1119	secretSvc := inmocks.NewMockSecretService(t)
1120	registrySvc := inmocks.NewMockRegistryService(t)
1121
1122	handler := newTestHandler(t, func(d *HandlerDeps) {
1123		d.ConfigSvc = configSvc
1124		d.AuthSvc = authSvc
1125		d.ContainerSvc = containerSvc
1126		d.SecretSvc = secretSvc
1127		d.RegistrySvc = registrySvc
1128	})
1129
1130	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
1131	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
1132	configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()
1133	secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
1134	secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
1135
1136	body, err := json.Marshal(dto.BootstrapRequest{
1137		Domain:      "app.example.com",
1138		Image:       "myapp:latest",
1139		Attachments: []string{"postgres:16"},
1140		Env:         map[string]string{"APP_ENV": "prod"},
1141		AttachmentEnv: map[string]map[string]string{
1142			"postgres": {"POSTGRES_PASSWORD": "secret"},
1143		},
1144	})
1145	assert.NoError(t, err)
1146
1147	req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body))
1148	req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
1149	rec := httptest.NewRecorder()
1150
1151	handler.ServeHTTP(rec, req)
1152
1153	assert.Equal(t, http.StatusOK, rec.Code)
1154	registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
1155
1156	var response dto.BootstrapResponse
1157	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1158	assert.Equal(t, "app.example.com", response.Domain)
1159	assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
1160	assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
1161	assert.Equal(t, []dto.BootstrapStep{
1162		{Name: "route", Status: "configured"},
1163		{Name: "attachment:postgres:16", Status: "created"},
1164		{Name: "env", Status: "updated"},
1165		{Name: "attachment_env:postgres", Status: "updated"},
1166	}, response.Steps)
1167}
1168
1169func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
1170	configSvc := inmocks.NewMockConfigService(t)
1171	authSvc := inmocks.NewMockAuthService(t)
1172	containerSvc := inmocks.NewMockContainerService(t)
1173	secretSvc := inmocks.NewMockSecretService(t)
1174
1175	handler := newTestHandler(t, func(d *HandlerDeps) {
1176		d.ConfigSvc = configSvc
1177		d.AuthSvc = authSvc
1178		d.ContainerSvc = containerSvc
1179		d.SecretSvc = secretSvc
1180	})
1181
1182	tests := []struct {
1183		name       string
1184		scopes     []string
1185		body       string
1186		wantStatus int
1187	}{
1188		{name: "route only needs routes permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusOK},
1189		{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},
1190		{name: "attachments require config permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","attachments":["postgres:16"]}`, wantStatus: http.StatusForbidden},
1191		{name: "missing routes permission", scopes: []string{"admin:secrets:write", "admin:config:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusForbidden},
1192	}
1193
1194	for _, tt := range tests {
1195		t.Run(tt.name, func(t *testing.T) {
1196			if tt.wantStatus == http.StatusOK || strings.Contains(tt.body, `"env"`) || strings.Contains(tt.body, `"attachments"`) {
1197				configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
1198			}
1199			if tt.wantStatus == http.StatusOK {
1200				configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
1201			}
1202
1203			req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))
1204			req = req.WithContext(ctxWithScopes(tt.scopes...))
1205			rec := httptest.NewRecorder()
1206
1207			handler.ServeHTTP(rec, req)
1208
1209			assert.Equal(t, tt.wantStatus, rec.Code)
1210		})
1211	}
1212}
1213
1214// Status endpoint tests
1215
1216func TestHandler_Status_RequiresReadScope(t *testing.T) {
1217	configSvc := inmocks.NewMockConfigService(t)
1218	authSvc := inmocks.NewMockAuthService(t)
1219	containerSvc := inmocks.NewMockContainerService(t)
1220	secretSvc := inmocks.NewMockSecretService(t)
1221
1222	handler := newTestHandler(t, func(d *HandlerDeps) {
1223		d.ConfigSvc = configSvc
1224		d.AuthSvc = authSvc
1225		d.ContainerSvc = containerSvc
1226		d.SecretSvc = secretSvc
1227	})
1228
1229	tests := []struct {
1230		name       string
1231		scopes     []string
1232		wantStatus int
1233	}{
1234		{
1235			name:       "status read access granted",
1236			scopes:     []string{"admin:status:read"},
1237			wantStatus: http.StatusOK,
1238		},
1239		{
1240			name:       "all admin access granted",
1241			scopes:     []string{"admin:*:*"},
1242			wantStatus: http.StatusOK,
1243		},
1244		{
1245			name:       "wrong resource denied",
1246			scopes:     []string{"admin:routes:read"},
1247			wantStatus: http.StatusForbidden,
1248		},
1249	}
1250
1251	for _, tt := range tests {
1252		t.Run(tt.name, func(t *testing.T) {
1253			if tt.wantStatus == http.StatusOK {
1254				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
1255				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
1256				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
1257				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
1258				configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
1259				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
1260			}
1261
1262			req := httptest.NewRequest("GET", "/admin/status", nil)
1263			req = req.WithContext(ctxWithScopes(tt.scopes...))
1264			rec := httptest.NewRecorder()
1265
1266			handler.ServeHTTP(rec, req)
1267
1268			assert.Equal(t, tt.wantStatus, rec.Code)
1269		})
1270	}
1271}
1272
1273// Config endpoint tests
1274
1275func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) {
1276	configSvc := inmocks.NewMockConfigService(t)
1277	handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc })
1278
1279	configSvc.EXPECT().GetServerPort().Return(8080).Once()
1280	configSvc.EXPECT().GetRegistryPort().Return(5000).Once()
1281	configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once()
1282	configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Once()
1283	configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Once()
1284	configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Once()
1285	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once()
1286	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{"db.example.com": "http://10.0.0.5:5432"}).Once()
1287
1288	server := newScopedTestServer(t, handler, "admin:config:read")
1289	resp, err := http.Get(server.URL + "/admin/config")
1290	require.NoError(t, err)
1291	defer resp.Body.Close()
1292	bodyBytes, err := io.ReadAll(resp.Body)
1293	require.NoError(t, err)
1294
1295	require.Equal(t, http.StatusOK, resp.StatusCode)
1296	body := string(bodyBytes)
1297	var raw map[string]any
1298	require.NoError(t, json.Unmarshal([]byte(body), &raw))
1299	serverConfig := raw["server"].(map[string]any)
1300	assert.NotContains(t, serverConfig, "data_dir")
1301	assert.NotContains(t, body, "/var/lib/gordon")
1302	assert.NotContains(t, body, "10.0.0.5")
1303}
1304
1305func TestHandler_Config_RequiresReadScope(t *testing.T) {
1306	configSvc := inmocks.NewMockConfigService(t)
1307	authSvc := inmocks.NewMockAuthService(t)
1308	containerSvc := inmocks.NewMockContainerService(t)
1309	secretSvc := inmocks.NewMockSecretService(t)
1310
1311	handler := newTestHandler(t, func(d *HandlerDeps) {
1312		d.ConfigSvc = configSvc
1313		d.AuthSvc = authSvc
1314		d.ContainerSvc = containerSvc
1315		d.SecretSvc = secretSvc
1316	})
1317
1318	tests := []struct {
1319		name       string
1320		scopes     []string
1321		wantStatus int
1322	}{
1323		{
1324			name:       "config read access granted",
1325			scopes:     []string{"admin:config:read"},
1326			wantStatus: http.StatusOK,
1327		},
1328		{
1329			name:       "all admin access granted",
1330			scopes:     []string{"admin:*:*"},
1331			wantStatus: http.StatusOK,
1332		},
1333		{
1334			name:       "wrong resource denied",
1335			scopes:     []string{"admin:routes:read"},
1336			wantStatus: http.StatusForbidden,
1337		},
1338	}
1339
1340	for _, tt := range tests {
1341		t.Run(tt.name, func(t *testing.T) {
1342			if tt.wantStatus == http.StatusOK {
1343				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
1344				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
1345				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
1346				configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
1347				configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
1348				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
1349				configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
1350				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
1351				configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
1352			}
1353
1354			req := httptest.NewRequest("GET", "/admin/config", nil)
1355			req = req.WithContext(ctxWithScopes(tt.scopes...))
1356			rec := httptest.NewRecorder()
1357
1358			handler.ServeHTTP(rec, req)
1359
1360			assert.Equal(t, tt.wantStatus, rec.Code)
1361		})
1362	}
1363}
1364
1365// Reload endpoint tests
1366
1367func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
1368	configSvc := inmocks.NewMockConfigService(t)
1369	authSvc := inmocks.NewMockAuthService(t)
1370	containerSvc := inmocks.NewMockContainerService(t)
1371	secretSvc := inmocks.NewMockSecretService(t)
1372	reloadTrigger := &reloadTriggerRecorder{}
1373
1374	handler := newTestHandler(t, func(d *HandlerDeps) {
1375		d.ConfigSvc = configSvc
1376		d.AuthSvc = authSvc
1377		d.ContainerSvc = containerSvc
1378		d.SecretSvc = secretSvc
1379		d.ReloadTrigger = reloadTrigger
1380	})
1381
1382	tests := []struct {
1383		name       string
1384		scopes     []string
1385		wantStatus int
1386	}{
1387		{
1388			name:       "config write access granted",
1389			scopes:     []string{"admin:config:write"},
1390			wantStatus: http.StatusOK,
1391		},
1392		{
1393			name:       "all admin access granted",
1394			scopes:     []string{"admin:*:*"},
1395			wantStatus: http.StatusOK,
1396		},
1397		{
1398			name:       "only read access denied",
1399			scopes:     []string{"admin:config:read"},
1400			wantStatus: http.StatusForbidden,
1401		},
1402		{
1403			name:       "wrong resource denied",
1404			scopes:     []string{"admin:routes:write"},
1405			wantStatus: http.StatusForbidden,
1406		},
1407	}
1408
1409	for _, tt := range tests {
1410		t.Run(tt.name, func(t *testing.T) {
1411			reloadTrigger.calls = 0
1412
1413			req := httptest.NewRequest("POST", "/admin/reload", nil)
1414			req = req.WithContext(ctxWithScopes(tt.scopes...))
1415			rec := httptest.NewRecorder()
1416
1417			handler.ServeHTTP(rec, req)
1418
1419			assert.Equal(t, tt.wantStatus, rec.Code)
1420			if tt.wantStatus == http.StatusOK {
1421				assert.Equal(t, 1, reloadTrigger.calls)
1422			} else {
1423				assert.Zero(t, reloadTrigger.calls)
1424			}
1425		})
1426	}
1427}
1428
1429func TestHandler_Reload_ReturnsServerErrorOnTriggerFailure(t *testing.T) {
1430	reloadTrigger := &reloadTriggerRecorder{err: errors.New("reload failed")}
1431
1432	handler := newTestHandler(t, func(d *HandlerDeps) {
1433		d.ReloadTrigger = reloadTrigger
1434	})
1435
1436	req := httptest.NewRequest("POST", "/admin/reload", nil)
1437	req = req.WithContext(ctxWithScopes("admin:config:write"))
1438	rec := httptest.NewRecorder()
1439
1440	handler.ServeHTTP(rec, req)
1441
1442	assert.Equal(t, http.StatusInternalServerError, rec.Code)
1443	assert.Equal(t, 1, reloadTrigger.calls)
1444}
1445
1446func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) {
1447	reloadTrigger := &reloadTriggerRecorder{}
1448
1449	handler := newTestHandler(t, func(d *HandlerDeps) {
1450		d.ReloadTrigger = reloadTrigger
1451	})
1452
1453	reqCtx, cancel := context.WithCancel(ctxWithScopes("admin:config:write"))
1454	cancel()
1455
1456	req := httptest.NewRequest("POST", "/admin/reload", nil)
1457	req = req.WithContext(reqCtx)
1458	rec := httptest.NewRecorder()
1459
1460	handler.ServeHTTP(rec, req)
1461
1462	assert.Equal(t, http.StatusOK, rec.Code)
1463	assert.Equal(t, 1, reloadTrigger.calls)
1464	require.NoError(t, reloadTrigger.ctxErrAtCall)
1465	assert.True(t, reloadTrigger.hasDeadline)
1466}
1467
1468// Functional tests
1469
1470func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
1471	configSvc := inmocks.NewMockConfigService(t)
1472	authSvc := inmocks.NewMockAuthService(t)
1473	containerSvc := inmocks.NewMockContainerService(t)
1474	secretSvc := inmocks.NewMockSecretService(t)
1475
1476	handler := newTestHandler(t, func(d *HandlerDeps) {
1477		d.ConfigSvc = configSvc
1478		d.AuthSvc = authSvc
1479		d.ContainerSvc = containerSvc
1480		d.SecretSvc = secretSvc
1481	})
1482
1483	expectedRoutes := []domain.Route{
1484		{Domain: "app1.example.com", Image: "app1:latest"},
1485		{Domain: "app2.example.com", Image: "app2:v1.0"},
1486	}
1487	configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
1488
1489	req := httptest.NewRequest("GET", "/admin/routes", nil)
1490	req = req.WithContext(ctxWithScopes("admin:routes:read"))
1491	rec := httptest.NewRecorder()
1492
1493	handler.ServeHTTP(rec, req)
1494
1495	assert.Equal(t, http.StatusOK, rec.Code)
1496
1497	var response struct {
1498		Routes []struct {
1499			Domain string `json:"domain"`
1500			Image  string `json:"image"`
1501			HTTPS  bool   `json:"https"`
1502		} `json:"routes"`
1503	}
1504	err := json.NewDecoder(rec.Body).Decode(&response)
1505	assert.NoError(t, err)
1506
1507	assert.Len(t, response.Routes, 2)
1508}
1509
1510func TestHandler_RoutesGet_DetailedSyncsAndIncludesConfiguredRouteWithoutContainer(t *testing.T) {
1511	configSvc := inmocks.NewMockConfigService(t)
1512	authSvc := inmocks.NewMockAuthService(t)
1513	containerSvc := inmocks.NewMockContainerService(t)
1514	secretSvc := inmocks.NewMockSecretService(t)
1515
1516	handler := newTestHandler(t, func(d *HandlerDeps) {
1517		d.ConfigSvc = configSvc
1518		d.AuthSvc = authSvc
1519		d.ContainerSvc = containerSvc
1520		d.SecretSvc = secretSvc
1521	})
1522
1523	configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
1524		Domain: "app.example.com",
1525		Image:  "app:latest",
1526	}}).Once()
1527	syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
1528	listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once()
1529	mock.InOrder(syncCall, listCall)
1530
1531	req := httptest.NewRequest("GET", "/admin/routes?detailed=true", nil)
1532	req = req.WithContext(ctxWithScopes("admin:routes:read"))
1533	rec := httptest.NewRecorder()
1534
1535	handler.ServeHTTP(rec, req)
1536
1537	assert.Equal(t, http.StatusOK, rec.Code)
1538
1539	var response dto.RoutesDetailResponse
1540	err := json.NewDecoder(rec.Body).Decode(&response)
1541	require.NoError(t, err)
1542	require.Len(t, response.Routes, 1)
1543	assert.Equal(t, "app.example.com", response.Routes[0].Domain)
1544	assert.Equal(t, "app:latest", response.Routes[0].Image)
1545	assert.Empty(t, response.Routes[0].ContainerID)
1546	assert.Empty(t, response.Routes[0].ContainerStatus)
1547}
1548
1549func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
1550	configSvc := inmocks.NewMockConfigService(t)
1551	authSvc := inmocks.NewMockAuthService(t)
1552	containerSvc := inmocks.NewMockContainerService(t)
1553	secretSvc := inmocks.NewMockSecretService(t)
1554
1555	handler := newTestHandler(t, func(d *HandlerDeps) {
1556		d.ConfigSvc = configSvc
1557		d.AuthSvc = authSvc
1558		d.ContainerSvc = containerSvc
1559		d.SecretSvc = secretSvc
1560	})
1561
1562	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
1563		Domain: "app.example.com", Image: "app:latest",
1564	}, nil)
1565
1566	req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
1567	req = req.WithContext(ctxWithScopes("admin:routes:read"))
1568	rec := httptest.NewRecorder()
1569
1570	handler.ServeHTTP(rec, req)
1571
1572	assert.Equal(t, http.StatusOK, rec.Code)
1573
1574	var route struct {
1575		Domain string `json:"domain"`
1576		Image  string `json:"image"`
1577		HTTPS  bool   `json:"https"`
1578	}
1579	err := json.NewDecoder(rec.Body).Decode(&route)
1580	assert.NoError(t, err)
1581	assert.Equal(t, "app.example.com", route.Domain)
1582	assert.Equal(t, "app:latest", route.Image)
1583}
1584
1585func TestHandler_RoutesGet_NotFound(t *testing.T) {
1586	configSvc := inmocks.NewMockConfigService(t)
1587	authSvc := inmocks.NewMockAuthService(t)
1588	containerSvc := inmocks.NewMockContainerService(t)
1589	secretSvc := inmocks.NewMockSecretService(t)
1590
1591	handler := newTestHandler(t, func(d *HandlerDeps) {
1592		d.ConfigSvc = configSvc
1593		d.AuthSvc = authSvc
1594		d.ContainerSvc = containerSvc
1595		d.SecretSvc = secretSvc
1596	})
1597
1598	configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
1599
1600	req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
1601	req = req.WithContext(ctxWithScopes("admin:routes:read"))
1602	rec := httptest.NewRecorder()
1603
1604	handler.ServeHTTP(rec, req)
1605
1606	assert.Equal(t, http.StatusNotFound, rec.Code)
1607}
1608
1609func TestHandler_RouteCleanupPreview_RequiresRoutesConfigAndVolumesReadScopes(t *testing.T) {
1610	configSvc := inmocks.NewMockConfigService(t)
1611	authSvc := inmocks.NewMockAuthService(t)
1612	volumeSvc := inmocks.NewMockVolumeService(t)
1613	secretSvc := inmocks.NewMockSecretService(t)
1614	containerMock := inmocks.NewMockContainerService(t)
1615	containerSvc := &previewContainerService{
1616		MockContainerService: containerMock,
1617		preview: func(_ context.Context, routeDomain string) (*domain.CleanupReport, error) {
1618			return &domain.CleanupReport{
1619				Domain:           routeDomain,
1620				OrphanedEntities: []domain.CleanupOrphanedEntity{{Kind: "route_container", ID: "ctr-1", Status: "running"}},
1621			}, nil
1622		},
1623	}
1624
1625	handler := newTestHandler(t, func(d *HandlerDeps) {
1626		d.ConfigSvc = configSvc
1627		d.AuthSvc = authSvc
1628		d.ContainerSvc = containerSvc
1629		d.VolumeSvc = volumeSvc
1630		d.SecretSvc = secretSvc
1631	})
1632
1633	volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{{Name: "gordon-app-example-com-data"}}, nil).Once()
1634
1635	req := httptest.NewRequest("GET", "/admin/routes/app.example.com/cleanup", nil)
1636	req = req.WithContext(ctxWithScopes("admin:routes:read", "admin:config:read", "admin:volumes:read"))
1637	rec := httptest.NewRecorder()
1638
1639	handler.ServeHTTP(rec, req)
1640
1641	assert.Equal(t, http.StatusOK, rec.Code)
1642	var report dto.CleanupReport
1643	require.NoError(t, json.NewDecoder(rec.Body).Decode(&report))
1644	require.Len(t, report.OrphanedEntities, 1)
1645	assert.Equal(t, "route_container", report.OrphanedEntities[0].Kind)
1646	require.Len(t, report.PreservedVolumes, 1)
1647	assert.Equal(t, "gordon-app-example-com-data", report.PreservedVolumes[0].Name)
1648
1649	for _, scopes := range [][]string{
1650		{"admin:routes:read", "admin:volumes:read"},
1651		{"admin:routes:read", "admin:config:read"},
1652	} {
1653		req := httptest.NewRequest("GET", "/admin/routes/app.example.com/cleanup", nil)
1654		req = req.WithContext(ctxWithScopes(scopes...))
1655		rec := httptest.NewRecorder()
1656		handler.ServeHTTP(rec, req)
1657		assert.Equal(t, http.StatusForbidden, rec.Code)
1658	}
1659}
1660
1661func TestHandler_RoutesPost_MissingFields(t *testing.T) {
1662	tests := []struct {
1663		name    string
1664		body    string
1665		mockErr error
1666	}{
1667		{
1668			name:    "missing domain",
1669			body:    `{"image": "app:latest"}`,
1670			mockErr: domain.ErrRouteDomainEmpty,
1671		},
1672		{
1673			name:    "missing image",
1674			body:    `{"domain": "app.example.com"}`,
1675			mockErr: domain.ErrRouteImageEmpty,
1676		},
1677		{
1678			name:    "empty object",
1679			body:    `{}`,
1680			mockErr: domain.ErrRouteDomainEmpty,
1681		},
1682	}
1683
1684	for _, tt := range tests {
1685		t.Run(tt.name, func(t *testing.T) {
1686			configSvc := inmocks.NewMockConfigService(t)
1687			authSvc := inmocks.NewMockAuthService(t)
1688			containerSvc := inmocks.NewMockContainerService(t)
1689			secretSvc := inmocks.NewMockSecretService(t)
1690			registrySvc := inmocks.NewMockRegistryService(t)
1691
1692			handler := newTestHandler(t, func(d *HandlerDeps) {
1693				d.ConfigSvc = configSvc
1694				d.AuthSvc = authSvc
1695				d.ContainerSvc = containerSvc
1696				d.SecretSvc = secretSvc
1697				d.RegistrySvc = registrySvc
1698			})
1699
1700			configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
1701
1702			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
1703			req = req.WithContext(ctxWithScopes("admin:routes:write"))
1704			rec := httptest.NewRecorder()
1705
1706			handler.ServeHTTP(rec, req)
1707
1708			assert.Equal(t, http.StatusBadRequest, rec.Code)
1709		})
1710	}
1711}
1712
1713func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
1714	configSvc := inmocks.NewMockConfigService(t)
1715	authSvc := inmocks.NewMockAuthService(t)
1716	containerSvc := inmocks.NewMockContainerService(t)
1717	secretSvc := inmocks.NewMockSecretService(t)
1718
1719	handler := newTestHandler(t, func(d *HandlerDeps) {
1720		d.ConfigSvc = configSvc
1721		d.AuthSvc = authSvc
1722		d.ContainerSvc = containerSvc
1723		d.SecretSvc = secretSvc
1724	})
1725
1726	req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
1727	req = req.WithContext(ctxWithScopes("admin:routes:write"))
1728	rec := httptest.NewRecorder()
1729
1730	handler.ServeHTTP(rec, req)
1731
1732	assert.Equal(t, http.StatusBadRequest, rec.Code)
1733}
1734
1735func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
1736	configSvc := inmocks.NewMockConfigService(t)
1737	authSvc := inmocks.NewMockAuthService(t)
1738	containerSvc := inmocks.NewMockContainerService(t)
1739	secretSvc := inmocks.NewMockSecretService(t)
1740
1741	handler := newTestHandler(t, func(d *HandlerDeps) {
1742		d.ConfigSvc = configSvc
1743		d.AuthSvc = authSvc
1744		d.ContainerSvc = containerSvc
1745		d.SecretSvc = secretSvc
1746	})
1747
1748	req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
1749	req = req.WithContext(ctxWithScopes("admin:routes:write"))
1750	rec := httptest.NewRecorder()
1751
1752	handler.ServeHTTP(rec, req)
1753
1754	assert.Equal(t, http.StatusBadRequest, rec.Code)
1755}
1756
1757func TestHandler_Secrets_MissingDomain(t *testing.T) {
1758	configSvc := inmocks.NewMockConfigService(t)
1759	authSvc := inmocks.NewMockAuthService(t)
1760	containerSvc := inmocks.NewMockContainerService(t)
1761	secretSvc := inmocks.NewMockSecretService(t)
1762
1763	handler := newTestHandler(t, func(d *HandlerDeps) {
1764		d.ConfigSvc = configSvc
1765		d.AuthSvc = authSvc
1766		d.ContainerSvc = containerSvc
1767		d.SecretSvc = secretSvc
1768	})
1769
1770	req := httptest.NewRequest("GET", "/admin/secrets/", nil)
1771	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1772	rec := httptest.NewRecorder()
1773
1774	handler.ServeHTTP(rec, req)
1775
1776	assert.Equal(t, http.StatusBadRequest, rec.Code)
1777}
1778
1779func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
1780	configSvc := inmocks.NewMockConfigService(t)
1781	authSvc := inmocks.NewMockAuthService(t)
1782	containerSvc := inmocks.NewMockContainerService(t)
1783	secretSvc := inmocks.NewMockSecretService(t)
1784
1785	handler := newTestHandler(t, func(d *HandlerDeps) {
1786		d.ConfigSvc = configSvc
1787		d.AuthSvc = authSvc
1788		d.ContainerSvc = containerSvc
1789		d.SecretSvc = secretSvc
1790	})
1791
1792	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
1793	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1794	rec := httptest.NewRecorder()
1795
1796	handler.ServeHTTP(rec, req)
1797
1798	assert.Equal(t, http.StatusBadRequest, rec.Code)
1799}
1800
1801func TestHandler_MethodNotAllowed(t *testing.T) {
1802	configSvc := inmocks.NewMockConfigService(t)
1803	authSvc := inmocks.NewMockAuthService(t)
1804	containerSvc := inmocks.NewMockContainerService(t)
1805	secretSvc := inmocks.NewMockSecretService(t)
1806
1807	handler := newTestHandler(t, func(d *HandlerDeps) {
1808		d.ConfigSvc = configSvc
1809		d.AuthSvc = authSvc
1810		d.ContainerSvc = containerSvc
1811		d.SecretSvc = secretSvc
1812	})
1813
1814	tests := []struct {
1815		method string
1816		path   string
1817	}{
1818		{"DELETE", "/admin/status"},
1819		{"PUT", "/admin/status"},
1820		{"GET", "/admin/reload"},
1821		{"DELETE", "/admin/reload"},
1822		{"POST", "/admin/config"},
1823		{"DELETE", "/admin/config"},
1824	}
1825
1826	for _, tt := range tests {
1827		t.Run(tt.method+" "+tt.path, func(t *testing.T) {
1828			req := httptest.NewRequest(tt.method, tt.path, nil)
1829			req = req.WithContext(ctxWithScopes("admin:*:*"))
1830			rec := httptest.NewRecorder()
1831
1832			handler.ServeHTTP(rec, req)
1833
1834			assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
1835		})
1836	}
1837}
1838
1839func TestHandler_Restart_InvalidDomain(t *testing.T) {
1840	configSvc := inmocks.NewMockConfigService(t)
1841	authSvc := inmocks.NewMockAuthService(t)
1842	containerSvc := inmocks.NewMockContainerService(t)
1843	secretSvc := inmocks.NewMockSecretService(t)
1844
1845	handler := newTestHandler(t, func(d *HandlerDeps) {
1846		d.ConfigSvc = configSvc
1847		d.AuthSvc = authSvc
1848		d.ContainerSvc = containerSvc
1849		d.SecretSvc = secretSvc
1850	})
1851
1852	tests := []struct {
1853		name   string
1854		path   string
1855		status int
1856	}{
1857		{"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest},
1858		{"empty domain", "/admin/restart/", http.StatusBadRequest},
1859	}
1860
1861	for _, tt := range tests {
1862		t.Run(tt.name, func(t *testing.T) {
1863			req := httptest.NewRequest("POST", tt.path, nil)
1864			req = req.WithContext(ctxWithScopes("admin:config:write"))
1865			rec := httptest.NewRecorder()
1866
1867			handler.ServeHTTP(rec, req)
1868
1869			assert.Equal(t, tt.status, rec.Code)
1870		})
1871	}
1872}
1873
1874func TestHandler_Deploy_ConfigWriteOnlyOmitsDeployFailureLogs(t *testing.T) {
1875	configSvc := inmocks.NewMockConfigService(t)
1876	containerSvc := inmocks.NewMockContainerService(t)
1877	secretSvc := inmocks.NewMockSecretService(t)
1878	route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1879	deployErr := &domain.DeployFailureError{
1880		Summary: "failed to deploy",
1881		Cause:   "image pull failed",
1882		Hint:    "push the image before retrying",
1883		Logs:    []string{"PASSWORD=hunter2", "TOKEN=abc123"},
1884	}
1885
1886	handler := newTestHandler(t, func(d *HandlerDeps) {
1887		d.ConfigSvc = configSvc
1888		d.ContainerSvc = containerSvc
1889		d.SecretSvc = secretSvc
1890	})
1891
1892	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1893	containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1894
1895	server := newScopedTestServer(t, handler, "admin:config:write")
1896	resp, err := http.Post(server.URL+"/admin/deploy/app.example.com", "application/json", nil)
1897	require.NoError(t, err)
1898	defer resp.Body.Close()
1899	bodyBytes, err := io.ReadAll(resp.Body)
1900	require.NoError(t, err)
1901	body := string(bodyBytes)
1902
1903	assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1904	assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying"}`, body)
1905	assert.NotContains(t, body, "hunter2")
1906	assert.NotContains(t, body, "abc123")
1907}
1908
1909func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) {
1910	configSvc := inmocks.NewMockConfigService(t)
1911	authSvc := inmocks.NewMockAuthService(t)
1912	containerSvc := inmocks.NewMockContainerService(t)
1913	secretSvc := inmocks.NewMockSecretService(t)
1914	route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1915	deployErr := &domain.DeployFailureError{
1916		Summary: "failed to deploy",
1917		Cause:   "image pull failed",
1918		Hint:    "push the image before retrying",
1919		Logs: []string{
1920			"pulling manifest PASSWORD=hunter2",
1921			`{"TOKEN":"abc123"}`,
1922		},
1923		Err: errors.New("manifest unknown: internal registry detail"),
1924	}
1925
1926	handler := newTestHandler(t, func(d *HandlerDeps) {
1927		d.ConfigSvc = configSvc
1928		d.AuthSvc = authSvc
1929		d.ContainerSvc = containerSvc
1930		d.SecretSvc = secretSvc
1931	})
1932	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1933		handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write", "admin:logs:read")))
1934	}))
1935	defer s.Close()
1936
1937	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1938	containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1939
1940	req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil)
1941	assert.NoError(t, err)
1942	resp, err := s.Client().Do(req)
1943	assert.NoError(t, err)
1944	defer resp.Body.Close()
1945	body, err := io.ReadAll(resp.Body)
1946	assert.NoError(t, err)
1947
1948	assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1949	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))
1950	assert.NotContains(t, string(body), "internal registry detail")
1951	assert.NotContains(t, string(body), "hunter2")
1952	assert.NotContains(t, string(body), "abc123")
1953}
1954
1955func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) {
1956	configSvc := inmocks.NewMockConfigService(t)
1957	authSvc := inmocks.NewMockAuthService(t)
1958	containerSvc := inmocks.NewMockContainerService(t)
1959	secretSvc := inmocks.NewMockSecretService(t)
1960	route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"}
1961	deployErr := errors.New("manifest unknown: internal registry detail")
1962
1963	handler := newTestHandler(t, func(d *HandlerDeps) {
1964		d.ConfigSvc = configSvc
1965		d.AuthSvc = authSvc
1966		d.ContainerSvc = containerSvc
1967		d.SecretSvc = secretSvc
1968	})
1969	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1970		handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write")))
1971	}))
1972	defer s.Close()
1973
1974	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once()
1975	containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once()
1976
1977	req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil)
1978	assert.NoError(t, err)
1979	resp, err := s.Client().Do(req)
1980	assert.NoError(t, err)
1981	defer resp.Body.Close()
1982	body, err := io.ReadAll(resp.Body)
1983	assert.NoError(t, err)
1984
1985	assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
1986	assert.JSONEq(t, `{"error":"failed to deploy container"}`, string(body))
1987	assert.NotContains(t, string(body), "internal registry detail")
1988}
1989
1990func TestHandler_Restart_ContainerNotFound(t *testing.T) {
1991	configSvc := inmocks.NewMockConfigService(t)
1992	authSvc := inmocks.NewMockAuthService(t)
1993	containerSvc := inmocks.NewMockContainerService(t)
1994	secretSvc := inmocks.NewMockSecretService(t)
1995
1996	handler := newTestHandler(t, func(d *HandlerDeps) {
1997		d.ConfigSvc = configSvc
1998		d.AuthSvc = authSvc
1999		d.ContainerSvc = containerSvc
2000		d.SecretSvc = secretSvc
2001	})
2002
2003	containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
2004
2005	req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil)
2006	req = req.WithContext(ctxWithScopes("admin:config:write"))
2007	rec := httptest.NewRecorder()
2008
2009	handler.ServeHTTP(rec, req)
2010
2011	assert.Equal(t, http.StatusNotFound, rec.Code)
2012}
2013
2014func TestHandler_Restart_GenericError(t *testing.T) {
2015	configSvc := inmocks.NewMockConfigService(t)
2016	authSvc := inmocks.NewMockAuthService(t)
2017	containerSvc := inmocks.NewMockContainerService(t)
2018	secretSvc := inmocks.NewMockSecretService(t)
2019
2020	handler := newTestHandler(t, func(d *HandlerDeps) {
2021		d.ConfigSvc = configSvc
2022		d.AuthSvc = authSvc
2023		d.ContainerSvc = containerSvc
2024		d.SecretSvc = secretSvc
2025	})
2026
2027	containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
2028
2029	req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil)
2030	req = req.WithContext(ctxWithScopes("admin:config:write"))
2031	rec := httptest.NewRecorder()
2032
2033	handler.ServeHTTP(rec, req)
2034
2035	assert.Equal(t, http.StatusInternalServerError, rec.Code)
2036	// Verify internal error details are NOT leaked
2037	assert.NotContains(t, rec.Body.String(), "docker daemon error")
2038	assert.NotContains(t, rec.Body.String(), "secret details")
2039}
2040
2041func TestHandler_Restart_Success(t *testing.T) {
2042	configSvc := inmocks.NewMockConfigService(t)
2043	authSvc := inmocks.NewMockAuthService(t)
2044	containerSvc := inmocks.NewMockContainerService(t)
2045	secretSvc := inmocks.NewMockSecretService(t)
2046
2047	handler := newTestHandler(t, func(d *HandlerDeps) {
2048		d.ConfigSvc = configSvc
2049		d.AuthSvc = authSvc
2050		d.ContainerSvc = containerSvc
2051		d.SecretSvc = secretSvc
2052	})
2053
2054	tests := []struct {
2055		name            string
2056		path            string
2057		withAttachments bool
2058	}{
2059		{
2060			name:            "without attachments",
2061			path:            "/admin/restart/test.example.com",
2062			withAttachments: false,
2063		},
2064		{
2065			name:            "with attachments",
2066			path:            "/admin/restart/test.example.com?attachments=true",
2067			withAttachments: true,
2068		},
2069	}
2070
2071	for _, tt := range tests {
2072		t.Run(tt.name, func(t *testing.T) {
2073			containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
2074
2075			req := httptest.NewRequest("POST", tt.path, nil)
2076			req = req.WithContext(ctxWithScopes("admin:config:write"))
2077			rec := httptest.NewRecorder()
2078
2079			handler.ServeHTTP(rec, req)
2080
2081			assert.Equal(t, http.StatusOK, rec.Code)
2082			var response struct {
2083				Status string `json:"status"`
2084				Domain string `json:"domain"`
2085			}
2086			assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
2087			assert.Equal(t, "restarted", response.Status)
2088			assert.Equal(t, "test.example.com", response.Domain)
2089		})
2090	}
2091}
2092
2093func TestHandler_Tags_InvalidRepository(t *testing.T) {
2094	configSvc := inmocks.NewMockConfigService(t)
2095	authSvc := inmocks.NewMockAuthService(t)
2096	containerSvc := inmocks.NewMockContainerService(t)
2097	secretSvc := inmocks.NewMockSecretService(t)
2098
2099	handler := newTestHandler(t, func(d *HandlerDeps) {
2100		d.ConfigSvc = configSvc
2101		d.AuthSvc = authSvc
2102		d.ContainerSvc = containerSvc
2103		d.SecretSvc = secretSvc
2104	})
2105
2106	tests := []struct {
2107		name   string
2108		path   string
2109		status int
2110	}{
2111		{"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
2112		{"empty repo", "/admin/tags/", http.StatusBadRequest},
2113	}
2114
2115	for _, tt := range tests {
2116		t.Run(tt.name, func(t *testing.T) {
2117			req := httptest.NewRequest("GET", tt.path, nil)
2118			req = req.WithContext(ctxWithScopes("admin:status:read"))
2119			rec := httptest.NewRecorder()
2120
2121			handler.ServeHTTP(rec, req)
2122
2123			assert.Equal(t, tt.status, rec.Code)
2124		})
2125	}
2126}
2127
2128func TestHandler_Tags_Success(t *testing.T) {
2129	configSvc := inmocks.NewMockConfigService(t)
2130	authSvc := inmocks.NewMockAuthService(t)
2131	containerSvc := inmocks.NewMockContainerService(t)
2132	secretSvc := inmocks.NewMockSecretService(t)
2133	registrySvc := inmocks.NewMockRegistryService(t)
2134
2135	handler := newTestHandler(t, func(d *HandlerDeps) {
2136		d.ConfigSvc = configSvc
2137		d.AuthSvc = authSvc
2138		d.ContainerSvc = containerSvc
2139		d.SecretSvc = secretSvc
2140		d.RegistrySvc = registrySvc
2141	})
2142
2143	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
2144
2145	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
2146	req = req.WithContext(ctxWithScopes("admin:status:read"))
2147	rec := httptest.NewRecorder()
2148
2149	handler.ServeHTTP(rec, req)
2150
2151	assert.Equal(t, http.StatusOK, rec.Code)
2152	var response struct {
2153		Repository string   `json:"repository"`
2154		Tags       []string `json:"tags"`
2155	}
2156	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
2157	assert.Equal(t, "myapp", response.Repository)
2158	assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
2159}
2160
2161func TestHandler_Tags_Error(t *testing.T) {
2162	configSvc := inmocks.NewMockConfigService(t)
2163	authSvc := inmocks.NewMockAuthService(t)
2164	containerSvc := inmocks.NewMockContainerService(t)
2165	secretSvc := inmocks.NewMockSecretService(t)
2166	registrySvc := inmocks.NewMockRegistryService(t)
2167
2168	handler := newTestHandler(t, func(d *HandlerDeps) {
2169		d.ConfigSvc = configSvc
2170		d.AuthSvc = authSvc
2171		d.ContainerSvc = containerSvc
2172		d.SecretSvc = secretSvc
2173		d.RegistrySvc = registrySvc
2174	})
2175
2176	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
2177
2178	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
2179	req = req.WithContext(ctxWithScopes("admin:status:read"))
2180	rec := httptest.NewRecorder()
2181
2182	handler.ServeHTTP(rec, req)
2183
2184	assert.Equal(t, http.StatusInternalServerError, rec.Code)
2185	assert.NotContains(t, rec.Body.String(), "registry error")
2186}
2187
2188func TestHandler_Tags_URLDecodedRepository(t *testing.T) {
2189	configSvc := inmocks.NewMockConfigService(t)
2190	authSvc := inmocks.NewMockAuthService(t)
2191	containerSvc := inmocks.NewMockContainerService(t)
2192	secretSvc := inmocks.NewMockSecretService(t)
2193	registrySvc := inmocks.NewMockRegistryService(t)
2194
2195	handler := newTestHandler(t, func(d *HandlerDeps) {
2196		d.ConfigSvc = configSvc
2197		d.AuthSvc = authSvc
2198		d.ContainerSvc = containerSvc
2199		d.SecretSvc = secretSvc
2200		d.RegistrySvc = registrySvc
2201	})
2202
2203	registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"latest"}, nil)
2204
2205	req := httptest.NewRequest("GET", "/admin/tags/repo%2Fapp", nil)
2206	req = req.WithContext(ctxWithScopes("admin:status:read"))
2207	rec := httptest.NewRecorder()
2208
2209	handler.ServeHTTP(rec, req)
2210
2211	assert.Equal(t, http.StatusOK, rec.Code)
2212	assert.JSONEq(t, `{"repository":"repo/app","tags":["latest"]}`, rec.Body.String())
2213}
2214
2215func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
2216	configSvc := inmocks.NewMockConfigService(t)
2217	authSvc := inmocks.NewMockAuthService(t)
2218	containerSvc := inmocks.NewMockContainerService(t)
2219	secretSvc := inmocks.NewMockSecretService(t)
2220
2221	handler := newTestHandler(t, func(d *HandlerDeps) {
2222		d.ConfigSvc = configSvc
2223		d.AuthSvc = authSvc
2224		d.ContainerSvc = containerSvc
2225		d.SecretSvc = secretSvc
2226	})
2227
2228	body := `{"REDIS_URL": "redis://localhost:6379"}`
2229	req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
2230	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
2231	rec := httptest.NewRecorder()
2232
2233	handler.ServeHTTP(rec, req)
2234
2235	assert.Equal(t, http.StatusForbidden, rec.Code)
2236}
2237
2238func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
2239	configSvc := inmocks.NewMockConfigService(t)
2240	authSvc := inmocks.NewMockAuthService(t)
2241	containerSvc := inmocks.NewMockContainerService(t)
2242	secretSvc := inmocks.NewMockSecretService(t)
2243
2244	handler := newTestHandler(t, func(d *HandlerDeps) {
2245		d.ConfigSvc = configSvc
2246		d.AuthSvc = authSvc
2247		d.ContainerSvc = containerSvc
2248		d.SecretSvc = secretSvc
2249	})
2250
2251	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
2252	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
2253	rec := httptest.NewRecorder()
2254
2255	handler.ServeHTTP(rec, req)
2256
2257	assert.Equal(t, http.StatusForbidden, rec.Code)
2258}
2259
2260func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
2261	configSvc := inmocks.NewMockConfigService(t)
2262	authSvc := inmocks.NewMockAuthService(t)
2263	containerSvc := inmocks.NewMockContainerService(t)
2264	secretSvc := inmocks.NewMockSecretService(t)
2265
2266	handler := newTestHandler(t, func(d *HandlerDeps) {
2267		d.ConfigSvc = configSvc
2268		d.AuthSvc = authSvc
2269		d.ContainerSvc = containerSvc
2270		d.SecretSvc = secretSvc
2271	})
2272
2273	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
2274	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
2275	rec := httptest.NewRecorder()
2276
2277	handler.ServeHTTP(rec, req)
2278
2279	assert.Equal(t, http.StatusBadRequest, rec.Code)
2280	assert.Contains(t, rec.Body.String(), "invalid attachment path")
2281}
2282
2283func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
2284	configSvc := inmocks.NewMockConfigService(t)
2285	authSvc := inmocks.NewMockAuthService(t)
2286	containerSvc := inmocks.NewMockContainerService(t)
2287	secretSvc := inmocks.NewMockSecretService(t)
2288
2289	handler := newTestHandler(t, func(d *HandlerDeps) {
2290		d.ConfigSvc = configSvc
2291		d.AuthSvc = authSvc
2292		d.ContainerSvc = containerSvc
2293		d.SecretSvc = secretSvc
2294	})
2295
2296	body := `{not valid json`
2297	req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
2298	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
2299	rec := httptest.NewRecorder()
2300
2301	handler.ServeHTTP(rec, req)
2302
2303	assert.Equal(t, http.StatusBadRequest, rec.Code)
2304	assert.Contains(t, rec.Body.String(), "invalid JSON")
2305}
2306
2307func TestHandler_NotFound(t *testing.T) {
2308	configSvc := inmocks.NewMockConfigService(t)
2309	authSvc := inmocks.NewMockAuthService(t)
2310	containerSvc := inmocks.NewMockContainerService(t)
2311	secretSvc := inmocks.NewMockSecretService(t)
2312
2313	handler := newTestHandler(t, func(d *HandlerDeps) {
2314		d.ConfigSvc = configSvc
2315		d.AuthSvc = authSvc
2316		d.ContainerSvc = containerSvc
2317		d.SecretSvc = secretSvc
2318	})
2319
2320	req := httptest.NewRequest("GET", "/admin/unknown", nil)
2321	req = req.WithContext(ctxWithScopes("admin:*:*"))
2322	rec := httptest.NewRecorder()
2323
2324	handler.ServeHTTP(rec, req)
2325
2326	assert.Equal(t, http.StatusNotFound, rec.Code)
2327}
2328
2329func TestHandler_BackupsStatus(t *testing.T) {
2330	configSvc := inmocks.NewMockConfigService(t)
2331	authSvc := inmocks.NewMockAuthService(t)
2332	containerSvc := inmocks.NewMockContainerService(t)
2333	secretSvc := inmocks.NewMockSecretService(t)
2334	backupSvc := inmocks.NewMockBackupService(t)
2335
2336	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)
2337
2338	handler := newTestHandler(t, func(d *HandlerDeps) {
2339		d.ConfigSvc = configSvc
2340		d.AuthSvc = authSvc
2341		d.ContainerSvc = containerSvc
2342		d.SecretSvc = secretSvc
2343		d.BackupSvc = backupSvc
2344	})
2345
2346	req := httptest.NewRequest("GET", "/admin/backups/status", nil)
2347	req = req.WithContext(ctxWithScopes("admin:status:read"))
2348	rec := httptest.NewRecorder()
2349
2350	handler.ServeHTTP(rec, req)
2351
2352	assert.Equal(t, http.StatusOK, rec.Code)
2353	assert.Contains(t, rec.Body.String(), "app.example.com")
2354	assert.NotContains(t, rec.Body.String(), "file_path")
2355}
2356
2357func TestHandler_BackupsListDomain(t *testing.T) {
2358	configSvc := inmocks.NewMockConfigService(t)
2359	authSvc := inmocks.NewMockAuthService(t)
2360	containerSvc := inmocks.NewMockContainerService(t)
2361	secretSvc := inmocks.NewMockSecretService(t)
2362	backupSvc := inmocks.NewMockBackupService(t)
2363
2364	backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
2365
2366	handler := newTestHandler(t, func(d *HandlerDeps) {
2367		d.ConfigSvc = configSvc
2368		d.AuthSvc = authSvc
2369		d.ContainerSvc = containerSvc
2370		d.SecretSvc = secretSvc
2371		d.BackupSvc = backupSvc
2372	})
2373
2374	req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)
2375	req = req.WithContext(ctxWithScopes("admin:status:read"))
2376	rec := httptest.NewRecorder()
2377
2378	handler.ServeHTTP(rec, req)
2379
2380	assert.Equal(t, http.StatusOK, rec.Code)
2381	assert.Contains(t, rec.Body.String(), "app.example.com")
2382}
2383
2384func TestHandler_BackupsRunDomain(t *testing.T) {
2385	configSvc := inmocks.NewMockConfigService(t)
2386	authSvc := inmocks.NewMockAuthService(t)
2387	containerSvc := inmocks.NewMockContainerService(t)
2388	secretSvc := inmocks.NewMockSecretService(t)
2389	backupSvc := inmocks.NewMockBackupService(t)
2390
2391	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)
2392
2393	handler := newTestHandler(t, func(d *HandlerDeps) {
2394		d.ConfigSvc = configSvc
2395		d.AuthSvc = authSvc
2396		d.ContainerSvc = containerSvc
2397		d.SecretSvc = secretSvc
2398		d.BackupSvc = backupSvc
2399	})
2400
2401	body := bytes.NewBufferString(`{"db":"postgres"}`)
2402	req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
2403	req = req.WithContext(ctxWithScopes("admin:config:write"))
2404	rec := httptest.NewRecorder()
2405
2406	handler.ServeHTTP(rec, req)
2407
2408	assert.Equal(t, http.StatusOK, rec.Code)
2409	assert.Contains(t, rec.Body.String(), "completed")
2410}
2411
2412func TestHandler_VolumeBackupsRunDomain_ReturnsPartialResults(t *testing.T) {
2413	volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2414	runErr := errors.New("one volume failed")
2415	jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
2416	volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(jobs, runErr)
2417
2418	handler := newTestHandler(t, func(d *HandlerDeps) {
2419		d.VolumeBackupSvc = volumeBackupSvc
2420	})
2421
2422	body := bytes.NewBufferString(`{"volume":"gordon-app-data"}`)
2423	req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", body)
2424	req = req.WithContext(ctxWithScopes("admin:config:write"))
2425	rec := httptest.NewRecorder()
2426
2427	handler.ServeHTTP(rec, req)
2428
2429	require.Equal(t, http.StatusPartialContent, rec.Code)
2430	var result dto.VolumeBackupRunResponse
2431	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &result))
2432	assert.Equal(t, "partial", result.Status)
2433	assert.Equal(t, runErr.Error(), result.Error)
2434	require.Len(t, result.Backups, 1)
2435	assert.Equal(t, "v1", result.Backups[0].ID)
2436}
2437
2438func TestHandler_VolumeBackupsRunDomain_ReturnsServerErrorWithoutResults(t *testing.T) {
2439	volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2440	volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(nil, errors.New("runtime unavailable"))
2441
2442	handler := newTestHandler(t, func(d *HandlerDeps) {
2443		d.VolumeBackupSvc = volumeBackupSvc
2444	})
2445
2446	req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", bytes.NewBufferString(`{}`))
2447	req = req.WithContext(ctxWithScopes("admin:config:write"))
2448	rec := httptest.NewRecorder()
2449
2450	handler.ServeHTTP(rec, req)
2451
2452	assert.Equal(t, http.StatusInternalServerError, rec.Code)
2453	assert.Contains(t, rec.Body.String(), "failed to run volume backups")
2454}
2455
2456func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
2457	configSvc := inmocks.NewMockConfigService(t)
2458	authSvc := inmocks.NewMockAuthService(t)
2459	containerSvc := inmocks.NewMockContainerService(t)
2460	secretSvc := inmocks.NewMockSecretService(t)
2461	backupSvc := inmocks.NewMockBackupService(t)
2462
2463	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)
2464
2465	handler := newTestHandler(t, func(d *HandlerDeps) {
2466		d.ConfigSvc = configSvc
2467		d.AuthSvc = authSvc
2468		d.ContainerSvc = containerSvc
2469		d.SecretSvc = secretSvc
2470		d.BackupSvc = backupSvc
2471	})
2472
2473	body := bytes.NewBufferString(`{"db":"postgres"}`)
2474	req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
2475	req.ContentLength = -1
2476	req = req.WithContext(ctxWithScopes("admin:config:write"))
2477	rec := httptest.NewRecorder()
2478
2479	handler.ServeHTTP(rec, req)
2480
2481	assert.Equal(t, http.StatusOK, rec.Code)
2482	assert.Contains(t, rec.Body.String(), "completed")
2483}
2484
2485func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) {
2486	logSvc := inmocks.NewMockLogService(t)
2487	handler := newTestHandler(t, func(d *HandlerDeps) {
2488		d.LogSvc = logSvc
2489	})
2490
2491	t.Run("status read denied", func(t *testing.T) {
2492		server := newScopedTestServer(t, handler, "admin:status:read")
2493		resp, err := http.Get(server.URL + "/admin/logs")
2494		require.NoError(t, err)
2495		defer resp.Body.Close()
2496		bodyBytes, err := io.ReadAll(resp.Body)
2497		require.NoError(t, err)
2498
2499		assert.Equal(t, http.StatusForbidden, resp.StatusCode)
2500		assert.Contains(t, string(bodyBytes), "insufficient permissions for logs:read")
2501	})
2502
2503	t.Run("logs read allowed and redacted", func(t *testing.T) {
2504		logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once()
2505
2506		server := newScopedTestServer(t, handler, "admin:logs:read")
2507		resp, err := http.Get(server.URL + "/admin/logs")
2508		require.NoError(t, err)
2509		defer resp.Body.Close()
2510		bodyBytes, err := io.ReadAll(resp.Body)
2511		require.NoError(t, err)
2512		body := string(bodyBytes)
2513
2514		assert.Equal(t, http.StatusOK, resp.StatusCode)
2515		assert.Contains(t, body, "PASSWORD=[REDACTED]")
2516		assert.Contains(t, body, `\"API_KEY\":\"[REDACTED]\"`)
2517		assert.NotContains(t, body, "hunter2")
2518		assert.NotContains(t, body, "abc123")
2519	})
2520}
2521
2522func TestHandler_BackupsDetectDomain(t *testing.T) {
2523	configSvc := inmocks.NewMockConfigService(t)
2524	authSvc := inmocks.NewMockAuthService(t)
2525	containerSvc := inmocks.NewMockContainerService(t)
2526	secretSvc := inmocks.NewMockSecretService(t)
2527	backupSvc := inmocks.NewMockBackupService(t)
2528
2529	backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
2530
2531	handler := newTestHandler(t, func(d *HandlerDeps) {
2532		d.ConfigSvc = configSvc
2533		d.AuthSvc = authSvc
2534		d.ContainerSvc = containerSvc
2535		d.SecretSvc = secretSvc
2536		d.BackupSvc = backupSvc
2537	})
2538
2539	req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
2540	req = req.WithContext(ctxWithScopes("admin:status:read"))
2541	rec := httptest.NewRecorder()
2542
2543	handler.ServeHTTP(rec, req)
2544
2545	assert.Equal(t, http.StatusOK, rec.Code)
2546	assert.Contains(t, rec.Body.String(), "postgres")
2547}
2548
2549func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
2550	configSvc := inmocks.NewMockConfigService(t)
2551	authSvc := inmocks.NewMockAuthService(t)
2552	containerSvc := inmocks.NewMockContainerService(t)
2553	secretSvc := inmocks.NewMockSecretService(t)
2554
2555	createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
2556	imageSvc := &stubImageService{
2557		listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
2558			return []domain.ImageInfo{
2559				{
2560					Repository: "myapp",
2561					Tag:        "latest",
2562					Size:       1024,
2563					Created:    createdAt,
2564					ID:         "sha256:abc123",
2565					Dangling:   false,
2566				},
2567				{
2568					Repository: "",
2569					Tag:        "",
2570					Size:       512,
2571					Created:    createdAt,
2572					ID:         "sha256:def456",
2573					Dangling:   true,
2574				},
2575			}, nil
2576		},
2577	}
2578
2579	handler := newTestHandler(t, func(d *HandlerDeps) {
2580		d.ConfigSvc = configSvc
2581		d.AuthSvc = authSvc
2582		d.ContainerSvc = containerSvc
2583		d.SecretSvc = secretSvc
2584		d.ImageSvc = imageSvc
2585	})
2586
2587	req := httptest.NewRequest("GET", "/admin/images", nil)
2588	req = req.WithContext(ctxWithScopes("admin:status:read"))
2589	rec := httptest.NewRecorder()
2590
2591	handler.ServeHTTP(rec, req)
2592
2593	assert.Equal(t, http.StatusOK, rec.Code)
2594
2595	var response struct {
2596		Images []struct {
2597			Repository string    `json:"repository"`
2598			Tag        string    `json:"tag"`
2599			Size       int64     `json:"size"`
2600			Created    time.Time `json:"created"`
2601			ID         string    `json:"id"`
2602			Dangling   bool      `json:"dangling"`
2603		} `json:"images"`
2604	}
2605
2606	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
2607	assert.Len(t, response.Images, 2)
2608	assert.Equal(t, "myapp", response.Images[0].Repository)
2609	assert.Equal(t, "latest", response.Images[0].Tag)
2610	assert.Equal(t, int64(1024), response.Images[0].Size)
2611	assert.Equal(t, createdAt, response.Images[0].Created)
2612	assert.Equal(t, "sha256:abc123", response.Images[0].ID)
2613	assert.False(t, response.Images[0].Dangling)
2614	assert.True(t, response.Images[1].Dangling)
2615}
2616
2617func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
2618	configSvc := inmocks.NewMockConfigService(t)
2619	authSvc := inmocks.NewMockAuthService(t)
2620	containerSvc := inmocks.NewMockContainerService(t)
2621	secretSvc := inmocks.NewMockSecretService(t)
2622
2623	tests := []struct {
2624		name             string
2625		body             string
2626		expectedKeepLast int
2627	}{
2628		{name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast},
2629		{name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
2630	}
2631
2632	for _, tt := range tests {
2633		t.Run(tt.name, func(t *testing.T) {
2634			called := false
2635			imageSvc := &stubImageService{
2636				pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
2637					called = true
2638					assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
2639					return domain.ImagePruneReport{
2640						Runtime:  domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
2641						Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
2642					}, nil
2643				},
2644			}
2645
2646			handler := newTestHandler(t, func(d *HandlerDeps) {
2647				d.ConfigSvc = configSvc
2648				d.AuthSvc = authSvc
2649				d.ContainerSvc = containerSvc
2650				d.SecretSvc = secretSvc
2651				d.ImageSvc = imageSvc
2652			})
2653
2654			req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
2655			req = req.WithContext(ctxWithScopes("admin:config:write"))
2656			rec := httptest.NewRecorder()
2657
2658			handler.ServeHTTP(rec, req)
2659
2660			assert.Equal(t, http.StatusOK, rec.Code)
2661			assert.True(t, called)
2662			assert.Contains(t, rec.Body.String(), "deleted_count")
2663			assert.Contains(t, rec.Body.String(), "tags_removed")
2664		})
2665	}
2666}
2667
2668func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
2669	t.Run("list failure maps to internal server error", func(t *testing.T) {
2670		configSvc := inmocks.NewMockConfigService(t)
2671		authSvc := inmocks.NewMockAuthService(t)
2672		containerSvc := inmocks.NewMockContainerService(t)
2673		secretSvc := inmocks.NewMockSecretService(t)
2674
2675		imageSvc := &stubImageService{
2676			listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
2677				return nil, errors.New("runtime list failed: confidential details")
2678			},
2679		}
2680
2681		handler := newTestHandler(t, func(d *HandlerDeps) {
2682			d.ConfigSvc = configSvc
2683			d.AuthSvc = authSvc
2684			d.ContainerSvc = containerSvc
2685			d.SecretSvc = secretSvc
2686			d.ImageSvc = imageSvc
2687		})
2688
2689		req := httptest.NewRequest("GET", "/admin/images", nil)
2690		req = req.WithContext(ctxWithScopes("admin:status:read"))
2691		rec := httptest.NewRecorder()
2692
2693		handler.ServeHTTP(rec, req)
2694
2695		assert.Equal(t, http.StatusInternalServerError, rec.Code)
2696		assert.Contains(t, rec.Body.String(), "failed to list images")
2697		assert.NotContains(t, rec.Body.String(), "confidential details")
2698	})
2699
2700	t.Run("prune failure maps to internal server error", func(t *testing.T) {
2701		configSvc := inmocks.NewMockConfigService(t)
2702		authSvc := inmocks.NewMockAuthService(t)
2703		containerSvc := inmocks.NewMockContainerService(t)
2704		secretSvc := inmocks.NewMockSecretService(t)
2705
2706		imageSvc := &stubImageService{
2707			pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
2708				return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
2709			},
2710		}
2711
2712		handler := newTestHandler(t, func(d *HandlerDeps) {
2713			d.ConfigSvc = configSvc
2714			d.AuthSvc = authSvc
2715			d.ContainerSvc = containerSvc
2716			d.SecretSvc = secretSvc
2717			d.ImageSvc = imageSvc
2718		})
2719
2720		req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
2721		req = req.WithContext(ctxWithScopes("admin:config:write"))
2722		rec := httptest.NewRecorder()
2723
2724		handler.ServeHTTP(rec, req)
2725
2726		assert.Equal(t, http.StatusInternalServerError, rec.Code)
2727		assert.Contains(t, rec.Body.String(), "failed to prune images")
2728		assert.NotContains(t, rec.Body.String(), "confidential details")
2729	})
2730}
2731
2732func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
2733	t.Run("rejects negative keep_last", func(t *testing.T) {
2734		configSvc := inmocks.NewMockConfigService(t)
2735		authSvc := inmocks.NewMockAuthService(t)
2736		containerSvc := inmocks.NewMockContainerService(t)
2737		secretSvc := inmocks.NewMockSecretService(t)
2738
2739		handler := newTestHandler(t, func(d *HandlerDeps) {
2740			d.ConfigSvc = configSvc
2741			d.AuthSvc = authSvc
2742			d.ContainerSvc = containerSvc
2743			d.SecretSvc = secretSvc
2744			d.ImageSvc = &stubImageService{}
2745		})
2746
2747		req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
2748		req = req.WithContext(ctxWithScopes("admin:config:write"))
2749		rec := httptest.NewRecorder()
2750
2751		handler.ServeHTTP(rec, req)
2752
2753		assert.Equal(t, http.StatusBadRequest, rec.Code)
2754		assert.Contains(t, rec.Body.String(), "keep_last must be")
2755	})
2756
2757	t.Run("returns service unavailable when image service missing", func(t *testing.T) {
2758		configSvc := inmocks.NewMockConfigService(t)
2759		authSvc := inmocks.NewMockAuthService(t)
2760		containerSvc := inmocks.NewMockContainerService(t)
2761		secretSvc := inmocks.NewMockSecretService(t)
2762
2763		handler := newTestHandler(t, func(d *HandlerDeps) {
2764			d.ConfigSvc = configSvc
2765			d.AuthSvc = authSvc
2766			d.ContainerSvc = containerSvc
2767			d.SecretSvc = secretSvc
2768		})
2769
2770		req := httptest.NewRequest("GET", "/admin/images", nil)
2771		req = req.WithContext(ctxWithScopes("admin:status:read"))
2772		rec := httptest.NewRecorder()
2773
2774		handler.ServeHTTP(rec, req)
2775
2776		assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
2777		assert.Contains(t, rec.Body.String(), "image service not available")
2778	})
2779}
2780
2781func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
2782	configSvc := inmocks.NewMockConfigService(t)
2783	authSvc := inmocks.NewMockAuthService(t)
2784	containerSvc := inmocks.NewMockContainerService(t)
2785	secretSvc := inmocks.NewMockSecretService(t)
2786
2787	tests := []struct {
2788		name                  string
2789		body                  string
2790		expectedPruneDangling bool
2791		expectedPruneRegistry bool
2792		expectedKeepLast      int
2793		wantStatus            int
2794	}{
2795		{
2796			name:                  "missing scope fields defaults to both true",
2797			body:                  `{}`,
2798			expectedPruneDangling: true,
2799			expectedPruneRegistry: true,
2800			expectedKeepLast:      domain.DefaultImagePruneKeepLast,
2801			wantStatus:            http.StatusOK,
2802		},
2803		{
2804			name:                  "explicit dangling only",
2805			body:                  `{"prune_dangling": true, "prune_registry": false}`,
2806			expectedPruneDangling: true,
2807			expectedPruneRegistry: false,
2808			expectedKeepLast:      domain.DefaultImagePruneKeepLast,
2809			wantStatus:            http.StatusOK,
2810		},
2811		{
2812			name:                  "explicit registry only",
2813			body:                  `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`,
2814			expectedPruneDangling: false,
2815			expectedPruneRegistry: true,
2816			expectedKeepLast:      5,
2817			wantStatus:            http.StatusOK,
2818		},
2819		{
2820			name:       "both scopes false returns 400",
2821			body:       `{"prune_dangling": false, "prune_registry": false}`,
2822			wantStatus: http.StatusBadRequest,
2823		},
2824	}
2825
2826	for _, tt := range tests {
2827		t.Run(tt.name, func(t *testing.T) {
2828			called := false
2829			imageSvc := &stubImageService{
2830				pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
2831					called = true
2832					assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling)
2833					assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry)
2834					assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
2835					return domain.ImagePruneReport{}, nil
2836				},
2837			}
2838
2839			handler := newTestHandler(t, func(d *HandlerDeps) {
2840				d.ConfigSvc = configSvc
2841				d.AuthSvc = authSvc
2842				d.ContainerSvc = containerSvc
2843				d.SecretSvc = secretSvc
2844				d.ImageSvc = imageSvc
2845			})
2846
2847			req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
2848			req = req.WithContext(ctxWithScopes("admin:config:write"))
2849			rec := httptest.NewRecorder()
2850
2851			handler.ServeHTTP(rec, req)
2852
2853			assert.Equal(t, tt.wantStatus, rec.Code)
2854			if tt.wantStatus == http.StatusOK {
2855				assert.True(t, called, "prune should have been called")
2856			}
2857			if tt.wantStatus == http.StatusBadRequest {
2858				assert.Contains(t, rec.Body.String(), "at least one prune scope")
2859			}
2860		})
2861	}
2862}
2863
2864func TestHandler_Images_Authorization(t *testing.T) {
2865	configSvc := inmocks.NewMockConfigService(t)
2866	authSvc := inmocks.NewMockAuthService(t)
2867	containerSvc := inmocks.NewMockContainerService(t)
2868	secretSvc := inmocks.NewMockSecretService(t)
2869
2870	handler := newTestHandler(t, func(d *HandlerDeps) {
2871		d.ConfigSvc = configSvc
2872		d.AuthSvc = authSvc
2873		d.ContainerSvc = containerSvc
2874		d.SecretSvc = secretSvc
2875		d.ImageSvc = &stubImageService{}
2876	})
2877
2878	t.Run("images list requires status:read", func(t *testing.T) {
2879		req := httptest.NewRequest("GET", "/admin/images", nil)
2880		req = req.WithContext(ctxWithScopes("admin:config:write"))
2881		rec := httptest.NewRecorder()
2882
2883		handler.ServeHTTP(rec, req)
2884
2885		assert.Equal(t, http.StatusForbidden, rec.Code)
2886		assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
2887	})
2888
2889	t.Run("images prune requires config:write", func(t *testing.T) {
2890		req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
2891		req = req.WithContext(ctxWithScopes("admin:status:read"))
2892		rec := httptest.NewRecorder()
2893
2894		handler.ServeHTTP(rec, req)
2895
2896		assert.Equal(t, http.StatusForbidden, rec.Code)
2897		assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
2898	})
2899}
2900
2901func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
2902	handler := newTestHandler(t, func(d *HandlerDeps) {
2903		d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
2904	})
2905
2906	server := newScopedTestServer(t, handler, "admin:config:write")
2907	resp, err := http.Get(server.URL + "/admin/tls/status")
2908	require.NoError(t, err)
2909	defer resp.Body.Close()
2910
2911	body, err := io.ReadAll(resp.Body)
2912	require.NoError(t, err)
2913
2914	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
2915	assert.Contains(t, string(body), "insufficient permissions for status:read")
2916}
2917
2918func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
2919	now := time.Now()
2920	tlsStatus := domain.PublicTLSStatus{
2921		ACMEEnabled:     true,
2922		ConfiguredMode:  domain.ACMEChallengeAuto,
2923		EffectiveMode:   domain.ACMEChallengeHTTP01,
2924		SelectionReason: "auto-selected",
2925		TokenSource:     domain.ACMETokenSourcePass,
2926		Certificates: []domain.ManagedCertificate{
2927			{
2928				ID:        "cert-1",
2929				Names:     []string{"example.com"},
2930				Challenge: domain.ACMEChallengeHTTP01,
2931				Status:    domain.TLSCertificateStatusValid,
2932				NotAfter:  now.Add(60 * 24 * time.Hour),
2933			},
2934		},
2935		Routes: []domain.TLSRouteCoverage{
2936			{
2937				Domain:       "example.com",
2938				Covered:      true,
2939				CoveredBy:    "cert-1",
2940				RequiredACME: true,
2941			},
2942		},
2943	}
2944
2945	handler := newTestHandler(t, func(d *HandlerDeps) {
2946		fakeSvc := inmocks.NewMockPublicTLSService(t)
2947		fakeSvc.EXPECT().Status(mock.Anything).Return(tlsStatus)
2948		d.PublicTLSSvc = fakeSvc
2949	})
2950
2951	server := newScopedTestServer(t, handler, "admin:status:read")
2952	resp, err := http.Get(server.URL + "/admin/tls/status")
2953	require.NoError(t, err)
2954	defer resp.Body.Close()
2955
2956	assert.Equal(t, http.StatusOK, resp.StatusCode)
2957
2958	var body dto.TLSStatusResponse
2959	err = json.NewDecoder(resp.Body).Decode(&body)
2960	require.NoError(t, err)
2961
2962	assert.True(t, body.ACMEEnabled)
2963	assert.Equal(t, "auto", body.ConfiguredMode)
2964	assert.Equal(t, "http-01", body.EffectiveMode)
2965	assert.Equal(t, "auto-selected", body.SelectionReason)
2966	assert.Equal(t, "pass", body.TokenSource)
2967	require.Len(t, body.Certificates, 1)
2968	assert.Equal(t, "cert-1", body.Certificates[0].ID)
2969	assert.Equal(t, "valid", body.Certificates[0].Status)
2970	assert.Equal(t, "http-01", body.Certificates[0].Challenge)
2971	require.Len(t, body.Routes, 1)
2972	assert.Equal(t, "example.com", body.Routes[0].Domain)
2973	assert.True(t, body.Routes[0].Covered)
2974}
2975
2976func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
2977	handler := newTestHandler(t, func(d *HandlerDeps) {
2978		d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
2979	})
2980
2981	server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write")
2982	resp, err := http.Post(server.URL+"/admin/tls/status", "application/json", nil)
2983	require.NoError(t, err)
2984	defer resp.Body.Close()
2985
2986	assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
2987}
2988
2989func TestHandler_AttachmentOrphans_ReturnsOrphanedAttachments(t *testing.T) {
2990	configSvc := inmocks.NewMockConfigService(t)
2991	authSvc := inmocks.NewMockAuthService(t)
2992	containerSvc := inmocks.NewMockContainerService(t)
2993	secretSvc := inmocks.NewMockSecretService(t)
2994	attachments := []domain.CleanupAttachment{{ContainerID: "attachment-1", Name: "postgres", Owner: "app.example.com"}}
2995	containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(attachments, nil).Once()
2996
2997	handler := newTestHandler(t, func(d *HandlerDeps) {
2998		d.ConfigSvc = configSvc
2999		d.AuthSvc = authSvc
3000		d.ContainerSvc = containerSvc
3001		d.SecretSvc = secretSvc
3002	})
3003
3004	req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil)
3005	req = req.WithContext(ctxWithScopes("admin:config:read"))
3006	rec := httptest.NewRecorder()
3007
3008	handler.ServeHTTP(rec, req)
3009
3010	require.Equal(t, http.StatusOK, rec.Code)
3011	var resp struct {
3012		Attachments []domain.CleanupAttachment `json:"attachments"`
3013	}
3014	require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
3015	require.Len(t, resp.Attachments, 1)
3016	assert.Equal(t, "app.example.com", resp.Attachments[0].Owner)
3017}
3018
3019func TestHandler_AttachmentPrune_StopsOnlyWhenRequested(t *testing.T) {
3020	configSvc := inmocks.NewMockConfigService(t)
3021	authSvc := inmocks.NewMockAuthService(t)
3022	containerSvc := inmocks.NewMockContainerService(t)
3023	secretSvc := inmocks.NewMockSecretService(t)
3024	report := &domain.CleanupReport{RemovedContainers: []domain.CleanupContainer{{ID: "attachment-1"}}}
3025	containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(report, nil).Once()
3026
3027	handler := newTestHandler(t, func(d *HandlerDeps) {
3028		d.ConfigSvc = configSvc
3029		d.AuthSvc = authSvc
3030		d.ContainerSvc = containerSvc
3031		d.SecretSvc = secretSvc
3032	})
3033
3034	req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil)
3035	req = req.WithContext(ctxWithScopes("admin:config:write"))
3036	rec := httptest.NewRecorder()
3037
3038	handler.ServeHTTP(rec, req)
3039
3040	require.Equal(t, http.StatusOK, rec.Code)
3041	var got domain.CleanupReport
3042	require.NoError(t, json.NewDecoder(rec.Body).Decode(&got))
3043	require.Len(t, got.RemovedContainers, 1)
3044	assert.Equal(t, "attachment-1", got.RemovedContainers[0].ID)
3045}
3046
3047func TestHandler_AttachmentOrphans_ReturnsServerErrorOnListFailure(t *testing.T) {
3048	configSvc := inmocks.NewMockConfigService(t)
3049	authSvc := inmocks.NewMockAuthService(t)
3050	containerSvc := inmocks.NewMockContainerService(t)
3051	secretSvc := inmocks.NewMockSecretService(t)
3052	containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(nil, errors.New("boom")).Once()
3053
3054	handler := newTestHandler(t, func(d *HandlerDeps) {
3055		d.ConfigSvc = configSvc
3056		d.AuthSvc = authSvc
3057		d.ContainerSvc = containerSvc
3058		d.SecretSvc = secretSvc
3059	})
3060
3061	req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil)
3062	req = req.WithContext(ctxWithScopes("admin:config:read"))
3063	rec := httptest.NewRecorder()
3064
3065	handler.ServeHTTP(rec, req)
3066
3067	assert.Equal(t, http.StatusInternalServerError, rec.Code)
3068}
3069
3070func TestHandler_AttachmentOrphans_PostReturns405(t *testing.T) {
3071	handler := newTestHandler(t, func(d *HandlerDeps) {
3072		d.ContainerSvc = inmocks.NewMockContainerService(t)
3073	})
3074	req := httptest.NewRequest("POST", "/admin/attachments/orphans", nil)
3075	req = req.WithContext(ctxWithScopes("admin:config:read"))
3076	rec := httptest.NewRecorder()
3077
3078	handler.ServeHTTP(rec, req)
3079
3080	assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
3081}
3082
3083func TestHandler_AttachmentPrune_ReturnsServerErrorOnCleanupFailure(t *testing.T) {
3084	configSvc := inmocks.NewMockConfigService(t)
3085	authSvc := inmocks.NewMockAuthService(t)
3086	containerSvc := inmocks.NewMockContainerService(t)
3087	secretSvc := inmocks.NewMockSecretService(t)
3088	containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(nil, errors.New("boom")).Once()
3089
3090	handler := newTestHandler(t, func(d *HandlerDeps) {
3091		d.ConfigSvc = configSvc
3092		d.AuthSvc = authSvc
3093		d.ContainerSvc = containerSvc
3094		d.SecretSvc = secretSvc
3095	})
3096
3097	req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil)
3098	req = req.WithContext(ctxWithScopes("admin:config:write"))
3099	rec := httptest.NewRecorder()
3100
3101	handler.ServeHTTP(rec, req)
3102
3103	assert.Equal(t, http.StatusInternalServerError, rec.Code)
3104}
3105
3106func TestHandler_AttachmentPrune_GetReturns405(t *testing.T) {
3107	handler := newTestHandler(t, func(d *HandlerDeps) {
3108		d.ContainerSvc = inmocks.NewMockContainerService(t)
3109	})
3110	req := httptest.NewRequest("GET", "/admin/attachments/prune", nil)
3111	req = req.WithContext(ctxWithScopes("admin:config:write"))
3112	rec := httptest.NewRecorder()
3113
3114	handler.ServeHTTP(rec, req)
3115
3116	assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
3117}

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