v2.10.2 705579a

handler_test.go RawHistoryFolder

1package admin
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"fmt"
8	"net/http"
9	"net/http/httptest"
10	"testing"
11
12	"github.com/bnema/zerowrap"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15
16	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20func testLogger() zerowrap.Logger {
21	return zerowrap.Default()
22}
23
24func ctxWithScopes(scopes ...string) context.Context {
25	ctx := context.Background()
26	return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
27}
28
29// Routes endpoint tests
30
31func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
32	configSvc := inmocks.NewMockConfigService(t)
33	authSvc := inmocks.NewMockAuthService(t)
34	containerSvc := inmocks.NewMockContainerService(t)
35	secretSvc := inmocks.NewMockSecretService(t)
36
37	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
38
39	tests := []struct {
40		name       string
41		scopes     []string
42		wantStatus int
43	}{
44		{
45			name:       "routes read access granted",
46			scopes:     []string{"admin:routes:read"},
47			wantStatus: http.StatusOK,
48		},
49		{
50			name:       "routes wildcard access granted",
51			scopes:     []string{"admin:routes:*"},
52			wantStatus: http.StatusOK,
53		},
54		{
55			name:       "all admin access granted",
56			scopes:     []string{"admin:*:*"},
57			wantStatus: http.StatusOK,
58		},
59		{
60			name:       "only write access denied",
61			scopes:     []string{"admin:routes:write"},
62			wantStatus: http.StatusForbidden,
63		},
64		{
65			name:       "wrong resource denied",
66			scopes:     []string{"admin:secrets:read"},
67			wantStatus: http.StatusForbidden,
68		},
69		{
70			name:       "no scopes denied",
71			scopes:     []string{},
72			wantStatus: http.StatusForbidden,
73		},
74	}
75
76	for _, tt := range tests {
77		t.Run(tt.name, func(t *testing.T) {
78			if tt.wantStatus == http.StatusOK {
79				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
80			}
81
82			req := httptest.NewRequest("GET", "/admin/routes", nil)
83			req = req.WithContext(ctxWithScopes(tt.scopes...))
84			rec := httptest.NewRecorder()
85
86			handler.ServeHTTP(rec, req)
87
88			assert.Equal(t, tt.wantStatus, rec.Code)
89		})
90	}
91}
92
93func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
94	configSvc := inmocks.NewMockConfigService(t)
95	authSvc := inmocks.NewMockAuthService(t)
96	containerSvc := inmocks.NewMockContainerService(t)
97	secretSvc := inmocks.NewMockSecretService(t)
98	registrySvc := inmocks.NewMockRegistryService(t)
99
100	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
101
102	routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
103
104	tests := []struct {
105		name       string
106		scopes     []string
107		wantStatus int
108	}{
109		{
110			name:       "routes write access granted",
111			scopes:     []string{"admin:routes:write"},
112			wantStatus: http.StatusCreated,
113		},
114		{
115			name:       "routes wildcard access granted",
116			scopes:     []string{"admin:routes:*"},
117			wantStatus: http.StatusCreated,
118		},
119		{
120			name:       "all admin access granted",
121			scopes:     []string{"admin:*:*"},
122			wantStatus: http.StatusCreated,
123		},
124		{
125			name:       "only read access denied",
126			scopes:     []string{"admin:routes:read"},
127			wantStatus: http.StatusForbidden,
128		},
129		{
130			name:       "wrong resource denied",
131			scopes:     []string{"admin:secrets:write"},
132			wantStatus: http.StatusForbidden,
133		},
134	}
135
136	for _, tt := range tests {
137		t.Run(tt.name, func(t *testing.T) {
138			if tt.wantStatus == http.StatusCreated {
139				configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
140				registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe()
141			}
142
143			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
144			req = req.WithContext(ctxWithScopes(tt.scopes...))
145			rec := httptest.NewRecorder()
146
147			handler.ServeHTTP(rec, req)
148
149			assert.Equal(t, tt.wantStatus, rec.Code)
150		})
151	}
152}
153
154func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
155	configSvc := inmocks.NewMockConfigService(t)
156	authSvc := inmocks.NewMockAuthService(t)
157	containerSvc := inmocks.NewMockContainerService(t)
158	secretSvc := inmocks.NewMockSecretService(t)
159	registrySvc := inmocks.NewMockRegistryService(t)
160
161	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
162
163	routeJSON := `{"image": "myapp:v2"}`
164
165	tests := []struct {
166		name       string
167		scopes     []string
168		wantStatus int
169	}{
170		{
171			name:       "routes write access granted",
172			scopes:     []string{"admin:routes:write"},
173			wantStatus: http.StatusOK,
174		},
175		{
176			name:       "only read access denied",
177			scopes:     []string{"admin:routes:read"},
178			wantStatus: http.StatusForbidden,
179		},
180	}
181
182	for _, tt := range tests {
183		t.Run(tt.name, func(t *testing.T) {
184			if tt.wantStatus == http.StatusOK {
185				configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
186				registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe()
187			}
188
189			req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
190			req = req.WithContext(ctxWithScopes(tt.scopes...))
191			rec := httptest.NewRecorder()
192
193			handler.ServeHTTP(rec, req)
194
195			assert.Equal(t, tt.wantStatus, rec.Code)
196		})
197	}
198}
199
200func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
201	configSvc := inmocks.NewMockConfigService(t)
202	authSvc := inmocks.NewMockAuthService(t)
203	containerSvc := inmocks.NewMockContainerService(t)
204	secretSvc := inmocks.NewMockSecretService(t)
205
206	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
207
208	tests := []struct {
209		name       string
210		scopes     []string
211		wantStatus int
212	}{
213		{
214			name:       "routes write access granted",
215			scopes:     []string{"admin:routes:write"},
216			wantStatus: http.StatusOK,
217		},
218		{
219			name:       "only read access denied",
220			scopes:     []string{"admin:routes:read"},
221			wantStatus: http.StatusForbidden,
222		},
223	}
224
225	for _, tt := range tests {
226		t.Run(tt.name, func(t *testing.T) {
227			if tt.wantStatus == http.StatusOK {
228				configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
229			}
230
231			req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
232			req = req.WithContext(ctxWithScopes(tt.scopes...))
233			rec := httptest.NewRecorder()
234
235			handler.ServeHTTP(rec, req)
236
237			assert.Equal(t, tt.wantStatus, rec.Code)
238		})
239	}
240}
241
242// Secrets endpoint tests
243
244func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
245	configSvc := inmocks.NewMockConfigService(t)
246	authSvc := inmocks.NewMockAuthService(t)
247	containerSvc := inmocks.NewMockContainerService(t)
248	secretSvc := inmocks.NewMockSecretService(t)
249
250	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
251
252	tests := []struct {
253		name       string
254		scopes     []string
255		wantStatus int
256	}{
257		{
258			name:       "secrets read access granted",
259			scopes:     []string{"admin:secrets:read"},
260			wantStatus: http.StatusOK,
261		},
262		{
263			name:       "secrets wildcard access granted",
264			scopes:     []string{"admin:secrets:*"},
265			wantStatus: http.StatusOK,
266		},
267		{
268			name:       "all admin access granted",
269			scopes:     []string{"admin:*:*"},
270			wantStatus: http.StatusOK,
271		},
272		{
273			name:       "only write access denied",
274			scopes:     []string{"admin:secrets:write"},
275			wantStatus: http.StatusForbidden,
276		},
277		{
278			name:       "wrong resource denied",
279			scopes:     []string{"admin:routes:read"},
280			wantStatus: http.StatusForbidden,
281		},
282	}
283
284	for _, tt := range tests {
285		t.Run(tt.name, func(t *testing.T) {
286			if tt.wantStatus == http.StatusOK {
287				secretSvc.EXPECT().ListKeysWithAttachments(mock.Anything, "app.example.com").Return([]string{}, nil, nil).Maybe()
288			}
289
290			req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
291			req = req.WithContext(ctxWithScopes(tt.scopes...))
292			rec := httptest.NewRecorder()
293
294			handler.ServeHTTP(rec, req)
295
296			assert.Equal(t, tt.wantStatus, rec.Code)
297		})
298	}
299}
300
301func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
302	configSvc := inmocks.NewMockConfigService(t)
303	authSvc := inmocks.NewMockAuthService(t)
304	containerSvc := inmocks.NewMockContainerService(t)
305	secretSvc := inmocks.NewMockSecretService(t)
306
307	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
308
309	secretsJSON := `{"API_KEY": "secret123"}`
310
311	tests := []struct {
312		name       string
313		scopes     []string
314		wantStatus int
315	}{
316		{
317			name:       "secrets write access granted",
318			scopes:     []string{"admin:secrets:write"},
319			wantStatus: http.StatusOK,
320		},
321		{
322			name:       "only read access denied",
323			scopes:     []string{"admin:secrets:read"},
324			wantStatus: http.StatusForbidden,
325		},
326		{
327			name:       "wrong resource denied",
328			scopes:     []string{"admin:routes:write"},
329			wantStatus: http.StatusForbidden,
330		},
331	}
332
333	for _, tt := range tests {
334		t.Run(tt.name, func(t *testing.T) {
335			if tt.wantStatus == http.StatusOK {
336				secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe()
337			}
338
339			req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
340			req = req.WithContext(ctxWithScopes(tt.scopes...))
341			rec := httptest.NewRecorder()
342
343			handler.ServeHTTP(rec, req)
344
345			assert.Equal(t, tt.wantStatus, rec.Code)
346		})
347	}
348}
349
350func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
351	configSvc := inmocks.NewMockConfigService(t)
352	authSvc := inmocks.NewMockAuthService(t)
353	containerSvc := inmocks.NewMockContainerService(t)
354	secretSvc := inmocks.NewMockSecretService(t)
355
356	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
357
358	tests := []struct {
359		name       string
360		scopes     []string
361		wantStatus int
362	}{
363		{
364			name:       "secrets write access granted",
365			scopes:     []string{"admin:secrets:write"},
366			wantStatus: http.StatusOK,
367		},
368		{
369			name:       "only read access denied",
370			scopes:     []string{"admin:secrets:read"},
371			wantStatus: http.StatusForbidden,
372		},
373	}
374
375	for _, tt := range tests {
376		t.Run(tt.name, func(t *testing.T) {
377			if tt.wantStatus == http.StatusOK {
378				secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe()
379			}
380
381			req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
382			req = req.WithContext(ctxWithScopes(tt.scopes...))
383			rec := httptest.NewRecorder()
384
385			handler.ServeHTTP(rec, req)
386
387			assert.Equal(t, tt.wantStatus, rec.Code)
388		})
389	}
390}
391
392// Status endpoint tests
393
394func TestHandler_Status_RequiresReadScope(t *testing.T) {
395	configSvc := inmocks.NewMockConfigService(t)
396	authSvc := inmocks.NewMockAuthService(t)
397	containerSvc := inmocks.NewMockContainerService(t)
398	secretSvc := inmocks.NewMockSecretService(t)
399
400	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
401
402	tests := []struct {
403		name       string
404		scopes     []string
405		wantStatus int
406	}{
407		{
408			name:       "status read access granted",
409			scopes:     []string{"admin:status:read"},
410			wantStatus: http.StatusOK,
411		},
412		{
413			name:       "all admin access granted",
414			scopes:     []string{"admin:*:*"},
415			wantStatus: http.StatusOK,
416		},
417		{
418			name:       "wrong resource denied",
419			scopes:     []string{"admin:routes:read"},
420			wantStatus: http.StatusForbidden,
421		},
422	}
423
424	for _, tt := range tests {
425		t.Run(tt.name, func(t *testing.T) {
426			if tt.wantStatus == http.StatusOK {
427				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
428				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
429				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
430				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
431				configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
432				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
433			}
434
435			req := httptest.NewRequest("GET", "/admin/status", nil)
436			req = req.WithContext(ctxWithScopes(tt.scopes...))
437			rec := httptest.NewRecorder()
438
439			handler.ServeHTTP(rec, req)
440
441			assert.Equal(t, tt.wantStatus, rec.Code)
442		})
443	}
444}
445
446// Config endpoint tests
447
448func TestHandler_Config_RequiresReadScope(t *testing.T) {
449	configSvc := inmocks.NewMockConfigService(t)
450	authSvc := inmocks.NewMockAuthService(t)
451	containerSvc := inmocks.NewMockContainerService(t)
452	secretSvc := inmocks.NewMockSecretService(t)
453
454	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
455
456	tests := []struct {
457		name       string
458		scopes     []string
459		wantStatus int
460	}{
461		{
462			name:       "config read access granted",
463			scopes:     []string{"admin:config:read"},
464			wantStatus: http.StatusOK,
465		},
466		{
467			name:       "all admin access granted",
468			scopes:     []string{"admin:*:*"},
469			wantStatus: http.StatusOK,
470		},
471		{
472			name:       "wrong resource denied",
473			scopes:     []string{"admin:routes:read"},
474			wantStatus: http.StatusForbidden,
475		},
476	}
477
478	for _, tt := range tests {
479		t.Run(tt.name, func(t *testing.T) {
480			if tt.wantStatus == http.StatusOK {
481				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
482				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
483				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
484				configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
485				configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
486				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
487				configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
488				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
489				configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
490			}
491
492			req := httptest.NewRequest("GET", "/admin/config", nil)
493			req = req.WithContext(ctxWithScopes(tt.scopes...))
494			rec := httptest.NewRecorder()
495
496			handler.ServeHTTP(rec, req)
497
498			assert.Equal(t, tt.wantStatus, rec.Code)
499		})
500	}
501}
502
503// Reload endpoint tests
504
505func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
506	configSvc := inmocks.NewMockConfigService(t)
507	authSvc := inmocks.NewMockAuthService(t)
508	containerSvc := inmocks.NewMockContainerService(t)
509	secretSvc := inmocks.NewMockSecretService(t)
510
511	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
512
513	tests := []struct {
514		name       string
515		scopes     []string
516		wantStatus int
517	}{
518		{
519			name:       "config write access granted",
520			scopes:     []string{"admin:config:write"},
521			wantStatus: http.StatusOK,
522		},
523		{
524			name:       "all admin access granted",
525			scopes:     []string{"admin:*:*"},
526			wantStatus: http.StatusOK,
527		},
528		{
529			name:       "only read access denied",
530			scopes:     []string{"admin:config:read"},
531			wantStatus: http.StatusForbidden,
532		},
533		{
534			name:       "wrong resource denied",
535			scopes:     []string{"admin:routes:write"},
536			wantStatus: http.StatusForbidden,
537		},
538	}
539
540	for _, tt := range tests {
541		t.Run(tt.name, func(t *testing.T) {
542			if tt.wantStatus == http.StatusOK {
543				configSvc.EXPECT().Reload(mock.Anything).Return(nil).Maybe()
544			}
545
546			req := httptest.NewRequest("POST", "/admin/reload", nil)
547			req = req.WithContext(ctxWithScopes(tt.scopes...))
548			rec := httptest.NewRecorder()
549
550			handler.ServeHTTP(rec, req)
551
552			assert.Equal(t, tt.wantStatus, rec.Code)
553		})
554	}
555}
556
557// Functional tests
558
559func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
560	configSvc := inmocks.NewMockConfigService(t)
561	authSvc := inmocks.NewMockAuthService(t)
562	containerSvc := inmocks.NewMockContainerService(t)
563	secretSvc := inmocks.NewMockSecretService(t)
564
565	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
566
567	expectedRoutes := []domain.Route{
568		{Domain: "app1.example.com", Image: "app1:latest"},
569		{Domain: "app2.example.com", Image: "app2:v1.0"},
570	}
571	configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
572
573	req := httptest.NewRequest("GET", "/admin/routes", nil)
574	req = req.WithContext(ctxWithScopes("admin:routes:read"))
575	rec := httptest.NewRecorder()
576
577	handler.ServeHTTP(rec, req)
578
579	assert.Equal(t, http.StatusOK, rec.Code)
580
581	var response struct {
582		Routes []struct {
583			Domain string `json:"domain"`
584			Image  string `json:"image"`
585			HTTPS  bool   `json:"https"`
586		} `json:"routes"`
587	}
588	err := json.NewDecoder(rec.Body).Decode(&response)
589	assert.NoError(t, err)
590
591	assert.Len(t, response.Routes, 2)
592}
593
594func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
595	configSvc := inmocks.NewMockConfigService(t)
596	authSvc := inmocks.NewMockAuthService(t)
597	containerSvc := inmocks.NewMockContainerService(t)
598	secretSvc := inmocks.NewMockSecretService(t)
599
600	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
601
602	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
603		Domain: "app.example.com", Image: "app:latest",
604	}, nil)
605
606	req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
607	req = req.WithContext(ctxWithScopes("admin:routes:read"))
608	rec := httptest.NewRecorder()
609
610	handler.ServeHTTP(rec, req)
611
612	assert.Equal(t, http.StatusOK, rec.Code)
613
614	var route struct {
615		Domain string `json:"domain"`
616		Image  string `json:"image"`
617		HTTPS  bool   `json:"https"`
618	}
619	err := json.NewDecoder(rec.Body).Decode(&route)
620	assert.NoError(t, err)
621	assert.Equal(t, "app.example.com", route.Domain)
622	assert.Equal(t, "app:latest", route.Image)
623}
624
625func TestHandler_RoutesGet_NotFound(t *testing.T) {
626	configSvc := inmocks.NewMockConfigService(t)
627	authSvc := inmocks.NewMockAuthService(t)
628	containerSvc := inmocks.NewMockContainerService(t)
629	secretSvc := inmocks.NewMockSecretService(t)
630
631	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
632
633	configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
634
635	req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
636	req = req.WithContext(ctxWithScopes("admin:routes:read"))
637	rec := httptest.NewRecorder()
638
639	handler.ServeHTTP(rec, req)
640
641	assert.Equal(t, http.StatusNotFound, rec.Code)
642}
643
644func TestHandler_RoutesPost_MissingFields(t *testing.T) {
645	tests := []struct {
646		name    string
647		body    string
648		mockErr error
649	}{
650		{
651			name:    "missing domain",
652			body:    `{"image": "app:latest"}`,
653			mockErr: domain.ErrRouteDomainEmpty,
654		},
655		{
656			name:    "missing image",
657			body:    `{"domain": "app.example.com"}`,
658			mockErr: domain.ErrRouteImageEmpty,
659		},
660		{
661			name:    "empty object",
662			body:    `{}`,
663			mockErr: domain.ErrRouteDomainEmpty,
664		},
665	}
666
667	for _, tt := range tests {
668		t.Run(tt.name, func(t *testing.T) {
669			configSvc := inmocks.NewMockConfigService(t)
670			authSvc := inmocks.NewMockAuthService(t)
671			containerSvc := inmocks.NewMockContainerService(t)
672			secretSvc := inmocks.NewMockSecretService(t)
673			registrySvc := inmocks.NewMockRegistryService(t)
674
675			handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
676
677			configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
678			registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
679
680			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
681			req = req.WithContext(ctxWithScopes("admin:routes:write"))
682			rec := httptest.NewRecorder()
683
684			handler.ServeHTTP(rec, req)
685
686			assert.Equal(t, http.StatusBadRequest, rec.Code)
687		})
688	}
689}
690
691func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
692	configSvc := inmocks.NewMockConfigService(t)
693	authSvc := inmocks.NewMockAuthService(t)
694	containerSvc := inmocks.NewMockContainerService(t)
695	secretSvc := inmocks.NewMockSecretService(t)
696	registrySvc := inmocks.NewMockRegistryService(t)
697
698	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
699
700	tests := []struct {
701		name         string
702		image        string
703		body         string
704		manifestName string
705		manifestRef  string
706	}{
707		{
708			name:         "tagged image not found",
709			image:        "myapp:latest",
710			body:         `{"domain": "app.example.com", "image": "myapp:latest"}`,
711			manifestName: "myapp",
712			manifestRef:  "latest",
713		},
714		{
715			name:         "digest image not found",
716			image:        "myapp@sha256:abc123",
717			body:         `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
718			manifestName: "myapp",
719			manifestRef:  "sha256:abc123",
720		},
721		{
722			name:         "implicit latest tag not found",
723			image:        "myapp",
724			body:         `{"domain": "app.example.com", "image": "myapp"}`,
725			manifestName: "myapp",
726			manifestRef:  "latest",
727		},
728	}
729
730	for _, tt := range tests {
731		t.Run(tt.name, func(t *testing.T) {
732			registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound)
733
734			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
735			req = req.WithContext(ctxWithScopes("admin:routes:write"))
736			rec := httptest.NewRecorder()
737
738			handler.ServeHTTP(rec, req)
739
740			assert.Equal(t, http.StatusBadRequest, rec.Code)
741			assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
742		})
743	}
744}
745
746func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
747	configSvc := inmocks.NewMockConfigService(t)
748	authSvc := inmocks.NewMockAuthService(t)
749	containerSvc := inmocks.NewMockContainerService(t)
750	secretSvc := inmocks.NewMockSecretService(t)
751
752	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
753
754	req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
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
763func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
764	configSvc := inmocks.NewMockConfigService(t)
765	authSvc := inmocks.NewMockAuthService(t)
766	containerSvc := inmocks.NewMockContainerService(t)
767	secretSvc := inmocks.NewMockSecretService(t)
768
769	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
770
771	req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
772	req = req.WithContext(ctxWithScopes("admin:routes:write"))
773	rec := httptest.NewRecorder()
774
775	handler.ServeHTTP(rec, req)
776
777	assert.Equal(t, http.StatusBadRequest, rec.Code)
778}
779
780func TestHandler_Secrets_MissingDomain(t *testing.T) {
781	configSvc := inmocks.NewMockConfigService(t)
782	authSvc := inmocks.NewMockAuthService(t)
783	containerSvc := inmocks.NewMockContainerService(t)
784	secretSvc := inmocks.NewMockSecretService(t)
785
786	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
787
788	req := httptest.NewRequest("GET", "/admin/secrets/", nil)
789	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
790	rec := httptest.NewRecorder()
791
792	handler.ServeHTTP(rec, req)
793
794	assert.Equal(t, http.StatusBadRequest, rec.Code)
795}
796
797func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
798	configSvc := inmocks.NewMockConfigService(t)
799	authSvc := inmocks.NewMockAuthService(t)
800	containerSvc := inmocks.NewMockContainerService(t)
801	secretSvc := inmocks.NewMockSecretService(t)
802
803	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
804
805	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
806	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
807	rec := httptest.NewRecorder()
808
809	handler.ServeHTTP(rec, req)
810
811	assert.Equal(t, http.StatusBadRequest, rec.Code)
812}
813
814func TestHandler_MethodNotAllowed(t *testing.T) {
815	configSvc := inmocks.NewMockConfigService(t)
816	authSvc := inmocks.NewMockAuthService(t)
817	containerSvc := inmocks.NewMockContainerService(t)
818	secretSvc := inmocks.NewMockSecretService(t)
819
820	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
821
822	tests := []struct {
823		method string
824		path   string
825	}{
826		{"DELETE", "/admin/status"},
827		{"PUT", "/admin/status"},
828		{"GET", "/admin/reload"},
829		{"DELETE", "/admin/reload"},
830		{"POST", "/admin/config"},
831		{"DELETE", "/admin/config"},
832	}
833
834	for _, tt := range tests {
835		t.Run(tt.method+" "+tt.path, func(t *testing.T) {
836			req := httptest.NewRequest(tt.method, tt.path, nil)
837			req = req.WithContext(ctxWithScopes("admin:*:*"))
838			rec := httptest.NewRecorder()
839
840			handler.ServeHTTP(rec, req)
841
842			assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
843		})
844	}
845}
846
847func TestHandler_Restart_InvalidDomain(t *testing.T) {
848	configSvc := inmocks.NewMockConfigService(t)
849	authSvc := inmocks.NewMockAuthService(t)
850	containerSvc := inmocks.NewMockContainerService(t)
851	secretSvc := inmocks.NewMockSecretService(t)
852
853	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
854
855	tests := []struct {
856		name   string
857		path   string
858		status int
859	}{
860		{"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest},
861		{"empty domain", "/admin/restart/", http.StatusBadRequest},
862	}
863
864	for _, tt := range tests {
865		t.Run(tt.name, func(t *testing.T) {
866			req := httptest.NewRequest("POST", tt.path, nil)
867			req = req.WithContext(ctxWithScopes("admin:config:write"))
868			rec := httptest.NewRecorder()
869
870			handler.ServeHTTP(rec, req)
871
872			assert.Equal(t, tt.status, rec.Code)
873		})
874	}
875}
876
877func TestHandler_Restart_ContainerNotFound(t *testing.T) {
878	configSvc := inmocks.NewMockConfigService(t)
879	authSvc := inmocks.NewMockAuthService(t)
880	containerSvc := inmocks.NewMockContainerService(t)
881	secretSvc := inmocks.NewMockSecretService(t)
882
883	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
884
885	containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
886
887	req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil)
888	req = req.WithContext(ctxWithScopes("admin:config:write"))
889	rec := httptest.NewRecorder()
890
891	handler.ServeHTTP(rec, req)
892
893	assert.Equal(t, http.StatusNotFound, rec.Code)
894}
895
896func TestHandler_Restart_GenericError(t *testing.T) {
897	configSvc := inmocks.NewMockConfigService(t)
898	authSvc := inmocks.NewMockAuthService(t)
899	containerSvc := inmocks.NewMockContainerService(t)
900	secretSvc := inmocks.NewMockSecretService(t)
901
902	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
903
904	containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
905
906	req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil)
907	req = req.WithContext(ctxWithScopes("admin:config:write"))
908	rec := httptest.NewRecorder()
909
910	handler.ServeHTTP(rec, req)
911
912	assert.Equal(t, http.StatusInternalServerError, rec.Code)
913	// Verify internal error details are NOT leaked
914	assert.NotContains(t, rec.Body.String(), "docker daemon error")
915	assert.NotContains(t, rec.Body.String(), "secret details")
916}
917
918func TestHandler_Restart_Success(t *testing.T) {
919	configSvc := inmocks.NewMockConfigService(t)
920	authSvc := inmocks.NewMockAuthService(t)
921	containerSvc := inmocks.NewMockContainerService(t)
922	secretSvc := inmocks.NewMockSecretService(t)
923
924	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
925
926	tests := []struct {
927		name            string
928		path            string
929		withAttachments bool
930	}{
931		{
932			name:            "without attachments",
933			path:            "/admin/restart/test.example.com",
934			withAttachments: false,
935		},
936		{
937			name:            "with attachments",
938			path:            "/admin/restart/test.example.com?attachments=true",
939			withAttachments: true,
940		},
941	}
942
943	for _, tt := range tests {
944		t.Run(tt.name, func(t *testing.T) {
945			containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
946
947			req := httptest.NewRequest("POST", tt.path, nil)
948			req = req.WithContext(ctxWithScopes("admin:config:write"))
949			rec := httptest.NewRecorder()
950
951			handler.ServeHTTP(rec, req)
952
953			assert.Equal(t, http.StatusOK, rec.Code)
954			var response struct {
955				Status string `json:"status"`
956				Domain string `json:"domain"`
957			}
958			assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
959			assert.Equal(t, "restarted", response.Status)
960			assert.Equal(t, "test.example.com", response.Domain)
961		})
962	}
963}
964
965func TestHandler_Tags_InvalidRepository(t *testing.T) {
966	configSvc := inmocks.NewMockConfigService(t)
967	authSvc := inmocks.NewMockAuthService(t)
968	containerSvc := inmocks.NewMockContainerService(t)
969	secretSvc := inmocks.NewMockSecretService(t)
970
971	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
972
973	tests := []struct {
974		name   string
975		path   string
976		status int
977	}{
978		{"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
979		{"empty repo", "/admin/tags/", http.StatusBadRequest},
980	}
981
982	for _, tt := range tests {
983		t.Run(tt.name, func(t *testing.T) {
984			req := httptest.NewRequest("GET", tt.path, nil)
985			req = req.WithContext(ctxWithScopes("admin:status:read"))
986			rec := httptest.NewRecorder()
987
988			handler.ServeHTTP(rec, req)
989
990			assert.Equal(t, tt.status, rec.Code)
991		})
992	}
993}
994
995func TestHandler_Tags_Success(t *testing.T) {
996	configSvc := inmocks.NewMockConfigService(t)
997	authSvc := inmocks.NewMockAuthService(t)
998	containerSvc := inmocks.NewMockContainerService(t)
999	secretSvc := inmocks.NewMockSecretService(t)
1000	registrySvc := inmocks.NewMockRegistryService(t)
1001
1002	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1003
1004	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1005
1006	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1007	req = req.WithContext(ctxWithScopes("admin:status:read"))
1008	rec := httptest.NewRecorder()
1009
1010	handler.ServeHTTP(rec, req)
1011
1012	assert.Equal(t, http.StatusOK, rec.Code)
1013	var response struct {
1014		Repository string   `json:"repository"`
1015		Tags       []string `json:"tags"`
1016	}
1017	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1018	assert.Equal(t, "myapp", response.Repository)
1019	assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1020}
1021
1022func TestHandler_Tags_Error(t *testing.T) {
1023	configSvc := inmocks.NewMockConfigService(t)
1024	authSvc := inmocks.NewMockAuthService(t)
1025	containerSvc := inmocks.NewMockContainerService(t)
1026	secretSvc := inmocks.NewMockSecretService(t)
1027	registrySvc := inmocks.NewMockRegistryService(t)
1028
1029	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1030
1031	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1032
1033	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1034	req = req.WithContext(ctxWithScopes("admin:status:read"))
1035	rec := httptest.NewRecorder()
1036
1037	handler.ServeHTTP(rec, req)
1038
1039	assert.Equal(t, http.StatusInternalServerError, rec.Code)
1040	assert.NotContains(t, rec.Body.String(), "registry error")
1041}
1042
1043func TestHandler_NotFound(t *testing.T) {
1044	configSvc := inmocks.NewMockConfigService(t)
1045	authSvc := inmocks.NewMockAuthService(t)
1046	containerSvc := inmocks.NewMockContainerService(t)
1047	secretSvc := inmocks.NewMockSecretService(t)
1048
1049	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1050
1051	req := httptest.NewRequest("GET", "/admin/unknown", nil)
1052	req = req.WithContext(ctxWithScopes("admin:*:*"))
1053	rec := httptest.NewRecorder()
1054
1055	handler.ServeHTTP(rec, req)
1056
1057	assert.Equal(t, http.StatusNotFound, rec.Code)
1058}

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