v2.13.0 7748089

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// Attachment secrets endpoint tests
393
394func TestHandler_AttachmentSecretsPost(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	secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
403
404	body := `{"REDIS_URL": "redis://localhost:6379"}`
405	req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
406	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
407	rec := httptest.NewRecorder()
408
409	handler.ServeHTTP(rec, req)
410
411	assert.Equal(t, http.StatusOK, rec.Code)
412
413	var response struct {
414		Status string `json:"status"`
415	}
416	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
417	assert.Equal(t, "updated", response.Status)
418}
419
420func TestHandler_AttachmentSecretsDelete(t *testing.T) {
421	configSvc := inmocks.NewMockConfigService(t)
422	authSvc := inmocks.NewMockAuthService(t)
423	containerSvc := inmocks.NewMockContainerService(t)
424	secretSvc := inmocks.NewMockSecretService(t)
425
426	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
427
428	secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
429
430	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
431	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
432	rec := httptest.NewRecorder()
433
434	handler.ServeHTTP(rec, req)
435
436	assert.Equal(t, http.StatusOK, rec.Code)
437
438	var response struct {
439		Status string `json:"status"`
440	}
441	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
442	assert.Equal(t, "deleted", response.Status)
443}
444
445// Status endpoint tests
446
447func TestHandler_Status_RequiresReadScope(t *testing.T) {
448	configSvc := inmocks.NewMockConfigService(t)
449	authSvc := inmocks.NewMockAuthService(t)
450	containerSvc := inmocks.NewMockContainerService(t)
451	secretSvc := inmocks.NewMockSecretService(t)
452
453	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
454
455	tests := []struct {
456		name       string
457		scopes     []string
458		wantStatus int
459	}{
460		{
461			name:       "status read access granted",
462			scopes:     []string{"admin:status:read"},
463			wantStatus: http.StatusOK,
464		},
465		{
466			name:       "all admin access granted",
467			scopes:     []string{"admin:*:*"},
468			wantStatus: http.StatusOK,
469		},
470		{
471			name:       "wrong resource denied",
472			scopes:     []string{"admin:routes:read"},
473			wantStatus: http.StatusForbidden,
474		},
475	}
476
477	for _, tt := range tests {
478		t.Run(tt.name, func(t *testing.T) {
479			if tt.wantStatus == http.StatusOK {
480				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
481				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
482				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
483				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
484				configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
485				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
486			}
487
488			req := httptest.NewRequest("GET", "/admin/status", nil)
489			req = req.WithContext(ctxWithScopes(tt.scopes...))
490			rec := httptest.NewRecorder()
491
492			handler.ServeHTTP(rec, req)
493
494			assert.Equal(t, tt.wantStatus, rec.Code)
495		})
496	}
497}
498
499// Config endpoint tests
500
501func TestHandler_Config_RequiresReadScope(t *testing.T) {
502	configSvc := inmocks.NewMockConfigService(t)
503	authSvc := inmocks.NewMockAuthService(t)
504	containerSvc := inmocks.NewMockContainerService(t)
505	secretSvc := inmocks.NewMockSecretService(t)
506
507	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
508
509	tests := []struct {
510		name       string
511		scopes     []string
512		wantStatus int
513	}{
514		{
515			name:       "config read access granted",
516			scopes:     []string{"admin:config:read"},
517			wantStatus: http.StatusOK,
518		},
519		{
520			name:       "all admin access granted",
521			scopes:     []string{"admin:*:*"},
522			wantStatus: http.StatusOK,
523		},
524		{
525			name:       "wrong resource denied",
526			scopes:     []string{"admin:routes:read"},
527			wantStatus: http.StatusForbidden,
528		},
529	}
530
531	for _, tt := range tests {
532		t.Run(tt.name, func(t *testing.T) {
533			if tt.wantStatus == http.StatusOK {
534				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
535				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
536				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
537				configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
538				configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
539				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
540				configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
541				configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
542				configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
543			}
544
545			req := httptest.NewRequest("GET", "/admin/config", nil)
546			req = req.WithContext(ctxWithScopes(tt.scopes...))
547			rec := httptest.NewRecorder()
548
549			handler.ServeHTTP(rec, req)
550
551			assert.Equal(t, tt.wantStatus, rec.Code)
552		})
553	}
554}
555
556// Reload endpoint tests
557
558func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
559	configSvc := inmocks.NewMockConfigService(t)
560	authSvc := inmocks.NewMockAuthService(t)
561	containerSvc := inmocks.NewMockContainerService(t)
562	secretSvc := inmocks.NewMockSecretService(t)
563
564	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
565
566	tests := []struct {
567		name       string
568		scopes     []string
569		wantStatus int
570	}{
571		{
572			name:       "config write access granted",
573			scopes:     []string{"admin:config:write"},
574			wantStatus: http.StatusOK,
575		},
576		{
577			name:       "all admin access granted",
578			scopes:     []string{"admin:*:*"},
579			wantStatus: http.StatusOK,
580		},
581		{
582			name:       "only read access denied",
583			scopes:     []string{"admin:config:read"},
584			wantStatus: http.StatusForbidden,
585		},
586		{
587			name:       "wrong resource denied",
588			scopes:     []string{"admin:routes:write"},
589			wantStatus: http.StatusForbidden,
590		},
591	}
592
593	for _, tt := range tests {
594		t.Run(tt.name, func(t *testing.T) {
595			if tt.wantStatus == http.StatusOK {
596				configSvc.EXPECT().Reload(mock.Anything).Return(nil).Maybe()
597			}
598
599			req := httptest.NewRequest("POST", "/admin/reload", nil)
600			req = req.WithContext(ctxWithScopes(tt.scopes...))
601			rec := httptest.NewRecorder()
602
603			handler.ServeHTTP(rec, req)
604
605			assert.Equal(t, tt.wantStatus, rec.Code)
606		})
607	}
608}
609
610// Functional tests
611
612func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
613	configSvc := inmocks.NewMockConfigService(t)
614	authSvc := inmocks.NewMockAuthService(t)
615	containerSvc := inmocks.NewMockContainerService(t)
616	secretSvc := inmocks.NewMockSecretService(t)
617
618	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
619
620	expectedRoutes := []domain.Route{
621		{Domain: "app1.example.com", Image: "app1:latest"},
622		{Domain: "app2.example.com", Image: "app2:v1.0"},
623	}
624	configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
625
626	req := httptest.NewRequest("GET", "/admin/routes", nil)
627	req = req.WithContext(ctxWithScopes("admin:routes:read"))
628	rec := httptest.NewRecorder()
629
630	handler.ServeHTTP(rec, req)
631
632	assert.Equal(t, http.StatusOK, rec.Code)
633
634	var response struct {
635		Routes []struct {
636			Domain string `json:"domain"`
637			Image  string `json:"image"`
638			HTTPS  bool   `json:"https"`
639		} `json:"routes"`
640	}
641	err := json.NewDecoder(rec.Body).Decode(&response)
642	assert.NoError(t, err)
643
644	assert.Len(t, response.Routes, 2)
645}
646
647func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
648	configSvc := inmocks.NewMockConfigService(t)
649	authSvc := inmocks.NewMockAuthService(t)
650	containerSvc := inmocks.NewMockContainerService(t)
651	secretSvc := inmocks.NewMockSecretService(t)
652
653	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
654
655	configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
656		Domain: "app.example.com", Image: "app:latest",
657	}, nil)
658
659	req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
660	req = req.WithContext(ctxWithScopes("admin:routes:read"))
661	rec := httptest.NewRecorder()
662
663	handler.ServeHTTP(rec, req)
664
665	assert.Equal(t, http.StatusOK, rec.Code)
666
667	var route struct {
668		Domain string `json:"domain"`
669		Image  string `json:"image"`
670		HTTPS  bool   `json:"https"`
671	}
672	err := json.NewDecoder(rec.Body).Decode(&route)
673	assert.NoError(t, err)
674	assert.Equal(t, "app.example.com", route.Domain)
675	assert.Equal(t, "app:latest", route.Image)
676}
677
678func TestHandler_RoutesGet_NotFound(t *testing.T) {
679	configSvc := inmocks.NewMockConfigService(t)
680	authSvc := inmocks.NewMockAuthService(t)
681	containerSvc := inmocks.NewMockContainerService(t)
682	secretSvc := inmocks.NewMockSecretService(t)
683
684	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
685
686	configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
687
688	req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
689	req = req.WithContext(ctxWithScopes("admin:routes:read"))
690	rec := httptest.NewRecorder()
691
692	handler.ServeHTTP(rec, req)
693
694	assert.Equal(t, http.StatusNotFound, rec.Code)
695}
696
697func TestHandler_RoutesPost_MissingFields(t *testing.T) {
698	tests := []struct {
699		name    string
700		body    string
701		mockErr error
702	}{
703		{
704			name:    "missing domain",
705			body:    `{"image": "app:latest"}`,
706			mockErr: domain.ErrRouteDomainEmpty,
707		},
708		{
709			name:    "missing image",
710			body:    `{"domain": "app.example.com"}`,
711			mockErr: domain.ErrRouteImageEmpty,
712		},
713		{
714			name:    "empty object",
715			body:    `{}`,
716			mockErr: domain.ErrRouteDomainEmpty,
717		},
718	}
719
720	for _, tt := range tests {
721		t.Run(tt.name, func(t *testing.T) {
722			configSvc := inmocks.NewMockConfigService(t)
723			authSvc := inmocks.NewMockAuthService(t)
724			containerSvc := inmocks.NewMockContainerService(t)
725			secretSvc := inmocks.NewMockSecretService(t)
726			registrySvc := inmocks.NewMockRegistryService(t)
727
728			handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
729
730			configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
731			registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
732
733			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
734			req = req.WithContext(ctxWithScopes("admin:routes:write"))
735			rec := httptest.NewRecorder()
736
737			handler.ServeHTTP(rec, req)
738
739			assert.Equal(t, http.StatusBadRequest, rec.Code)
740		})
741	}
742}
743
744func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
745	configSvc := inmocks.NewMockConfigService(t)
746	authSvc := inmocks.NewMockAuthService(t)
747	containerSvc := inmocks.NewMockContainerService(t)
748	secretSvc := inmocks.NewMockSecretService(t)
749	registrySvc := inmocks.NewMockRegistryService(t)
750
751	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
752
753	tests := []struct {
754		name         string
755		image        string
756		body         string
757		manifestName string
758		manifestRef  string
759	}{
760		{
761			name:         "tagged image not found",
762			image:        "myapp:latest",
763			body:         `{"domain": "app.example.com", "image": "myapp:latest"}`,
764			manifestName: "myapp",
765			manifestRef:  "latest",
766		},
767		{
768			name:         "digest image not found",
769			image:        "myapp@sha256:abc123",
770			body:         `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
771			manifestName: "myapp",
772			manifestRef:  "sha256:abc123",
773		},
774		{
775			name:         "implicit latest tag not found",
776			image:        "myapp",
777			body:         `{"domain": "app.example.com", "image": "myapp"}`,
778			manifestName: "myapp",
779			manifestRef:  "latest",
780		},
781	}
782
783	for _, tt := range tests {
784		t.Run(tt.name, func(t *testing.T) {
785			registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound)
786
787			req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
788			req = req.WithContext(ctxWithScopes("admin:routes:write"))
789			rec := httptest.NewRecorder()
790
791			handler.ServeHTTP(rec, req)
792
793			assert.Equal(t, http.StatusBadRequest, rec.Code)
794			assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
795		})
796	}
797}
798
799func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
800	configSvc := inmocks.NewMockConfigService(t)
801	authSvc := inmocks.NewMockAuthService(t)
802	containerSvc := inmocks.NewMockContainerService(t)
803	secretSvc := inmocks.NewMockSecretService(t)
804
805	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
806
807	req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
808	req = req.WithContext(ctxWithScopes("admin:routes:write"))
809	rec := httptest.NewRecorder()
810
811	handler.ServeHTTP(rec, req)
812
813	assert.Equal(t, http.StatusBadRequest, rec.Code)
814}
815
816func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
817	configSvc := inmocks.NewMockConfigService(t)
818	authSvc := inmocks.NewMockAuthService(t)
819	containerSvc := inmocks.NewMockContainerService(t)
820	secretSvc := inmocks.NewMockSecretService(t)
821
822	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
823
824	req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
825	req = req.WithContext(ctxWithScopes("admin:routes:write"))
826	rec := httptest.NewRecorder()
827
828	handler.ServeHTTP(rec, req)
829
830	assert.Equal(t, http.StatusBadRequest, rec.Code)
831}
832
833func TestHandler_Secrets_MissingDomain(t *testing.T) {
834	configSvc := inmocks.NewMockConfigService(t)
835	authSvc := inmocks.NewMockAuthService(t)
836	containerSvc := inmocks.NewMockContainerService(t)
837	secretSvc := inmocks.NewMockSecretService(t)
838
839	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
840
841	req := httptest.NewRequest("GET", "/admin/secrets/", nil)
842	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
843	rec := httptest.NewRecorder()
844
845	handler.ServeHTTP(rec, req)
846
847	assert.Equal(t, http.StatusBadRequest, rec.Code)
848}
849
850func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
851	configSvc := inmocks.NewMockConfigService(t)
852	authSvc := inmocks.NewMockAuthService(t)
853	containerSvc := inmocks.NewMockContainerService(t)
854	secretSvc := inmocks.NewMockSecretService(t)
855
856	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
857
858	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
859	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
860	rec := httptest.NewRecorder()
861
862	handler.ServeHTTP(rec, req)
863
864	assert.Equal(t, http.StatusBadRequest, rec.Code)
865}
866
867func TestHandler_MethodNotAllowed(t *testing.T) {
868	configSvc := inmocks.NewMockConfigService(t)
869	authSvc := inmocks.NewMockAuthService(t)
870	containerSvc := inmocks.NewMockContainerService(t)
871	secretSvc := inmocks.NewMockSecretService(t)
872
873	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
874
875	tests := []struct {
876		method string
877		path   string
878	}{
879		{"DELETE", "/admin/status"},
880		{"PUT", "/admin/status"},
881		{"GET", "/admin/reload"},
882		{"DELETE", "/admin/reload"},
883		{"POST", "/admin/config"},
884		{"DELETE", "/admin/config"},
885	}
886
887	for _, tt := range tests {
888		t.Run(tt.method+" "+tt.path, func(t *testing.T) {
889			req := httptest.NewRequest(tt.method, tt.path, nil)
890			req = req.WithContext(ctxWithScopes("admin:*:*"))
891			rec := httptest.NewRecorder()
892
893			handler.ServeHTTP(rec, req)
894
895			assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
896		})
897	}
898}
899
900func TestHandler_Restart_InvalidDomain(t *testing.T) {
901	configSvc := inmocks.NewMockConfigService(t)
902	authSvc := inmocks.NewMockAuthService(t)
903	containerSvc := inmocks.NewMockContainerService(t)
904	secretSvc := inmocks.NewMockSecretService(t)
905
906	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
907
908	tests := []struct {
909		name   string
910		path   string
911		status int
912	}{
913		{"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest},
914		{"empty domain", "/admin/restart/", http.StatusBadRequest},
915	}
916
917	for _, tt := range tests {
918		t.Run(tt.name, func(t *testing.T) {
919			req := httptest.NewRequest("POST", tt.path, nil)
920			req = req.WithContext(ctxWithScopes("admin:config:write"))
921			rec := httptest.NewRecorder()
922
923			handler.ServeHTTP(rec, req)
924
925			assert.Equal(t, tt.status, rec.Code)
926		})
927	}
928}
929
930func TestHandler_Restart_ContainerNotFound(t *testing.T) {
931	configSvc := inmocks.NewMockConfigService(t)
932	authSvc := inmocks.NewMockAuthService(t)
933	containerSvc := inmocks.NewMockContainerService(t)
934	secretSvc := inmocks.NewMockSecretService(t)
935
936	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
937
938	containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
939
940	req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil)
941	req = req.WithContext(ctxWithScopes("admin:config:write"))
942	rec := httptest.NewRecorder()
943
944	handler.ServeHTTP(rec, req)
945
946	assert.Equal(t, http.StatusNotFound, rec.Code)
947}
948
949func TestHandler_Restart_GenericError(t *testing.T) {
950	configSvc := inmocks.NewMockConfigService(t)
951	authSvc := inmocks.NewMockAuthService(t)
952	containerSvc := inmocks.NewMockContainerService(t)
953	secretSvc := inmocks.NewMockSecretService(t)
954
955	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
956
957	containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
958
959	req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil)
960	req = req.WithContext(ctxWithScopes("admin:config:write"))
961	rec := httptest.NewRecorder()
962
963	handler.ServeHTTP(rec, req)
964
965	assert.Equal(t, http.StatusInternalServerError, rec.Code)
966	// Verify internal error details are NOT leaked
967	assert.NotContains(t, rec.Body.String(), "docker daemon error")
968	assert.NotContains(t, rec.Body.String(), "secret details")
969}
970
971func TestHandler_Restart_Success(t *testing.T) {
972	configSvc := inmocks.NewMockConfigService(t)
973	authSvc := inmocks.NewMockAuthService(t)
974	containerSvc := inmocks.NewMockContainerService(t)
975	secretSvc := inmocks.NewMockSecretService(t)
976
977	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
978
979	tests := []struct {
980		name            string
981		path            string
982		withAttachments bool
983	}{
984		{
985			name:            "without attachments",
986			path:            "/admin/restart/test.example.com",
987			withAttachments: false,
988		},
989		{
990			name:            "with attachments",
991			path:            "/admin/restart/test.example.com?attachments=true",
992			withAttachments: true,
993		},
994	}
995
996	for _, tt := range tests {
997		t.Run(tt.name, func(t *testing.T) {
998			containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
999
1000			req := httptest.NewRequest("POST", tt.path, nil)
1001			req = req.WithContext(ctxWithScopes("admin:config:write"))
1002			rec := httptest.NewRecorder()
1003
1004			handler.ServeHTTP(rec, req)
1005
1006			assert.Equal(t, http.StatusOK, rec.Code)
1007			var response struct {
1008				Status string `json:"status"`
1009				Domain string `json:"domain"`
1010			}
1011			assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1012			assert.Equal(t, "restarted", response.Status)
1013			assert.Equal(t, "test.example.com", response.Domain)
1014		})
1015	}
1016}
1017
1018func TestHandler_Tags_InvalidRepository(t *testing.T) {
1019	configSvc := inmocks.NewMockConfigService(t)
1020	authSvc := inmocks.NewMockAuthService(t)
1021	containerSvc := inmocks.NewMockContainerService(t)
1022	secretSvc := inmocks.NewMockSecretService(t)
1023
1024	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1025
1026	tests := []struct {
1027		name   string
1028		path   string
1029		status int
1030	}{
1031		{"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
1032		{"empty repo", "/admin/tags/", http.StatusBadRequest},
1033	}
1034
1035	for _, tt := range tests {
1036		t.Run(tt.name, func(t *testing.T) {
1037			req := httptest.NewRequest("GET", tt.path, nil)
1038			req = req.WithContext(ctxWithScopes("admin:status:read"))
1039			rec := httptest.NewRecorder()
1040
1041			handler.ServeHTTP(rec, req)
1042
1043			assert.Equal(t, tt.status, rec.Code)
1044		})
1045	}
1046}
1047
1048func TestHandler_Tags_Success(t *testing.T) {
1049	configSvc := inmocks.NewMockConfigService(t)
1050	authSvc := inmocks.NewMockAuthService(t)
1051	containerSvc := inmocks.NewMockContainerService(t)
1052	secretSvc := inmocks.NewMockSecretService(t)
1053	registrySvc := inmocks.NewMockRegistryService(t)
1054
1055	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1056
1057	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1058
1059	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1060	req = req.WithContext(ctxWithScopes("admin:status:read"))
1061	rec := httptest.NewRecorder()
1062
1063	handler.ServeHTTP(rec, req)
1064
1065	assert.Equal(t, http.StatusOK, rec.Code)
1066	var response struct {
1067		Repository string   `json:"repository"`
1068		Tags       []string `json:"tags"`
1069	}
1070	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1071	assert.Equal(t, "myapp", response.Repository)
1072	assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1073}
1074
1075func TestHandler_Tags_Error(t *testing.T) {
1076	configSvc := inmocks.NewMockConfigService(t)
1077	authSvc := inmocks.NewMockAuthService(t)
1078	containerSvc := inmocks.NewMockContainerService(t)
1079	secretSvc := inmocks.NewMockSecretService(t)
1080	registrySvc := inmocks.NewMockRegistryService(t)
1081
1082	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1083
1084	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1085
1086	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1087	req = req.WithContext(ctxWithScopes("admin:status:read"))
1088	rec := httptest.NewRecorder()
1089
1090	handler.ServeHTTP(rec, req)
1091
1092	assert.Equal(t, http.StatusInternalServerError, rec.Code)
1093	assert.NotContains(t, rec.Body.String(), "registry error")
1094}
1095
1096func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
1097	configSvc := inmocks.NewMockConfigService(t)
1098	authSvc := inmocks.NewMockAuthService(t)
1099	containerSvc := inmocks.NewMockContainerService(t)
1100	secretSvc := inmocks.NewMockSecretService(t)
1101
1102	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1103
1104	body := `{"REDIS_URL": "redis://localhost:6379"}`
1105	req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1106	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1107	rec := httptest.NewRecorder()
1108
1109	handler.ServeHTTP(rec, req)
1110
1111	assert.Equal(t, http.StatusForbidden, rec.Code)
1112}
1113
1114func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
1115	configSvc := inmocks.NewMockConfigService(t)
1116	authSvc := inmocks.NewMockAuthService(t)
1117	containerSvc := inmocks.NewMockContainerService(t)
1118	secretSvc := inmocks.NewMockSecretService(t)
1119
1120	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1121
1122	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1123	req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1124	rec := httptest.NewRecorder()
1125
1126	handler.ServeHTTP(rec, req)
1127
1128	assert.Equal(t, http.StatusForbidden, rec.Code)
1129}
1130
1131func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1132	configSvc := inmocks.NewMockConfigService(t)
1133	authSvc := inmocks.NewMockAuthService(t)
1134	containerSvc := inmocks.NewMockContainerService(t)
1135	secretSvc := inmocks.NewMockSecretService(t)
1136
1137	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1138
1139	req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
1140	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1141	rec := httptest.NewRecorder()
1142
1143	handler.ServeHTTP(rec, req)
1144
1145	assert.Equal(t, http.StatusBadRequest, rec.Code)
1146	assert.Contains(t, rec.Body.String(), "invalid attachment path")
1147}
1148
1149func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
1150	configSvc := inmocks.NewMockConfigService(t)
1151	authSvc := inmocks.NewMockAuthService(t)
1152	containerSvc := inmocks.NewMockContainerService(t)
1153	secretSvc := inmocks.NewMockSecretService(t)
1154
1155	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1156
1157	body := `{not valid json`
1158	req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1159	req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1160	rec := httptest.NewRecorder()
1161
1162	handler.ServeHTTP(rec, req)
1163
1164	assert.Equal(t, http.StatusBadRequest, rec.Code)
1165	assert.Contains(t, rec.Body.String(), "invalid JSON")
1166}
1167
1168func TestHandler_NotFound(t *testing.T) {
1169	configSvc := inmocks.NewMockConfigService(t)
1170	authSvc := inmocks.NewMockAuthService(t)
1171	containerSvc := inmocks.NewMockContainerService(t)
1172	secretSvc := inmocks.NewMockSecretService(t)
1173
1174	handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1175
1176	req := httptest.NewRequest("GET", "/admin/unknown", nil)
1177	req = req.WithContext(ctxWithScopes("admin:*:*"))
1178	rec := httptest.NewRecorder()
1179
1180	handler.ServeHTTP(rec, req)
1181
1182	assert.Equal(t, http.StatusNotFound, rec.Code)
1183}

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