v2.31.0-dev5 4162c86

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

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