v2.16.0 ebd2ab3

handler_test.go RawHistoryFolder

1package admin
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"net/http"
10	"net/http/httptest"
11	"testing"
12	"time"
13
14	"github.com/bnema/zerowrap"
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/mock"
17
18	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
19	"github.com/bnema/gordon/internal/domain"
20)
21
22type stubImageService struct {
23	listImagesFunc func(context.Context) ([]domain.ImageInfo, error)
24	pruneFunc      func(context.Context, int) (domain.ImagePruneReport, error)
25}
26
27func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
28	if s.listImagesFunc == nil {
29		return nil, nil
30	}
31	return s.listImagesFunc(ctx)
32}
33
34func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
35	if s.pruneFunc == nil {
36		return domain.ImagePruneReport{}, nil
37	}
38	return s.pruneFunc(ctx, keepLast)
39}
40
41func testLogger() zerowrap.Logger {
42	return zerowrap.Default()
43}
44
45func ctxWithScopes(scopes ...string) context.Context {
46	ctx := context.Background()
47	return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
48}
49
50// Routes endpoint tests
51
52func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
53	configSvc := inmocks.NewMockConfigService(t)
54	authSvc := inmocks.NewMockAuthService(t)
55	containerSvc := inmocks.NewMockContainerService(t)
56	secretSvc := inmocks.NewMockSecretService(t)
57
58	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
59
60	tests := []struct {
61		name       string
62		scopes     []string
63		wantStatus int
64	}{
65		{
66			name:       "routes read access granted",
67			scopes:     []string{"admin:routes:read"},
68			wantStatus: http.StatusOK,
69		},
70		{
71			name:       "routes wildcard access granted",
72			scopes:     []string{"admin:routes:*"},
73			wantStatus: http.StatusOK,
74		},
75		{
76			name:       "all admin access granted",
77			scopes:     []string{"admin:*:*"},
78			wantStatus: http.StatusOK,
79		},
80		{
81			name:       "only write access denied",
82			scopes:     []string{"admin:routes:write"},
83			wantStatus: http.StatusForbidden,
84		},
85		{
86			name:       "wrong resource denied",
87			scopes:     []string{"admin:secrets:read"},
88			wantStatus: http.StatusForbidden,
89		},
90		{
91			name:       "no scopes denied",
92			scopes:     []string{},
93			wantStatus: http.StatusForbidden,
94		},
95	}
96
97	for _, tt := range tests {
98		t.Run(tt.name, func(t *testing.T) {
99			if tt.wantStatus == http.StatusOK {
100				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
101			}
102
103			req := httptest.NewRequest("GET", "/admin/routes", nil)
104			req = req.WithContext(ctxWithScopes(tt.scopes...))
105			rec := httptest.NewRecorder()
106
107			handler.ServeHTTP(rec, req)
108
109			assert.Equal(t, tt.wantStatus, rec.Code)
110		})
111	}
112}
113
114func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
115	configSvc := inmocks.NewMockConfigService(t)
116	authSvc := inmocks.NewMockAuthService(t)
117	containerSvc := inmocks.NewMockContainerService(t)
118	secretSvc := inmocks.NewMockSecretService(t)
119	registrySvc := inmocks.NewMockRegistryService(t)
120
121	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
122
123	routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
124
125	tests := []struct {
126		name       string
127		scopes     []string
128		wantStatus int
129	}{
130		{
131			name:       "routes write access granted",
132			scopes:     []string{"admin:routes:write"},
133			wantStatus: http.StatusCreated,
134		},
135		{
136			name:       "routes wildcard access granted",
137			scopes:     []string{"admin:routes:*"},
138			wantStatus: http.StatusCreated,
139		},
140		{
141			name:       "all admin access granted",
142			scopes:     []string{"admin:*:*"},
143			wantStatus: http.StatusCreated,
144		},
145		{
146			name:       "only read access denied",
147			scopes:     []string{"admin:routes:read"},
148			wantStatus: http.StatusForbidden,
149		},
150		{
151			name:       "wrong resource denied",
152			scopes:     []string{"admin:secrets:write"},
153			wantStatus: http.StatusForbidden,
154		},
155	}
156
157	for _, tt := range tests {
158		t.Run(tt.name, func(t *testing.T) {
159			if tt.wantStatus == http.StatusCreated {
160				configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
161				registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe()
162			}
163
164			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
165			req = req.WithContext(ctxWithScopes(tt.scopes...))
166			rec := httptest.NewRecorder()
167
168			handler.ServeHTTP(rec, req)
169
170			assert.Equal(t, tt.wantStatus, rec.Code)
171		})
172	}
173}
174
175func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
176	configSvc := inmocks.NewMockConfigService(t)
177	authSvc := inmocks.NewMockAuthService(t)
178	containerSvc := inmocks.NewMockContainerService(t)
179	secretSvc := inmocks.NewMockSecretService(t)
180	registrySvc := inmocks.NewMockRegistryService(t)
181
182	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
183
184	routeJSON := `{"image": "myapp:v2"}`
185
186	tests := []struct {
187		name       string
188		scopes     []string
189		wantStatus int
190	}{
191		{
192			name:       "routes write access granted",
193			scopes:     []string{"admin:routes:write"},
194			wantStatus: http.StatusOK,
195		},
196		{
197			name:       "only read access denied",
198			scopes:     []string{"admin:routes:read"},
199			wantStatus: http.StatusForbidden,
200		},
201	}
202
203	for _, tt := range tests {
204		t.Run(tt.name, func(t *testing.T) {
205			if tt.wantStatus == http.StatusOK {
206				configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
207				registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe()
208			}
209
210			req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
211			req = req.WithContext(ctxWithScopes(tt.scopes...))
212			rec := httptest.NewRecorder()
213
214			handler.ServeHTTP(rec, req)
215
216			assert.Equal(t, tt.wantStatus, rec.Code)
217		})
218	}
219}
220
221func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
222	configSvc := inmocks.NewMockConfigService(t)
223	authSvc := inmocks.NewMockAuthService(t)
224	containerSvc := inmocks.NewMockContainerService(t)
225	secretSvc := inmocks.NewMockSecretService(t)
226
227	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
228
229	tests := []struct {
230		name       string
231		scopes     []string
232		wantStatus int
233	}{
234		{
235			name:       "routes write access granted",
236			scopes:     []string{"admin:routes:write"},
237			wantStatus: http.StatusOK,
238		},
239		{
240			name:       "only read access denied",
241			scopes:     []string{"admin:routes:read"},
242			wantStatus: http.StatusForbidden,
243		},
244	}
245
246	for _, tt := range tests {
247		t.Run(tt.name, func(t *testing.T) {
248			if tt.wantStatus == http.StatusOK {
249				configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
250			}
251
252			req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
253			req = req.WithContext(ctxWithScopes(tt.scopes...))
254			rec := httptest.NewRecorder()
255
256			handler.ServeHTTP(rec, req)
257
258			assert.Equal(t, tt.wantStatus, rec.Code)
259		})
260	}
261}
262
263// Secrets endpoint tests
264
265func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
266	configSvc := inmocks.NewMockConfigService(t)
267	authSvc := inmocks.NewMockAuthService(t)
268	containerSvc := inmocks.NewMockContainerService(t)
269	secretSvc := inmocks.NewMockSecretService(t)
270
271	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
272
273	tests := []struct {
274		name       string
275		scopes     []string
276		wantStatus int
277	}{
278		{
279			name:       "secrets read access granted",
280			scopes:     []string{"admin:secrets:read"},
281			wantStatus: http.StatusOK,
282		},
283		{
284			name:       "secrets wildcard access granted",
285			scopes:     []string{"admin:secrets:*"},
286			wantStatus: http.StatusOK,
287		},
288		{
289			name:       "all admin access granted",
290			scopes:     []string{"admin:*:*"},
291			wantStatus: http.StatusOK,
292		},
293		{
294			name:       "only write access denied",
295			scopes:     []string{"admin:secrets:write"},
296			wantStatus: http.StatusForbidden,
297		},
298		{
299			name:       "wrong resource denied",
300			scopes:     []string{"admin:routes:read"},
301			wantStatus: http.StatusForbidden,
302		},
303	}
304
305	for _, tt := range tests {
306		t.Run(tt.name, func(t *testing.T) {
307			if tt.wantStatus == http.StatusOK {
308				secretSvc.EXPECT().ListKeysWithAttachments(mock.Anything, "app.example.com").Return([]string{}, nil, nil).Maybe()
309			}
310
311			req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
312			req = req.WithContext(ctxWithScopes(tt.scopes...))
313			rec := httptest.NewRecorder()
314
315			handler.ServeHTTP(rec, req)
316
317			assert.Equal(t, tt.wantStatus, rec.Code)
318		})
319	}
320}
321
322func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
323	configSvc := inmocks.NewMockConfigService(t)
324	authSvc := inmocks.NewMockAuthService(t)
325	containerSvc := inmocks.NewMockContainerService(t)
326	secretSvc := inmocks.NewMockSecretService(t)
327
328	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
329
330	secretsJSON := `{"API_KEY": "secret123"}`
331
332	tests := []struct {
333		name       string
334		scopes     []string
335		wantStatus int
336	}{
337		{
338			name:       "secrets write access granted",
339			scopes:     []string{"admin:secrets:write"},
340			wantStatus: http.StatusOK,
341		},
342		{
343			name:       "only read access denied",
344			scopes:     []string{"admin:secrets:read"},
345			wantStatus: http.StatusForbidden,
346		},
347		{
348			name:       "wrong resource denied",
349			scopes:     []string{"admin:routes:write"},
350			wantStatus: http.StatusForbidden,
351		},
352	}
353
354	for _, tt := range tests {
355		t.Run(tt.name, func(t *testing.T) {
356			if tt.wantStatus == http.StatusOK {
357				secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe()
358			}
359
360			req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
361			req = req.WithContext(ctxWithScopes(tt.scopes...))
362			rec := httptest.NewRecorder()
363
364			handler.ServeHTTP(rec, req)
365
366			assert.Equal(t, tt.wantStatus, rec.Code)
367		})
368	}
369}
370
371func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
372	configSvc := inmocks.NewMockConfigService(t)
373	authSvc := inmocks.NewMockAuthService(t)
374	containerSvc := inmocks.NewMockContainerService(t)
375	secretSvc := inmocks.NewMockSecretService(t)
376
377	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
378
379	tests := []struct {
380		name       string
381		scopes     []string
382		wantStatus int
383	}{
384		{
385			name:       "secrets write access granted",
386			scopes:     []string{"admin:secrets:write"},
387			wantStatus: http.StatusOK,
388		},
389		{
390			name:       "only read access denied",
391			scopes:     []string{"admin:secrets:read"},
392			wantStatus: http.StatusForbidden,
393		},
394	}
395
396	for _, tt := range tests {
397		t.Run(tt.name, func(t *testing.T) {
398			if tt.wantStatus == http.StatusOK {
399				secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe()
400			}
401
402			req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
403			req = req.WithContext(ctxWithScopes(tt.scopes...))
404			rec := httptest.NewRecorder()
405
406			handler.ServeHTTP(rec, req)
407
408			assert.Equal(t, tt.wantStatus, rec.Code)
409		})
410	}
411}
412
413// Attachment secrets endpoint tests
414
415func TestHandler_AttachmentSecretsPost(t *testing.T) {
416	configSvc := inmocks.NewMockConfigService(t)
417	authSvc := inmocks.NewMockAuthService(t)
418	containerSvc := inmocks.NewMockContainerService(t)
419	secretSvc := inmocks.NewMockSecretService(t)
420
421	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
422
423	secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
424
425	body := `{"REDIS_URL": "redis://localhost:6379"}`
426	req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
427	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
428	rec := httptest.NewRecorder()
429
430	handler.ServeHTTP(rec, req)
431
432	assert.Equal(t, http.StatusOK, rec.Code)
433
434	var response struct {
435		Status string `json:"status"`
436	}
437	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
438	assert.Equal(t, "updated", response.Status)
439}
440
441func TestHandler_AttachmentSecretsDelete(t *testing.T) {
442	configSvc := inmocks.NewMockConfigService(t)
443	authSvc := inmocks.NewMockAuthService(t)
444	containerSvc := inmocks.NewMockContainerService(t)
445	secretSvc := inmocks.NewMockSecretService(t)
446
447	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
448
449	secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
450
451	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
452	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
453	rec := httptest.NewRecorder()
454
455	handler.ServeHTTP(rec, req)
456
457	assert.Equal(t, http.StatusOK, rec.Code)
458
459	var response struct {
460		Status string `json:"status"`
461	}
462	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
463	assert.Equal(t, "deleted", response.Status)
464}
465
466// Status endpoint tests
467
468func TestHandler_Status_RequiresReadScope(t *testing.T) {
469	configSvc := inmocks.NewMockConfigService(t)
470	authSvc := inmocks.NewMockAuthService(t)
471	containerSvc := inmocks.NewMockContainerService(t)
472	secretSvc := inmocks.NewMockSecretService(t)
473
474	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
475
476	tests := []struct {
477		name       string
478		scopes     []string
479		wantStatus int
480	}{
481		{
482			name:       "status read access granted",
483			scopes:     []string{"admin:status:read"},
484			wantStatus: http.StatusOK,
485		},
486		{
487			name:       "all admin access granted",
488			scopes:     []string{"admin:*:*"},
489			wantStatus: http.StatusOK,
490		},
491		{
492			name:       "wrong resource denied",
493			scopes:     []string{"admin:routes:read"},
494			wantStatus: http.StatusForbidden,
495		},
496	}
497
498	for _, tt := range tests {
499		t.Run(tt.name, func(t *testing.T) {
500			if tt.wantStatus == http.StatusOK {
501				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
502				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
503				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
504				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
505				configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
506				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
507			}
508
509			req := httptest.NewRequest("GET", "/admin/status", nil)
510			req = req.WithContext(ctxWithScopes(tt.scopes...))
511			rec := httptest.NewRecorder()
512
513			handler.ServeHTTP(rec, req)
514
515			assert.Equal(t, tt.wantStatus, rec.Code)
516		})
517	}
518}
519
520// Config endpoint tests
521
522func TestHandler_Config_RequiresReadScope(t *testing.T) {
523	configSvc := inmocks.NewMockConfigService(t)
524	authSvc := inmocks.NewMockAuthService(t)
525	containerSvc := inmocks.NewMockContainerService(t)
526	secretSvc := inmocks.NewMockSecretService(t)
527
528	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
529
530	tests := []struct {
531		name       string
532		scopes     []string
533		wantStatus int
534	}{
535		{
536			name:       "config read access granted",
537			scopes:     []string{"admin:config:read"},
538			wantStatus: http.StatusOK,
539		},
540		{
541			name:       "all admin access granted",
542			scopes:     []string{"admin:*:*"},
543			wantStatus: http.StatusOK,
544		},
545		{
546			name:       "wrong resource denied",
547			scopes:     []string{"admin:routes:read"},
548			wantStatus: http.StatusForbidden,
549		},
550	}
551
552	for _, tt := range tests {
553		t.Run(tt.name, func(t *testing.T) {
554			if tt.wantStatus == http.StatusOK {
555				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
556				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
557				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
558				configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
559				configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
560				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
561				configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
562				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
563				configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
564			}
565
566			req := httptest.NewRequest("GET", "/admin/config", nil)
567			req = req.WithContext(ctxWithScopes(tt.scopes...))
568			rec := httptest.NewRecorder()
569
570			handler.ServeHTTP(rec, req)
571
572			assert.Equal(t, tt.wantStatus, rec.Code)
573		})
574	}
575}
576
577// Reload endpoint tests
578
579func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
580	configSvc := inmocks.NewMockConfigService(t)
581	authSvc := inmocks.NewMockAuthService(t)
582	containerSvc := inmocks.NewMockContainerService(t)
583	secretSvc := inmocks.NewMockSecretService(t)
584
585	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
586
587	tests := []struct {
588		name       string
589		scopes     []string
590		wantStatus int
591	}{
592		{
593			name:       "config write access granted",
594			scopes:     []string{"admin:config:write"},
595			wantStatus: http.StatusOK,
596		},
597		{
598			name:       "all admin access granted",
599			scopes:     []string{"admin:*:*"},
600			wantStatus: http.StatusOK,
601		},
602		{
603			name:       "only read access denied",
604			scopes:     []string{"admin:config:read"},
605			wantStatus: http.StatusForbidden,
606		},
607		{
608			name:       "wrong resource denied",
609			scopes:     []string{"admin:routes:write"},
610			wantStatus: http.StatusForbidden,
611		},
612	}
613
614	for _, tt := range tests {
615		t.Run(tt.name, func(t *testing.T) {
616			if tt.wantStatus == http.StatusOK {
617				configSvc.EXPECT().Reload(mock.Anything).Return(nil).Maybe()
618			}
619
620			req := httptest.NewRequest("POST", "/admin/reload", nil)
621			req = req.WithContext(ctxWithScopes(tt.scopes...))
622			rec := httptest.NewRecorder()
623
624			handler.ServeHTTP(rec, req)
625
626			assert.Equal(t, tt.wantStatus, rec.Code)
627		})
628	}
629}
630
631// Functional tests
632
633func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
634	configSvc := inmocks.NewMockConfigService(t)
635	authSvc := inmocks.NewMockAuthService(t)
636	containerSvc := inmocks.NewMockContainerService(t)
637	secretSvc := inmocks.NewMockSecretService(t)
638
639	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
640
641	expectedRoutes := []domain.Route{
642		{Domain: "app1.example.com", Image: "app1:latest"},
643		{Domain: "app2.example.com", Image: "app2:v1.0"},
644	}
645	configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
646
647	req := httptest.NewRequest("GET", "/admin/routes", nil)
648	req = req.WithContext(ctxWithScopes("admin:routes:read"))
649	rec := httptest.NewRecorder()
650
651	handler.ServeHTTP(rec, req)
652
653	assert.Equal(t, http.StatusOK, rec.Code)
654
655	var response struct {
656		Routes []struct {
657			Domain string `json:"domain"`
658			Image  string `json:"image"`
659			HTTPS  bool   `json:"https"`
660		} `json:"routes"`
661	}
662	err := json.NewDecoder(rec.Body).Decode(&response)
663	assert.NoError(t, err)
664
665	assert.Len(t, response.Routes, 2)
666}
667
668func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
669	configSvc := inmocks.NewMockConfigService(t)
670	authSvc := inmocks.NewMockAuthService(t)
671	containerSvc := inmocks.NewMockContainerService(t)
672	secretSvc := inmocks.NewMockSecretService(t)
673
674	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
675
676	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
677		Domain: "app.example.com", Image: "app:latest",
678	}, nil)
679
680	req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
681	req = req.WithContext(ctxWithScopes("admin:routes:read"))
682	rec := httptest.NewRecorder()
683
684	handler.ServeHTTP(rec, req)
685
686	assert.Equal(t, http.StatusOK, rec.Code)
687
688	var route struct {
689		Domain string `json:"domain"`
690		Image  string `json:"image"`
691		HTTPS  bool   `json:"https"`
692	}
693	err := json.NewDecoder(rec.Body).Decode(&route)
694	assert.NoError(t, err)
695	assert.Equal(t, "app.example.com", route.Domain)
696	assert.Equal(t, "app:latest", route.Image)
697}
698
699func TestHandler_RoutesGet_NotFound(t *testing.T) {
700	configSvc := inmocks.NewMockConfigService(t)
701	authSvc := inmocks.NewMockAuthService(t)
702	containerSvc := inmocks.NewMockContainerService(t)
703	secretSvc := inmocks.NewMockSecretService(t)
704
705	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
706
707	configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
708
709	req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
710	req = req.WithContext(ctxWithScopes("admin:routes:read"))
711	rec := httptest.NewRecorder()
712
713	handler.ServeHTTP(rec, req)
714
715	assert.Equal(t, http.StatusNotFound, rec.Code)
716}
717
718func TestHandler_RoutesPost_MissingFields(t *testing.T) {
719	tests := []struct {
720		name    string
721		body    string
722		mockErr error
723	}{
724		{
725			name:    "missing domain",
726			body:    `{"image": "app:latest"}`,
727			mockErr: domain.ErrRouteDomainEmpty,
728		},
729		{
730			name:    "missing image",
731			body:    `{"domain": "app.example.com"}`,
732			mockErr: domain.ErrRouteImageEmpty,
733		},
734		{
735			name:    "empty object",
736			body:    `{}`,
737			mockErr: domain.ErrRouteDomainEmpty,
738		},
739	}
740
741	for _, tt := range tests {
742		t.Run(tt.name, func(t *testing.T) {
743			configSvc := inmocks.NewMockConfigService(t)
744			authSvc := inmocks.NewMockAuthService(t)
745			containerSvc := inmocks.NewMockContainerService(t)
746			secretSvc := inmocks.NewMockSecretService(t)
747			registrySvc := inmocks.NewMockRegistryService(t)
748
749			handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
750
751			configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
752			registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
753
754			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
755			req = req.WithContext(ctxWithScopes("admin:routes:write"))
756			rec := httptest.NewRecorder()
757
758			handler.ServeHTTP(rec, req)
759
760			assert.Equal(t, http.StatusBadRequest, rec.Code)
761		})
762	}
763}
764
765func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
766	configSvc := inmocks.NewMockConfigService(t)
767	authSvc := inmocks.NewMockAuthService(t)
768	containerSvc := inmocks.NewMockContainerService(t)
769	secretSvc := inmocks.NewMockSecretService(t)
770	registrySvc := inmocks.NewMockRegistryService(t)
771
772	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
773
774	tests := []struct {
775		name         string
776		image        string
777		body         string
778		manifestName string
779		manifestRef  string
780	}{
781		{
782			name:         "tagged image not found",
783			image:        "myapp:latest",
784			body:         `{"domain": "app.example.com", "image": "myapp:latest"}`,
785			manifestName: "myapp",
786			manifestRef:  "latest",
787		},
788		{
789			name:         "digest image not found",
790			image:        "myapp@sha256:abc123",
791			body:         `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
792			manifestName: "myapp",
793			manifestRef:  "sha256:abc123",
794		},
795		{
796			name:         "implicit latest tag not found",
797			image:        "myapp",
798			body:         `{"domain": "app.example.com", "image": "myapp"}`,
799			manifestName: "myapp",
800			manifestRef:  "latest",
801		},
802	}
803
804	for _, tt := range tests {
805		t.Run(tt.name, func(t *testing.T) {
806			registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound)
807
808			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
809			req = req.WithContext(ctxWithScopes("admin:routes:write"))
810			rec := httptest.NewRecorder()
811
812			handler.ServeHTTP(rec, req)
813
814			assert.Equal(t, http.StatusBadRequest, rec.Code)
815			assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
816		})
817	}
818}
819
820func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
821	configSvc := inmocks.NewMockConfigService(t)
822	authSvc := inmocks.NewMockAuthService(t)
823	containerSvc := inmocks.NewMockContainerService(t)
824	secretSvc := inmocks.NewMockSecretService(t)
825
826	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
827
828	req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
829	req = req.WithContext(ctxWithScopes("admin:routes:write"))
830	rec := httptest.NewRecorder()
831
832	handler.ServeHTTP(rec, req)
833
834	assert.Equal(t, http.StatusBadRequest, rec.Code)
835}
836
837func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
838	configSvc := inmocks.NewMockConfigService(t)
839	authSvc := inmocks.NewMockAuthService(t)
840	containerSvc := inmocks.NewMockContainerService(t)
841	secretSvc := inmocks.NewMockSecretService(t)
842
843	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
844
845	req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
846	req = req.WithContext(ctxWithScopes("admin:routes:write"))
847	rec := httptest.NewRecorder()
848
849	handler.ServeHTTP(rec, req)
850
851	assert.Equal(t, http.StatusBadRequest, rec.Code)
852}
853
854func TestHandler_Secrets_MissingDomain(t *testing.T) {
855	configSvc := inmocks.NewMockConfigService(t)
856	authSvc := inmocks.NewMockAuthService(t)
857	containerSvc := inmocks.NewMockContainerService(t)
858	secretSvc := inmocks.NewMockSecretService(t)
859
860	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
861
862	req := httptest.NewRequest("GET", "/admin/secrets/", nil)
863	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
864	rec := httptest.NewRecorder()
865
866	handler.ServeHTTP(rec, req)
867
868	assert.Equal(t, http.StatusBadRequest, rec.Code)
869}
870
871func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
872	configSvc := inmocks.NewMockConfigService(t)
873	authSvc := inmocks.NewMockAuthService(t)
874	containerSvc := inmocks.NewMockContainerService(t)
875	secretSvc := inmocks.NewMockSecretService(t)
876
877	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
878
879	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
880	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
881	rec := httptest.NewRecorder()
882
883	handler.ServeHTTP(rec, req)
884
885	assert.Equal(t, http.StatusBadRequest, rec.Code)
886}
887
888func TestHandler_MethodNotAllowed(t *testing.T) {
889	configSvc := inmocks.NewMockConfigService(t)
890	authSvc := inmocks.NewMockAuthService(t)
891	containerSvc := inmocks.NewMockContainerService(t)
892	secretSvc := inmocks.NewMockSecretService(t)
893
894	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
895
896	tests := []struct {
897		method string
898		path   string
899	}{
900		{"DELETE", "/admin/status"},
901		{"PUT", "/admin/status"},
902		{"GET", "/admin/reload"},
903		{"DELETE", "/admin/reload"},
904		{"POST", "/admin/config"},
905		{"DELETE", "/admin/config"},
906	}
907
908	for _, tt := range tests {
909		t.Run(tt.method+" "+tt.path, func(t *testing.T) {
910			req := httptest.NewRequest(tt.method, tt.path, nil)
911			req = req.WithContext(ctxWithScopes("admin:*:*"))
912			rec := httptest.NewRecorder()
913
914			handler.ServeHTTP(rec, req)
915
916			assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
917		})
918	}
919}
920
921func TestHandler_Restart_InvalidDomain(t *testing.T) {
922	configSvc := inmocks.NewMockConfigService(t)
923	authSvc := inmocks.NewMockAuthService(t)
924	containerSvc := inmocks.NewMockContainerService(t)
925	secretSvc := inmocks.NewMockSecretService(t)
926
927	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
928
929	tests := []struct {
930		name   string
931		path   string
932		status int
933	}{
934		{"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest},
935		{"empty domain", "/admin/restart/", http.StatusBadRequest},
936	}
937
938	for _, tt := range tests {
939		t.Run(tt.name, func(t *testing.T) {
940			req := httptest.NewRequest("POST", tt.path, nil)
941			req = req.WithContext(ctxWithScopes("admin:config:write"))
942			rec := httptest.NewRecorder()
943
944			handler.ServeHTTP(rec, req)
945
946			assert.Equal(t, tt.status, rec.Code)
947		})
948	}
949}
950
951func TestHandler_Restart_ContainerNotFound(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 := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
958
959	containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
960
961	req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil)
962	req = req.WithContext(ctxWithScopes("admin:config:write"))
963	rec := httptest.NewRecorder()
964
965	handler.ServeHTTP(rec, req)
966
967	assert.Equal(t, http.StatusNotFound, rec.Code)
968}
969
970func TestHandler_Restart_GenericError(t *testing.T) {
971	configSvc := inmocks.NewMockConfigService(t)
972	authSvc := inmocks.NewMockAuthService(t)
973	containerSvc := inmocks.NewMockContainerService(t)
974	secretSvc := inmocks.NewMockSecretService(t)
975
976	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
977
978	containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
979
980	req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil)
981	req = req.WithContext(ctxWithScopes("admin:config:write"))
982	rec := httptest.NewRecorder()
983
984	handler.ServeHTTP(rec, req)
985
986	assert.Equal(t, http.StatusInternalServerError, rec.Code)
987	// Verify internal error details are NOT leaked
988	assert.NotContains(t, rec.Body.String(), "docker daemon error")
989	assert.NotContains(t, rec.Body.String(), "secret details")
990}
991
992func TestHandler_Restart_Success(t *testing.T) {
993	configSvc := inmocks.NewMockConfigService(t)
994	authSvc := inmocks.NewMockAuthService(t)
995	containerSvc := inmocks.NewMockContainerService(t)
996	secretSvc := inmocks.NewMockSecretService(t)
997
998	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
999
1000	tests := []struct {
1001		name            string
1002		path            string
1003		withAttachments bool
1004	}{
1005		{
1006			name:            "without attachments",
1007			path:            "/admin/restart/test.example.com",
1008			withAttachments: false,
1009		},
1010		{
1011			name:            "with attachments",
1012			path:            "/admin/restart/test.example.com?attachments=true",
1013			withAttachments: true,
1014		},
1015	}
1016
1017	for _, tt := range tests {
1018		t.Run(tt.name, func(t *testing.T) {
1019			containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
1020
1021			req := httptest.NewRequest("POST", tt.path, nil)
1022			req = req.WithContext(ctxWithScopes("admin:config:write"))
1023			rec := httptest.NewRecorder()
1024
1025			handler.ServeHTTP(rec, req)
1026
1027			assert.Equal(t, http.StatusOK, rec.Code)
1028			var response struct {
1029				Status string `json:"status"`
1030				Domain string `json:"domain"`
1031			}
1032			assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1033			assert.Equal(t, "restarted", response.Status)
1034			assert.Equal(t, "test.example.com", response.Domain)
1035		})
1036	}
1037}
1038
1039func TestHandler_Tags_InvalidRepository(t *testing.T) {
1040	configSvc := inmocks.NewMockConfigService(t)
1041	authSvc := inmocks.NewMockAuthService(t)
1042	containerSvc := inmocks.NewMockContainerService(t)
1043	secretSvc := inmocks.NewMockSecretService(t)
1044
1045	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1046
1047	tests := []struct {
1048		name   string
1049		path   string
1050		status int
1051	}{
1052		{"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
1053		{"empty repo", "/admin/tags/", http.StatusBadRequest},
1054	}
1055
1056	for _, tt := range tests {
1057		t.Run(tt.name, func(t *testing.T) {
1058			req := httptest.NewRequest("GET", tt.path, nil)
1059			req = req.WithContext(ctxWithScopes("admin:status:read"))
1060			rec := httptest.NewRecorder()
1061
1062			handler.ServeHTTP(rec, req)
1063
1064			assert.Equal(t, tt.status, rec.Code)
1065		})
1066	}
1067}
1068
1069func TestHandler_Tags_Success(t *testing.T) {
1070	configSvc := inmocks.NewMockConfigService(t)
1071	authSvc := inmocks.NewMockAuthService(t)
1072	containerSvc := inmocks.NewMockContainerService(t)
1073	secretSvc := inmocks.NewMockSecretService(t)
1074	registrySvc := inmocks.NewMockRegistryService(t)
1075
1076	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
1077
1078	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1079
1080	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1081	req = req.WithContext(ctxWithScopes("admin:status:read"))
1082	rec := httptest.NewRecorder()
1083
1084	handler.ServeHTTP(rec, req)
1085
1086	assert.Equal(t, http.StatusOK, rec.Code)
1087	var response struct {
1088		Repository string   `json:"repository"`
1089		Tags       []string `json:"tags"`
1090	}
1091	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1092	assert.Equal(t, "myapp", response.Repository)
1093	assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1094}
1095
1096func TestHandler_Tags_Error(t *testing.T) {
1097	configSvc := inmocks.NewMockConfigService(t)
1098	authSvc := inmocks.NewMockAuthService(t)
1099	containerSvc := inmocks.NewMockContainerService(t)
1100	secretSvc := inmocks.NewMockSecretService(t)
1101	registrySvc := inmocks.NewMockRegistryService(t)
1102
1103	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
1104
1105	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1106
1107	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1108	req = req.WithContext(ctxWithScopes("admin:status:read"))
1109	rec := httptest.NewRecorder()
1110
1111	handler.ServeHTTP(rec, req)
1112
1113	assert.Equal(t, http.StatusInternalServerError, rec.Code)
1114	assert.NotContains(t, rec.Body.String(), "registry error")
1115}
1116
1117func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
1118	configSvc := inmocks.NewMockConfigService(t)
1119	authSvc := inmocks.NewMockAuthService(t)
1120	containerSvc := inmocks.NewMockContainerService(t)
1121	secretSvc := inmocks.NewMockSecretService(t)
1122
1123	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1124
1125	body := `{"REDIS_URL": "redis://localhost:6379"}`
1126	req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1127	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1128	rec := httptest.NewRecorder()
1129
1130	handler.ServeHTTP(rec, req)
1131
1132	assert.Equal(t, http.StatusForbidden, rec.Code)
1133}
1134
1135func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
1136	configSvc := inmocks.NewMockConfigService(t)
1137	authSvc := inmocks.NewMockAuthService(t)
1138	containerSvc := inmocks.NewMockContainerService(t)
1139	secretSvc := inmocks.NewMockSecretService(t)
1140
1141	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1142
1143	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1144	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1145	rec := httptest.NewRecorder()
1146
1147	handler.ServeHTTP(rec, req)
1148
1149	assert.Equal(t, http.StatusForbidden, rec.Code)
1150}
1151
1152func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1153	configSvc := inmocks.NewMockConfigService(t)
1154	authSvc := inmocks.NewMockAuthService(t)
1155	containerSvc := inmocks.NewMockContainerService(t)
1156	secretSvc := inmocks.NewMockSecretService(t)
1157
1158	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1159
1160	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
1161	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1162	rec := httptest.NewRecorder()
1163
1164	handler.ServeHTTP(rec, req)
1165
1166	assert.Equal(t, http.StatusBadRequest, rec.Code)
1167	assert.Contains(t, rec.Body.String(), "invalid attachment path")
1168}
1169
1170func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
1171	configSvc := inmocks.NewMockConfigService(t)
1172	authSvc := inmocks.NewMockAuthService(t)
1173	containerSvc := inmocks.NewMockContainerService(t)
1174	secretSvc := inmocks.NewMockSecretService(t)
1175
1176	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1177
1178	body := `{not valid json`
1179	req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1180	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1181	rec := httptest.NewRecorder()
1182
1183	handler.ServeHTTP(rec, req)
1184
1185	assert.Equal(t, http.StatusBadRequest, rec.Code)
1186	assert.Contains(t, rec.Body.String(), "invalid JSON")
1187}
1188
1189func TestHandler_NotFound(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 := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1196
1197	req := httptest.NewRequest("GET", "/admin/unknown", nil)
1198	req = req.WithContext(ctxWithScopes("admin:*:*"))
1199	rec := httptest.NewRecorder()
1200
1201	handler.ServeHTTP(rec, req)
1202
1203	assert.Equal(t, http.StatusNotFound, rec.Code)
1204}
1205
1206func TestHandler_BackupsStatus(t *testing.T) {
1207	configSvc := inmocks.NewMockConfigService(t)
1208	authSvc := inmocks.NewMockAuthService(t)
1209	containerSvc := inmocks.NewMockContainerService(t)
1210	secretSvc := inmocks.NewMockSecretService(t)
1211	backupSvc := inmocks.NewMockBackupService(t)
1212
1213	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)
1214
1215	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1216
1217	req := httptest.NewRequest("GET", "/admin/backups/status", nil)
1218	req = req.WithContext(ctxWithScopes("admin:status:read"))
1219	rec := httptest.NewRecorder()
1220
1221	handler.ServeHTTP(rec, req)
1222
1223	assert.Equal(t, http.StatusOK, rec.Code)
1224	assert.Contains(t, rec.Body.String(), "app.example.com")
1225	assert.NotContains(t, rec.Body.String(), "file_path")
1226}
1227
1228func TestHandler_BackupsListDomain(t *testing.T) {
1229	configSvc := inmocks.NewMockConfigService(t)
1230	authSvc := inmocks.NewMockAuthService(t)
1231	containerSvc := inmocks.NewMockContainerService(t)
1232	secretSvc := inmocks.NewMockSecretService(t)
1233	backupSvc := inmocks.NewMockBackupService(t)
1234
1235	backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1236
1237	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1238
1239	req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)
1240	req = req.WithContext(ctxWithScopes("admin:status:read"))
1241	rec := httptest.NewRecorder()
1242
1243	handler.ServeHTTP(rec, req)
1244
1245	assert.Equal(t, http.StatusOK, rec.Code)
1246	assert.Contains(t, rec.Body.String(), "app.example.com")
1247}
1248
1249func TestHandler_BackupsRunDomain(t *testing.T) {
1250	configSvc := inmocks.NewMockConfigService(t)
1251	authSvc := inmocks.NewMockAuthService(t)
1252	containerSvc := inmocks.NewMockContainerService(t)
1253	secretSvc := inmocks.NewMockSecretService(t)
1254	backupSvc := inmocks.NewMockBackupService(t)
1255
1256	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)
1257
1258	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1259
1260	body := bytes.NewBufferString(`{"db":"postgres"}`)
1261	req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
1262	req = req.WithContext(ctxWithScopes("admin:config:write"))
1263	rec := httptest.NewRecorder()
1264
1265	handler.ServeHTTP(rec, req)
1266
1267	assert.Equal(t, http.StatusOK, rec.Code)
1268	assert.Contains(t, rec.Body.String(), "completed")
1269}
1270
1271func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
1272	configSvc := inmocks.NewMockConfigService(t)
1273	authSvc := inmocks.NewMockAuthService(t)
1274	containerSvc := inmocks.NewMockContainerService(t)
1275	secretSvc := inmocks.NewMockSecretService(t)
1276	backupSvc := inmocks.NewMockBackupService(t)
1277
1278	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)
1279
1280	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1281
1282	body := bytes.NewBufferString(`{"db":"postgres"}`)
1283	req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
1284	req.ContentLength = -1
1285	req = req.WithContext(ctxWithScopes("admin:config:write"))
1286	rec := httptest.NewRecorder()
1287
1288	handler.ServeHTTP(rec, req)
1289
1290	assert.Equal(t, http.StatusOK, rec.Code)
1291	assert.Contains(t, rec.Body.String(), "completed")
1292}
1293
1294func TestHandler_BackupsDetectDomain(t *testing.T) {
1295	configSvc := inmocks.NewMockConfigService(t)
1296	authSvc := inmocks.NewMockAuthService(t)
1297	containerSvc := inmocks.NewMockContainerService(t)
1298	secretSvc := inmocks.NewMockSecretService(t)
1299	backupSvc := inmocks.NewMockBackupService(t)
1300
1301	backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
1302
1303	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1304
1305	req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
1306	req = req.WithContext(ctxWithScopes("admin:status:read"))
1307	rec := httptest.NewRecorder()
1308
1309	handler.ServeHTTP(rec, req)
1310
1311	assert.Equal(t, http.StatusOK, rec.Code)
1312	assert.Contains(t, rec.Body.String(), "postgres")
1313}
1314
1315func TestHandler_ImagesGet_ReturnsMappedList(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	createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
1322	imageSvc := &stubImageService{
1323		listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1324			return []domain.ImageInfo{
1325				{
1326					Repository: "myapp",
1327					Tag:        "latest",
1328					Size:       1024,
1329					Created:    createdAt,
1330					ID:         "sha256:abc123",
1331					Dangling:   false,
1332				},
1333				{
1334					Repository: "",
1335					Tag:        "",
1336					Size:       512,
1337					Created:    createdAt,
1338					ID:         "sha256:def456",
1339					Dangling:   true,
1340				},
1341			}, nil
1342		},
1343	}
1344
1345	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1346
1347	req := httptest.NewRequest("GET", "/admin/images", nil)
1348	req = req.WithContext(ctxWithScopes("admin:status:read"))
1349	rec := httptest.NewRecorder()
1350
1351	handler.ServeHTTP(rec, req)
1352
1353	assert.Equal(t, http.StatusOK, rec.Code)
1354
1355	var response struct {
1356		Images []struct {
1357			Repository string    `json:"repository"`
1358			Tag        string    `json:"tag"`
1359			Size       int64     `json:"size"`
1360			Created    time.Time `json:"created"`
1361			ID         string    `json:"id"`
1362			Dangling   bool      `json:"dangling"`
1363		} `json:"images"`
1364	}
1365
1366	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1367	assert.Len(t, response.Images, 2)
1368	assert.Equal(t, "myapp", response.Images[0].Repository)
1369	assert.Equal(t, "latest", response.Images[0].Tag)
1370	assert.Equal(t, int64(1024), response.Images[0].Size)
1371	assert.Equal(t, createdAt, response.Images[0].Created)
1372	assert.Equal(t, "sha256:abc123", response.Images[0].ID)
1373	assert.False(t, response.Images[0].Dangling)
1374	assert.True(t, response.Images[1].Dangling)
1375}
1376
1377func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1378	configSvc := inmocks.NewMockConfigService(t)
1379	authSvc := inmocks.NewMockAuthService(t)
1380	containerSvc := inmocks.NewMockContainerService(t)
1381	secretSvc := inmocks.NewMockSecretService(t)
1382
1383	tests := []struct {
1384		name             string
1385		body             string
1386		expectedKeepLast int
1387	}{
1388		{name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast},
1389		{name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
1390	}
1391
1392	for _, tt := range tests {
1393		t.Run(tt.name, func(t *testing.T) {
1394			called := false
1395			imageSvc := &stubImageService{
1396				pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) {
1397					called = true
1398					assert.Equal(t, tt.expectedKeepLast, keepLast)
1399					return domain.ImagePruneReport{
1400						Runtime:  domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
1401						Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
1402					}, nil
1403				},
1404			}
1405
1406			handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1407
1408			req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1409			req = req.WithContext(ctxWithScopes("admin:config:write"))
1410			rec := httptest.NewRecorder()
1411
1412			handler.ServeHTTP(rec, req)
1413
1414			assert.Equal(t, http.StatusOK, rec.Code)
1415			assert.True(t, called)
1416			assert.Contains(t, rec.Body.String(), "deleted_count")
1417			assert.Contains(t, rec.Body.String(), "tags_removed")
1418		})
1419	}
1420}
1421
1422func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1423	t.Run("list failure maps to internal server error", func(t *testing.T) {
1424		configSvc := inmocks.NewMockConfigService(t)
1425		authSvc := inmocks.NewMockAuthService(t)
1426		containerSvc := inmocks.NewMockContainerService(t)
1427		secretSvc := inmocks.NewMockSecretService(t)
1428
1429		imageSvc := &stubImageService{
1430			listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1431				return nil, errors.New("runtime list failed: confidential details")
1432			},
1433		}
1434
1435		handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1436
1437		req := httptest.NewRequest("GET", "/admin/images", nil)
1438		req = req.WithContext(ctxWithScopes("admin:status:read"))
1439		rec := httptest.NewRecorder()
1440
1441		handler.ServeHTTP(rec, req)
1442
1443		assert.Equal(t, http.StatusInternalServerError, rec.Code)
1444		assert.Contains(t, rec.Body.String(), "failed to list images")
1445		assert.NotContains(t, rec.Body.String(), "confidential details")
1446	})
1447
1448	t.Run("prune failure maps to internal server error", func(t *testing.T) {
1449		configSvc := inmocks.NewMockConfigService(t)
1450		authSvc := inmocks.NewMockAuthService(t)
1451		containerSvc := inmocks.NewMockContainerService(t)
1452		secretSvc := inmocks.NewMockSecretService(t)
1453
1454		imageSvc := &stubImageService{
1455			pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) {
1456				return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
1457			},
1458		}
1459
1460		handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1461
1462		req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
1463		req = req.WithContext(ctxWithScopes("admin:config:write"))
1464		rec := httptest.NewRecorder()
1465
1466		handler.ServeHTTP(rec, req)
1467
1468		assert.Equal(t, http.StatusInternalServerError, rec.Code)
1469		assert.Contains(t, rec.Body.String(), "failed to prune images")
1470		assert.NotContains(t, rec.Body.String(), "confidential details")
1471	})
1472}
1473
1474func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1475	t.Run("rejects negative keep_last", func(t *testing.T) {
1476		configSvc := inmocks.NewMockConfigService(t)
1477		authSvc := inmocks.NewMockAuthService(t)
1478		containerSvc := inmocks.NewMockContainerService(t)
1479		secretSvc := inmocks.NewMockSecretService(t)
1480
1481		handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1482
1483		req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
1484		req = req.WithContext(ctxWithScopes("admin:config:write"))
1485		rec := httptest.NewRecorder()
1486
1487		handler.ServeHTTP(rec, req)
1488
1489		assert.Equal(t, http.StatusBadRequest, rec.Code)
1490		assert.Contains(t, rec.Body.String(), "keep_last must be")
1491	})
1492
1493	t.Run("returns service unavailable when image service missing", func(t *testing.T) {
1494		configSvc := inmocks.NewMockConfigService(t)
1495		authSvc := inmocks.NewMockAuthService(t)
1496		containerSvc := inmocks.NewMockContainerService(t)
1497		secretSvc := inmocks.NewMockSecretService(t)
1498
1499		handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1500
1501		req := httptest.NewRequest("GET", "/admin/images", nil)
1502		req = req.WithContext(ctxWithScopes("admin:status:read"))
1503		rec := httptest.NewRecorder()
1504
1505		handler.ServeHTTP(rec, req)
1506
1507		assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
1508		assert.Contains(t, rec.Body.String(), "image service not available")
1509	})
1510}
1511
1512func TestHandler_Images_Authorization(t *testing.T) {
1513	configSvc := inmocks.NewMockConfigService(t)
1514	authSvc := inmocks.NewMockAuthService(t)
1515	containerSvc := inmocks.NewMockContainerService(t)
1516	secretSvc := inmocks.NewMockSecretService(t)
1517
1518	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1519
1520	t.Run("images list requires status:read", func(t *testing.T) {
1521		req := httptest.NewRequest("GET", "/admin/images", nil)
1522		req = req.WithContext(ctxWithScopes("admin:config:write"))
1523		rec := httptest.NewRecorder()
1524
1525		handler.ServeHTTP(rec, req)
1526
1527		assert.Equal(t, http.StatusForbidden, rec.Code)
1528		assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
1529	})
1530
1531	t.Run("images prune requires config:write", func(t *testing.T) {
1532		req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
1533		req = req.WithContext(ctxWithScopes("admin:status:read"))
1534		rec := httptest.NewRecorder()
1535
1536		handler.ServeHTTP(rec, req)
1537
1538		assert.Equal(t, http.StatusForbidden, rec.Code)
1539		assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
1540	})
1541}

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