v3.0.0 f19f453

handler_test.go RawHistoryFolder

1package admin
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"io"
10	"net/http"
11	"net/http/httptest"
12	"testing"
13	"time"
14
15	"github.com/bnema/zerowrap"
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19
20	"github.com/bnema/gordon/internal/adapters/dto"
21	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
22	"github.com/bnema/gordon/internal/domain"
23)
24
25type stubImageService struct {
26	listImagesFunc func(context.Context) ([]domain.ImageInfo, error)
27	pruneFunc      func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error)
28}
29
30type noopReloadTrigger struct{}
31
32func (noopReloadTrigger) Trigger(context.Context) error { return nil }
33
34type reloadTriggerRecorder struct {
35	calls        int
36	err          error
37	ctxErrAtCall error
38	hasDeadline  bool
39}
40
41func (r *reloadTriggerRecorder) Trigger(ctx context.Context) error {
42	r.calls++
43	r.ctxErrAtCall = ctx.Err()
44	_, r.hasDeadline = ctx.Deadline()
45	return r.err
46}
47
48func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
49	if s.listImagesFunc == nil {
50		return nil, nil
51	}
52	return s.listImagesFunc(ctx)
53}
54
55func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
56	if s.pruneFunc == nil {
57		return domain.ImagePruneReport{}, nil
58	}
59	return s.pruneFunc(ctx, opts)
60}
61
62func testLogger() zerowrap.Logger {
63	return zerowrap.Default()
64}
65
66func ctxWithScopes(scopes ...string) context.Context {
67	ctx := context.Background()
68	return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
69}
70
71func newScopedTestServer(t *testing.T, handler http.Handler, scopes ...string) *httptest.Server {
72	t.Helper()
73
74	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75		handler.ServeHTTP(w, r.WithContext(ctxWithScopes(scopes...)))
76	}))
77	t.Cleanup(server.Close)
78
79	return server
80}
81
82func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler {
83	t.Helper()
84	deps := HandlerDeps{
85		ConfigSvc:     inmocks.NewMockConfigService(t),
86		AuthSvc:       inmocks.NewMockAuthService(t),
87		ContainerSvc:  inmocks.NewMockContainerService(t),
88		HealthSvc:     inmocks.NewMockHealthService(t),
89		Log:           testLogger(),
90		ReloadTrigger: noopReloadTrigger{},
91	}
92	for _, opt := range opts {
93		opt(&deps)
94	}
95	return NewHandler(deps)
96}
97
98func TestHandler_VolumesGet_RequiresVolumesReadScope(t *testing.T) {
99	volumeSvc := inmocks.NewMockVolumeService(t)
100	handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc })
101
102	tests := []struct {
103		name       string
104		scopes     []string
105		wantStatus int
106	}{
107		{name: "volumes read access granted", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusOK},
108		{name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK},
109		{name: "status read denied", scopes: []string{"admin:status:read"}, wantStatus: http.StatusForbidden},
110		{name: "volumes write denied", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusForbidden},
111	}
112
113	for _, tt := range tests {
114		t.Run(tt.name, func(t *testing.T) {
115			if tt.wantStatus == http.StatusOK {
116				volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{}, nil).Once()
117			}
118			server := newScopedTestServer(t, handler, tt.scopes...)
119			resp, err := http.Get(server.URL + "/admin/volumes")
120			require.NoError(t, err)
121			defer resp.Body.Close()
122			assert.Equal(t, tt.wantStatus, resp.StatusCode)
123		})
124	}
125}
126
127func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) {
128	volumeSvc := inmocks.NewMockVolumeService(t)
129	handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc })
130
131	tests := []struct {
132		name       string
133		scopes     []string
134		wantStatus int
135	}{
136		{name: "volumes write access granted", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusOK},
137		{name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK},
138		{name: "config write denied", scopes: []string{"admin:config:write"}, wantStatus: http.StatusForbidden},
139		{name: "volumes read denied", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusForbidden},
140	}
141
142	for _, tt := range tests {
143		t.Run(tt.name, func(t *testing.T) {
144			if tt.wantStatus == http.StatusOK {
145				volumeSvc.EXPECT().PruneVolumes(mock.Anything, true).Return(&domain.VolumePruneReport{}, []*domain.VolumeInfo{}, nil).Once()
146			}
147			server := newScopedTestServer(t, handler, tt.scopes...)
148			resp, err := http.Post(server.URL+"/admin/volumes/prune", "application/json", bytes.NewBufferString(`{"dry_run":true}`))
149			require.NoError(t, err)
150			defer resp.Body.Close()
151			assert.Equal(t, tt.wantStatus, resp.StatusCode)
152		})
153	}
154}
155
156// Routes endpoint tests
157
158func TestHandler_Status_RequiresReadScope(t *testing.T) {
159	configSvc := inmocks.NewMockConfigService(t)
160	authSvc := inmocks.NewMockAuthService(t)
161	containerSvc := inmocks.NewMockContainerService(t)
162
163	handler := newTestHandler(t, func(d *HandlerDeps) {
164		d.ConfigSvc = configSvc
165		d.AuthSvc = authSvc
166		d.ContainerSvc = containerSvc
167	})
168
169	tests := []struct {
170		name       string
171		scopes     []string
172		wantStatus int
173	}{
174		{
175			name:       "status read access granted",
176			scopes:     []string{"admin:status:read"},
177			wantStatus: http.StatusOK,
178		},
179		{
180			name:       "all admin access granted",
181			scopes:     []string{"admin:*:*"},
182			wantStatus: http.StatusOK,
183		},
184		{
185			name:       "wrong resource denied",
186			scopes:     []string{"admin:routes:read"},
187			wantStatus: http.StatusForbidden,
188		},
189	}
190
191	for _, tt := range tests {
192		t.Run(tt.name, func(t *testing.T) {
193			if tt.wantStatus == http.StatusOK {
194				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
195				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
196				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
197				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
198			}
199
200			req := httptest.NewRequest("GET", "/admin/status", nil)
201			req = req.WithContext(ctxWithScopes(tt.scopes...))
202			rec := httptest.NewRecorder()
203
204			handler.ServeHTTP(rec, req)
205
206			assert.Equal(t, tt.wantStatus, rec.Code)
207		})
208	}
209}
210
211// Config endpoint tests
212
213func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) {
214	configSvc := inmocks.NewMockConfigService(t)
215	handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc })
216
217	configSvc.EXPECT().GetServerPort().Return(8080).Once()
218	configSvc.EXPECT().GetRegistryPort().Return(5000).Once()
219	configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once()
220	configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Once()
221	configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Once()
222	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{"db.example.com": "http://10.0.0.5:5432"}).Once()
223
224	server := newScopedTestServer(t, handler, "admin:config:read")
225	resp, err := http.Get(server.URL + "/admin/config")
226	require.NoError(t, err)
227	defer resp.Body.Close()
228	bodyBytes, err := io.ReadAll(resp.Body)
229	require.NoError(t, err)
230
231	require.Equal(t, http.StatusOK, resp.StatusCode)
232	body := string(bodyBytes)
233	var raw map[string]any
234	require.NoError(t, json.Unmarshal([]byte(body), &raw))
235	serverConfig := raw["server"].(map[string]any)
236	assert.NotContains(t, serverConfig, "data_dir")
237	assert.NotContains(t, body, "/var/lib/gordon")
238	assert.NotContains(t, body, "10.0.0.5")
239}
240
241func TestHandler_Config_RequiresReadScope(t *testing.T) {
242	configSvc := inmocks.NewMockConfigService(t)
243	authSvc := inmocks.NewMockAuthService(t)
244	containerSvc := inmocks.NewMockContainerService(t)
245
246	handler := newTestHandler(t, func(d *HandlerDeps) {
247		d.ConfigSvc = configSvc
248		d.AuthSvc = authSvc
249		d.ContainerSvc = containerSvc
250	})
251
252	tests := []struct {
253		name       string
254		scopes     []string
255		wantStatus int
256	}{
257		{
258			name:       "config read access granted",
259			scopes:     []string{"admin:config:read"},
260			wantStatus: http.StatusOK,
261		},
262		{
263			name:       "all admin access granted",
264			scopes:     []string{"admin:*:*"},
265			wantStatus: http.StatusOK,
266		},
267		{
268			name:       "wrong resource denied",
269			scopes:     []string{"admin:routes:read"},
270			wantStatus: http.StatusForbidden,
271		},
272	}
273
274	for _, tt := range tests {
275		t.Run(tt.name, func(t *testing.T) {
276			if tt.wantStatus == http.StatusOK {
277				configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
278				configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
279				configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
280				configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
281				configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
282				configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
283				configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
284			}
285
286			req := httptest.NewRequest("GET", "/admin/config", nil)
287			req = req.WithContext(ctxWithScopes(tt.scopes...))
288			rec := httptest.NewRecorder()
289
290			handler.ServeHTTP(rec, req)
291
292			assert.Equal(t, tt.wantStatus, rec.Code)
293		})
294	}
295}
296
297// Reload endpoint tests
298
299func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
300	configSvc := inmocks.NewMockConfigService(t)
301	authSvc := inmocks.NewMockAuthService(t)
302	containerSvc := inmocks.NewMockContainerService(t)
303	reloadTrigger := &reloadTriggerRecorder{}
304
305	handler := newTestHandler(t, func(d *HandlerDeps) {
306		d.ConfigSvc = configSvc
307		d.AuthSvc = authSvc
308		d.ContainerSvc = containerSvc
309		d.ReloadTrigger = reloadTrigger
310	})
311
312	tests := []struct {
313		name       string
314		scopes     []string
315		wantStatus int
316	}{
317		{
318			name:       "config write access granted",
319			scopes:     []string{"admin:config:write"},
320			wantStatus: http.StatusOK,
321		},
322		{
323			name:       "all admin access granted",
324			scopes:     []string{"admin:*:*"},
325			wantStatus: http.StatusOK,
326		},
327		{
328			name:       "only read access denied",
329			scopes:     []string{"admin:config:read"},
330			wantStatus: http.StatusForbidden,
331		},
332		{
333			name:       "wrong resource denied",
334			scopes:     []string{"admin:routes:write"},
335			wantStatus: http.StatusForbidden,
336		},
337	}
338
339	for _, tt := range tests {
340		t.Run(tt.name, func(t *testing.T) {
341			reloadTrigger.calls = 0
342
343			req := httptest.NewRequest("POST", "/admin/reload", nil)
344			req = req.WithContext(ctxWithScopes(tt.scopes...))
345			rec := httptest.NewRecorder()
346
347			handler.ServeHTTP(rec, req)
348
349			assert.Equal(t, tt.wantStatus, rec.Code)
350			if tt.wantStatus == http.StatusOK {
351				assert.Equal(t, 1, reloadTrigger.calls)
352			} else {
353				assert.Zero(t, reloadTrigger.calls)
354			}
355		})
356	}
357}
358
359func TestHandler_Reload_ReturnsServerErrorOnTriggerFailure(t *testing.T) {
360	reloadTrigger := &reloadTriggerRecorder{err: errors.New("reload failed")}
361
362	handler := newTestHandler(t, func(d *HandlerDeps) {
363		d.ReloadTrigger = reloadTrigger
364	})
365
366	req := httptest.NewRequest("POST", "/admin/reload", nil)
367	req = req.WithContext(ctxWithScopes("admin:config:write"))
368	rec := httptest.NewRecorder()
369
370	handler.ServeHTTP(rec, req)
371
372	assert.Equal(t, http.StatusInternalServerError, rec.Code)
373	assert.Equal(t, 1, reloadTrigger.calls)
374}
375
376func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) {
377	reloadTrigger := &reloadTriggerRecorder{}
378
379	handler := newTestHandler(t, func(d *HandlerDeps) {
380		d.ReloadTrigger = reloadTrigger
381	})
382
383	reqCtx, cancel := context.WithCancel(ctxWithScopes("admin:config:write"))
384	cancel()
385
386	req := httptest.NewRequest("POST", "/admin/reload", nil)
387	req = req.WithContext(reqCtx)
388	rec := httptest.NewRecorder()
389
390	handler.ServeHTTP(rec, req)
391
392	assert.Equal(t, http.StatusOK, rec.Code)
393	assert.Equal(t, 1, reloadTrigger.calls)
394	require.NoError(t, reloadTrigger.ctxErrAtCall)
395	assert.True(t, reloadTrigger.hasDeadline)
396}
397
398// Functional tests
399
400func TestHandler_MethodNotAllowed(t *testing.T) {
401	configSvc := inmocks.NewMockConfigService(t)
402	authSvc := inmocks.NewMockAuthService(t)
403	containerSvc := inmocks.NewMockContainerService(t)
404
405	handler := newTestHandler(t, func(d *HandlerDeps) {
406		d.ConfigSvc = configSvc
407		d.AuthSvc = authSvc
408		d.ContainerSvc = containerSvc
409	})
410
411	tests := []struct {
412		method string
413		path   string
414	}{
415		{"DELETE", "/admin/status"},
416		{"PUT", "/admin/status"},
417		{"GET", "/admin/reload"},
418		{"DELETE", "/admin/reload"},
419		{"POST", "/admin/config"},
420		{"DELETE", "/admin/config"},
421	}
422
423	for _, tt := range tests {
424		t.Run(tt.method+" "+tt.path, func(t *testing.T) {
425			req := httptest.NewRequest(tt.method, tt.path, nil)
426			req = req.WithContext(ctxWithScopes("admin:*:*"))
427			rec := httptest.NewRecorder()
428
429			handler.ServeHTTP(rec, req)
430
431			assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
432		})
433	}
434}
435
436func TestHandler_Tags_InvalidRepository(t *testing.T) {
437	configSvc := inmocks.NewMockConfigService(t)
438	authSvc := inmocks.NewMockAuthService(t)
439	containerSvc := inmocks.NewMockContainerService(t)
440
441	handler := newTestHandler(t, func(d *HandlerDeps) {
442		d.ConfigSvc = configSvc
443		d.AuthSvc = authSvc
444		d.ContainerSvc = containerSvc
445	})
446
447	tests := []struct {
448		name   string
449		path   string
450		status int
451	}{
452		{"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
453		{"empty repo", "/admin/tags/", http.StatusBadRequest},
454	}
455
456	for _, tt := range tests {
457		t.Run(tt.name, func(t *testing.T) {
458			req := httptest.NewRequest("GET", tt.path, nil)
459			req = req.WithContext(ctxWithScopes("admin:status:read"))
460			rec := httptest.NewRecorder()
461
462			handler.ServeHTTP(rec, req)
463
464			assert.Equal(t, tt.status, rec.Code)
465		})
466	}
467}
468
469func TestHandler_Tags_Success(t *testing.T) {
470	configSvc := inmocks.NewMockConfigService(t)
471	authSvc := inmocks.NewMockAuthService(t)
472	containerSvc := inmocks.NewMockContainerService(t)
473	registrySvc := inmocks.NewMockRegistryService(t)
474
475	handler := newTestHandler(t, func(d *HandlerDeps) {
476		d.ConfigSvc = configSvc
477		d.AuthSvc = authSvc
478		d.ContainerSvc = containerSvc
479		d.RegistrySvc = registrySvc
480	})
481
482	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
483
484	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
485	req = req.WithContext(ctxWithScopes("admin:status:read"))
486	rec := httptest.NewRecorder()
487
488	handler.ServeHTTP(rec, req)
489
490	assert.Equal(t, http.StatusOK, rec.Code)
491	var response struct {
492		Repository string   `json:"repository"`
493		Tags       []string `json:"tags"`
494	}
495	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
496	assert.Equal(t, "myapp", response.Repository)
497	assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
498}
499
500func TestHandler_Tags_Error(t *testing.T) {
501	configSvc := inmocks.NewMockConfigService(t)
502	authSvc := inmocks.NewMockAuthService(t)
503	containerSvc := inmocks.NewMockContainerService(t)
504	registrySvc := inmocks.NewMockRegistryService(t)
505
506	handler := newTestHandler(t, func(d *HandlerDeps) {
507		d.ConfigSvc = configSvc
508		d.AuthSvc = authSvc
509		d.ContainerSvc = containerSvc
510		d.RegistrySvc = registrySvc
511	})
512
513	registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
514
515	req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
516	req = req.WithContext(ctxWithScopes("admin:status:read"))
517	rec := httptest.NewRecorder()
518
519	handler.ServeHTTP(rec, req)
520
521	assert.Equal(t, http.StatusInternalServerError, rec.Code)
522	assert.NotContains(t, rec.Body.String(), "registry error")
523}
524
525func TestHandler_Tags_URLDecodedRepository(t *testing.T) {
526	configSvc := inmocks.NewMockConfigService(t)
527	authSvc := inmocks.NewMockAuthService(t)
528	containerSvc := inmocks.NewMockContainerService(t)
529	registrySvc := inmocks.NewMockRegistryService(t)
530
531	handler := newTestHandler(t, func(d *HandlerDeps) {
532		d.ConfigSvc = configSvc
533		d.AuthSvc = authSvc
534		d.ContainerSvc = containerSvc
535		d.RegistrySvc = registrySvc
536	})
537
538	registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"latest"}, nil)
539
540	req := httptest.NewRequest("GET", "/admin/tags/repo%2Fapp", nil)
541	req = req.WithContext(ctxWithScopes("admin:status:read"))
542	rec := httptest.NewRecorder()
543
544	handler.ServeHTTP(rec, req)
545
546	assert.Equal(t, http.StatusOK, rec.Code)
547	assert.JSONEq(t, `{"repository":"repo/app","tags":["latest"]}`, rec.Body.String())
548}
549
550func TestHandler_NotFound(t *testing.T) {
551	configSvc := inmocks.NewMockConfigService(t)
552	authSvc := inmocks.NewMockAuthService(t)
553	containerSvc := inmocks.NewMockContainerService(t)
554
555	handler := newTestHandler(t, func(d *HandlerDeps) {
556		d.ConfigSvc = configSvc
557		d.AuthSvc = authSvc
558		d.ContainerSvc = containerSvc
559	})
560
561	req := httptest.NewRequest("GET", "/admin/unknown", nil)
562	req = req.WithContext(ctxWithScopes("admin:*:*"))
563	rec := httptest.NewRecorder()
564
565	handler.ServeHTTP(rec, req)
566
567	assert.Equal(t, http.StatusNotFound, rec.Code)
568}
569
570func TestHandler_BackupsStatus(t *testing.T) {
571	configSvc := inmocks.NewMockConfigService(t)
572	authSvc := inmocks.NewMockAuthService(t)
573	containerSvc := inmocks.NewMockContainerService(t)
574	backupSvc := inmocks.NewMockBackupService(t)
575
576	backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil)
577
578	handler := newTestHandler(t, func(d *HandlerDeps) {
579		d.ConfigSvc = configSvc
580		d.AuthSvc = authSvc
581		d.ContainerSvc = containerSvc
582		d.BackupSvc = backupSvc
583	})
584
585	req := httptest.NewRequest("GET", "/admin/backups/status", nil)
586	req = req.WithContext(ctxWithScopes("admin:status:read"))
587	rec := httptest.NewRecorder()
588
589	handler.ServeHTTP(rec, req)
590
591	assert.Equal(t, http.StatusOK, rec.Code)
592	assert.Contains(t, rec.Body.String(), "shop")
593	assert.Contains(t, rec.Body.String(), "orders")
594	assert.NotContains(t, rec.Body.String(), "file_path")
595}
596
597func TestHandler_BackupsListApp(t *testing.T) {
598	configSvc := inmocks.NewMockConfigService(t)
599	authSvc := inmocks.NewMockAuthService(t)
600	containerSvc := inmocks.NewMockContainerService(t)
601	backupSvc := inmocks.NewMockBackupService(t)
602
603	backupSvc.EXPECT().ListBackups(mock.Anything, "shop").Return([]domain.BackupJob{{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)
604
605	handler := newTestHandler(t, func(d *HandlerDeps) {
606		d.ConfigSvc = configSvc
607		d.AuthSvc = authSvc
608		d.ContainerSvc = containerSvc
609		d.BackupSvc = backupSvc
610	})
611
612	req := httptest.NewRequest("GET", "/admin/backups/shop", nil)
613	req = req.WithContext(ctxWithScopes("admin:status:read"))
614	rec := httptest.NewRecorder()
615
616	handler.ServeHTTP(rec, req)
617
618	assert.Equal(t, http.StatusOK, rec.Code)
619	assert.Contains(t, rec.Body.String(), "shop")
620}
621
622func TestHandler_BackupsRunApp(t *testing.T) {
623	configSvc := inmocks.NewMockConfigService(t)
624	authSvc := inmocks.NewMockAuthService(t)
625	containerSvc := inmocks.NewMockContainerService(t)
626	backupSvc := inmocks.NewMockBackupService(t)
627
628	backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)
629
630	handler := newTestHandler(t, func(d *HandlerDeps) {
631		d.ConfigSvc = configSvc
632		d.AuthSvc = authSvc
633		d.ContainerSvc = containerSvc
634		d.BackupSvc = backupSvc
635	})
636
637	body := bytes.NewBufferString(`{"service":"api","database":"orders"}`)
638	req := httptest.NewRequest("POST", "/admin/backups/shop", body)
639	req = req.WithContext(ctxWithScopes("admin:config:write"))
640	rec := httptest.NewRecorder()
641
642	handler.ServeHTTP(rec, req)
643
644	assert.Equal(t, http.StatusOK, rec.Code)
645	assert.Contains(t, rec.Body.String(), "completed")
646}
647
648func TestHandler_VolumeBackupsRunDomain_ReturnsPartialResults(t *testing.T) {
649	volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
650	runErr := errors.New("one volume failed")
651	jobs := []domain.VolumeBackupJob{{ID: "v1", App: "shop", Service: "api", VolumeName: "data", Status: domain.BackupStatusCompleted}}
652	volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "shop", "api", "data").Return(jobs, runErr)
653
654	handler := newTestHandler(t, func(d *HandlerDeps) {
655		d.VolumeBackupSvc = volumeBackupSvc
656	})
657
658	body := bytes.NewBufferString(`{"service":"api","volume":"data"}`)
659	req := httptest.NewRequest("POST", "/admin/backups/volumes/shop", body)
660	req = req.WithContext(ctxWithScopes("admin:config:write"))
661	rec := httptest.NewRecorder()
662
663	handler.ServeHTTP(rec, req)
664
665	require.Equal(t, http.StatusPartialContent, rec.Code)
666	var result dto.VolumeBackupRunResponse
667	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &result))
668	assert.Equal(t, "partial", result.Status)
669	assert.Equal(t, runErr.Error(), result.Error)
670	require.Len(t, result.Backups, 1)
671	assert.Equal(t, "v1", result.Backups[0].ID)
672}
673
674func TestHandler_VolumeBackupsRunDomain_ReturnsServerErrorWithoutResults(t *testing.T) {
675	volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
676	volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "shop", "", "").Return(nil, errors.New("runtime unavailable"))
677
678	handler := newTestHandler(t, func(d *HandlerDeps) {
679		d.VolumeBackupSvc = volumeBackupSvc
680	})
681
682	req := httptest.NewRequest("POST", "/admin/backups/volumes/shop", bytes.NewBufferString(`{}`))
683	req = req.WithContext(ctxWithScopes("admin:config:write"))
684	rec := httptest.NewRecorder()
685
686	handler.ServeHTTP(rec, req)
687
688	assert.Equal(t, http.StatusInternalServerError, rec.Code)
689	assert.Contains(t, rec.Body.String(), "failed to run volume backups")
690}
691
692func TestHandler_BackupsRunApp_ChunkedBodyIsDecoded(t *testing.T) {
693	configSvc := inmocks.NewMockConfigService(t)
694	authSvc := inmocks.NewMockAuthService(t)
695	containerSvc := inmocks.NewMockContainerService(t)
696	backupSvc := inmocks.NewMockBackupService(t)
697
698	backupSvc.EXPECT().RunBackup(mock.Anything, "shop", "api", "orders").Return(&domain.BackupResult{Job: domain.BackupJob{App: "shop", Service: "api", DBName: "orders", Status: domain.BackupStatusCompleted}}, nil)
699
700	handler := newTestHandler(t, func(d *HandlerDeps) {
701		d.ConfigSvc = configSvc
702		d.AuthSvc = authSvc
703		d.ContainerSvc = containerSvc
704		d.BackupSvc = backupSvc
705	})
706
707	body := bytes.NewBufferString(`{"service":"api","database":"orders"}`)
708	req := httptest.NewRequest("POST", "/admin/backups/shop", body)
709	req.ContentLength = -1
710	req = req.WithContext(ctxWithScopes("admin:config:write"))
711	rec := httptest.NewRecorder()
712
713	handler.ServeHTTP(rec, req)
714
715	assert.Equal(t, http.StatusOK, rec.Code)
716	assert.Contains(t, rec.Body.String(), "completed")
717}
718
719func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) {
720	logSvc := inmocks.NewMockLogService(t)
721	handler := newTestHandler(t, func(d *HandlerDeps) {
722		d.LogSvc = logSvc
723	})
724
725	t.Run("status read denied", func(t *testing.T) {
726		server := newScopedTestServer(t, handler, "admin:status:read")
727		resp, err := http.Get(server.URL + "/admin/logs")
728		require.NoError(t, err)
729		defer resp.Body.Close()
730		bodyBytes, err := io.ReadAll(resp.Body)
731		require.NoError(t, err)
732
733		assert.Equal(t, http.StatusForbidden, resp.StatusCode)
734		assert.Contains(t, string(bodyBytes), "insufficient permissions for logs:read")
735	})
736
737	t.Run("logs read allowed and redacted", func(t *testing.T) {
738		logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once()
739
740		server := newScopedTestServer(t, handler, "admin:logs:read")
741		resp, err := http.Get(server.URL + "/admin/logs")
742		require.NoError(t, err)
743		defer resp.Body.Close()
744		bodyBytes, err := io.ReadAll(resp.Body)
745		require.NoError(t, err)
746		body := string(bodyBytes)
747
748		assert.Equal(t, http.StatusOK, resp.StatusCode)
749		assert.Contains(t, body, "PASSWORD=[REDACTED]")
750		assert.Contains(t, body, `\"API_KEY\":\"[REDACTED]\"`)
751		assert.NotContains(t, body, "hunter2")
752		assert.NotContains(t, body, "abc123")
753	})
754}
755
756func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
757	configSvc := inmocks.NewMockConfigService(t)
758	authSvc := inmocks.NewMockAuthService(t)
759	containerSvc := inmocks.NewMockContainerService(t)
760
761	createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
762	imageSvc := &stubImageService{
763		listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
764			return []domain.ImageInfo{
765				{
766					Repository: "myapp",
767					Tag:        "latest",
768					Size:       1024,
769					Created:    createdAt,
770					ID:         "sha256:abc123",
771					Dangling:   false,
772				},
773				{
774					Repository: "",
775					Tag:        "",
776					Size:       512,
777					Created:    createdAt,
778					ID:         "sha256:def456",
779					Dangling:   true,
780				},
781			}, nil
782		},
783	}
784
785	handler := newTestHandler(t, func(d *HandlerDeps) {
786		d.ConfigSvc = configSvc
787		d.AuthSvc = authSvc
788		d.ContainerSvc = containerSvc
789		d.ImageSvc = imageSvc
790	})
791
792	req := httptest.NewRequest("GET", "/admin/images", nil)
793	req = req.WithContext(ctxWithScopes("admin:status:read"))
794	rec := httptest.NewRecorder()
795
796	handler.ServeHTTP(rec, req)
797
798	assert.Equal(t, http.StatusOK, rec.Code)
799
800	var response struct {
801		Images []struct {
802			Repository string    `json:"repository"`
803			Tag        string    `json:"tag"`
804			Size       int64     `json:"size"`
805			Created    time.Time `json:"created"`
806			ID         string    `json:"id"`
807			Dangling   bool      `json:"dangling"`
808		} `json:"images"`
809	}
810
811	assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
812	assert.Len(t, response.Images, 2)
813	assert.Equal(t, "myapp", response.Images[0].Repository)
814	assert.Equal(t, "latest", response.Images[0].Tag)
815	assert.Equal(t, int64(1024), response.Images[0].Size)
816	assert.Equal(t, createdAt, response.Images[0].Created)
817	assert.Equal(t, "sha256:abc123", response.Images[0].ID)
818	assert.False(t, response.Images[0].Dangling)
819	assert.True(t, response.Images[1].Dangling)
820}
821
822func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
823	configSvc := inmocks.NewMockConfigService(t)
824	authSvc := inmocks.NewMockAuthService(t)
825	containerSvc := inmocks.NewMockContainerService(t)
826
827	tests := []struct {
828		name             string
829		body             string
830		expectedKeepLast int
831	}{
832		{name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast},
833		{name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
834	}
835
836	for _, tt := range tests {
837		t.Run(tt.name, func(t *testing.T) {
838			called := false
839			imageSvc := &stubImageService{
840				pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
841					called = true
842					assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
843					return domain.ImagePruneReport{
844						Runtime:  domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
845						Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
846					}, nil
847				},
848			}
849
850			handler := newTestHandler(t, func(d *HandlerDeps) {
851				d.ConfigSvc = configSvc
852				d.AuthSvc = authSvc
853				d.ContainerSvc = containerSvc
854				d.ImageSvc = imageSvc
855			})
856
857			req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
858			req = req.WithContext(ctxWithScopes("admin:config:write"))
859			rec := httptest.NewRecorder()
860
861			handler.ServeHTTP(rec, req)
862
863			assert.Equal(t, http.StatusOK, rec.Code)
864			assert.True(t, called)
865			assert.Contains(t, rec.Body.String(), "deleted_count")
866			assert.Contains(t, rec.Body.String(), "tags_removed")
867		})
868	}
869}
870
871func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
872	t.Run("list failure maps to internal server error", func(t *testing.T) {
873		configSvc := inmocks.NewMockConfigService(t)
874		authSvc := inmocks.NewMockAuthService(t)
875		containerSvc := inmocks.NewMockContainerService(t)
876
877		imageSvc := &stubImageService{
878			listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
879				return nil, errors.New("runtime list failed: confidential details")
880			},
881		}
882
883		handler := newTestHandler(t, func(d *HandlerDeps) {
884			d.ConfigSvc = configSvc
885			d.AuthSvc = authSvc
886			d.ContainerSvc = containerSvc
887			d.ImageSvc = imageSvc
888		})
889
890		req := httptest.NewRequest("GET", "/admin/images", nil)
891		req = req.WithContext(ctxWithScopes("admin:status:read"))
892		rec := httptest.NewRecorder()
893
894		handler.ServeHTTP(rec, req)
895
896		assert.Equal(t, http.StatusInternalServerError, rec.Code)
897		assert.Contains(t, rec.Body.String(), "failed to list images")
898		assert.NotContains(t, rec.Body.String(), "confidential details")
899	})
900
901	t.Run("prune failure maps to internal server error", func(t *testing.T) {
902		configSvc := inmocks.NewMockConfigService(t)
903		authSvc := inmocks.NewMockAuthService(t)
904		containerSvc := inmocks.NewMockContainerService(t)
905
906		imageSvc := &stubImageService{
907			pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
908				return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
909			},
910		}
911
912		handler := newTestHandler(t, func(d *HandlerDeps) {
913			d.ConfigSvc = configSvc
914			d.AuthSvc = authSvc
915			d.ContainerSvc = containerSvc
916			d.ImageSvc = imageSvc
917		})
918
919		req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
920		req = req.WithContext(ctxWithScopes("admin:config:write"))
921		rec := httptest.NewRecorder()
922
923		handler.ServeHTTP(rec, req)
924
925		assert.Equal(t, http.StatusInternalServerError, rec.Code)
926		assert.Contains(t, rec.Body.String(), "failed to prune images")
927		assert.NotContains(t, rec.Body.String(), "confidential details")
928	})
929}
930
931func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
932	t.Run("rejects negative keep_last", func(t *testing.T) {
933		configSvc := inmocks.NewMockConfigService(t)
934		authSvc := inmocks.NewMockAuthService(t)
935		containerSvc := inmocks.NewMockContainerService(t)
936
937		handler := newTestHandler(t, func(d *HandlerDeps) {
938			d.ConfigSvc = configSvc
939			d.AuthSvc = authSvc
940			d.ContainerSvc = containerSvc
941			d.ImageSvc = &stubImageService{}
942		})
943
944		req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
945		req = req.WithContext(ctxWithScopes("admin:config:write"))
946		rec := httptest.NewRecorder()
947
948		handler.ServeHTTP(rec, req)
949
950		assert.Equal(t, http.StatusBadRequest, rec.Code)
951		assert.Contains(t, rec.Body.String(), "keep_last must be")
952	})
953
954	t.Run("returns service unavailable when image service missing", func(t *testing.T) {
955		configSvc := inmocks.NewMockConfigService(t)
956		authSvc := inmocks.NewMockAuthService(t)
957		containerSvc := inmocks.NewMockContainerService(t)
958
959		handler := newTestHandler(t, func(d *HandlerDeps) {
960			d.ConfigSvc = configSvc
961			d.AuthSvc = authSvc
962			d.ContainerSvc = containerSvc
963		})
964
965		req := httptest.NewRequest("GET", "/admin/images", nil)
966		req = req.WithContext(ctxWithScopes("admin:status:read"))
967		rec := httptest.NewRecorder()
968
969		handler.ServeHTTP(rec, req)
970
971		assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
972		assert.Contains(t, rec.Body.String(), "image service not available")
973	})
974}
975
976func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) {
977	configSvc := inmocks.NewMockConfigService(t)
978	authSvc := inmocks.NewMockAuthService(t)
979	containerSvc := inmocks.NewMockContainerService(t)
980
981	tests := []struct {
982		name                  string
983		body                  string
984		expectedPruneDangling bool
985		expectedPruneRegistry bool
986		expectedKeepLast      int
987		wantStatus            int
988	}{
989		{
990			name:                  "missing scope fields defaults to both true",
991			body:                  `{}`,
992			expectedPruneDangling: true,
993			expectedPruneRegistry: true,
994			expectedKeepLast:      domain.DefaultImagePruneKeepLast,
995			wantStatus:            http.StatusOK,
996		},
997		{
998			name:                  "explicit dangling only",
999			body:                  `{"prune_dangling": true, "prune_registry": false}`,
1000			expectedPruneDangling: true,
1001			expectedPruneRegistry: false,
1002			expectedKeepLast:      domain.DefaultImagePruneKeepLast,
1003			wantStatus:            http.StatusOK,
1004		},
1005		{
1006			name:                  "explicit registry only",
1007			body:                  `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`,
1008			expectedPruneDangling: false,
1009			expectedPruneRegistry: true,
1010			expectedKeepLast:      5,
1011			wantStatus:            http.StatusOK,
1012		},
1013		{
1014			name:       "both scopes false returns 400",
1015			body:       `{"prune_dangling": false, "prune_registry": false}`,
1016			wantStatus: http.StatusBadRequest,
1017		},
1018	}
1019
1020	for _, tt := range tests {
1021		t.Run(tt.name, func(t *testing.T) {
1022			called := false
1023			imageSvc := &stubImageService{
1024				pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
1025					called = true
1026					assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling)
1027					assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry)
1028					assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
1029					return domain.ImagePruneReport{}, nil
1030				},
1031			}
1032
1033			handler := newTestHandler(t, func(d *HandlerDeps) {
1034				d.ConfigSvc = configSvc
1035				d.AuthSvc = authSvc
1036				d.ContainerSvc = containerSvc
1037				d.ImageSvc = imageSvc
1038			})
1039
1040			req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1041			req = req.WithContext(ctxWithScopes("admin:config:write"))
1042			rec := httptest.NewRecorder()
1043
1044			handler.ServeHTTP(rec, req)
1045
1046			assert.Equal(t, tt.wantStatus, rec.Code)
1047			if tt.wantStatus == http.StatusOK {
1048				assert.True(t, called, "prune should have been called")
1049			}
1050			if tt.wantStatus == http.StatusBadRequest {
1051				assert.Contains(t, rec.Body.String(), "at least one prune scope")
1052			}
1053		})
1054	}
1055}
1056
1057func TestHandler_Images_Authorization(t *testing.T) {
1058	configSvc := inmocks.NewMockConfigService(t)
1059	authSvc := inmocks.NewMockAuthService(t)
1060	containerSvc := inmocks.NewMockContainerService(t)
1061
1062	handler := newTestHandler(t, func(d *HandlerDeps) {
1063		d.ConfigSvc = configSvc
1064		d.AuthSvc = authSvc
1065		d.ContainerSvc = containerSvc
1066		d.ImageSvc = &stubImageService{}
1067	})
1068
1069	t.Run("images list requires status:read", func(t *testing.T) {
1070		req := httptest.NewRequest("GET", "/admin/images", nil)
1071		req = req.WithContext(ctxWithScopes("admin:config:write"))
1072		rec := httptest.NewRecorder()
1073
1074		handler.ServeHTTP(rec, req)
1075
1076		assert.Equal(t, http.StatusForbidden, rec.Code)
1077		assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
1078	})
1079
1080	t.Run("images prune requires config:write", func(t *testing.T) {
1081		req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
1082		req = req.WithContext(ctxWithScopes("admin:status:read"))
1083		rec := httptest.NewRecorder()
1084
1085		handler.ServeHTTP(rec, req)
1086
1087		assert.Equal(t, http.StatusForbidden, rec.Code)
1088		assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
1089	})
1090}
1091
1092func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
1093	handler := newTestHandler(t, func(d *HandlerDeps) {
1094		d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
1095	})
1096
1097	server := newScopedTestServer(t, handler, "admin:config:write")
1098	resp, err := http.Get(server.URL + "/admin/tls/status")
1099	require.NoError(t, err)
1100	defer resp.Body.Close()
1101
1102	body, err := io.ReadAll(resp.Body)
1103	require.NoError(t, err)
1104
1105	assert.Equal(t, http.StatusForbidden, resp.StatusCode)
1106	assert.Contains(t, string(body), "insufficient permissions for status:read")
1107}
1108
1109func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
1110	now := time.Now()
1111	tlsStatus := domain.PublicTLSStatus{
1112		ACMEEnabled:     true,
1113		ConfiguredMode:  domain.ACMEChallengeAuto,
1114		EffectiveMode:   domain.ACMEChallengeHTTP01,
1115		SelectionReason: "auto-selected",
1116		TokenSource:     domain.ACMETokenSourcePass,
1117		Certificates: []domain.ManagedCertificate{
1118			{
1119				ID:        "cert-1",
1120				Names:     []string{"example.com"},
1121				Challenge: domain.ACMEChallengeHTTP01,
1122				Status:    domain.TLSCertificateStatusValid,
1123				NotAfter:  now.Add(60 * 24 * time.Hour),
1124			},
1125		},
1126		Routes: []domain.TLSRouteCoverage{
1127			{
1128				Domain:       "example.com",
1129				Covered:      true,
1130				CoveredBy:    "cert-1",
1131				RequiredACME: true,
1132			},
1133		},
1134	}
1135
1136	handler := newTestHandler(t, func(d *HandlerDeps) {
1137		fakeSvc := inmocks.NewMockPublicTLSService(t)
1138		fakeSvc.EXPECT().Status(mock.Anything).Return(tlsStatus)
1139		d.PublicTLSSvc = fakeSvc
1140	})
1141
1142	server := newScopedTestServer(t, handler, "admin:status:read")
1143	resp, err := http.Get(server.URL + "/admin/tls/status")
1144	require.NoError(t, err)
1145	defer resp.Body.Close()
1146
1147	assert.Equal(t, http.StatusOK, resp.StatusCode)
1148
1149	var body dto.TLSStatusResponse
1150	err = json.NewDecoder(resp.Body).Decode(&body)
1151	require.NoError(t, err)
1152
1153	assert.True(t, body.ACMEEnabled)
1154	assert.Equal(t, "auto", body.ConfiguredMode)
1155	assert.Equal(t, "http-01", body.EffectiveMode)
1156	assert.Equal(t, "auto-selected", body.SelectionReason)
1157	assert.Equal(t, "pass", body.TokenSource)
1158	require.Len(t, body.Certificates, 1)
1159	assert.Equal(t, "cert-1", body.Certificates[0].ID)
1160	assert.Equal(t, "valid", body.Certificates[0].Status)
1161	assert.Equal(t, "http-01", body.Certificates[0].Challenge)
1162	require.Len(t, body.Routes, 1)
1163	assert.Equal(t, "example.com", body.Routes[0].Domain)
1164	assert.True(t, body.Routes[0].Covered)
1165}
1166
1167func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
1168	handler := newTestHandler(t, func(d *HandlerDeps) {
1169		d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
1170	})
1171
1172	server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write")
1173	resp, err := http.Post(server.URL+"/admin/tls/status", "application/json", nil)
1174	require.NoError(t, err)
1175	defer resp.Body.Close()
1176
1177	assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
1178}

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