v2.8.0 6aced72

auth_test.go RawHistoryFolder

1package middleware
2
3import (
4	"context"
5	"encoding/base64"
6	"errors"
7	"net/http"
8	"net/http/httptest"
9	"testing"
10	"time"
11
12	"github.com/bnema/zerowrap"
13	"github.com/stretchr/testify/assert"
14
15	"github.com/bnema/gordon/internal/domain"
16)
17
18func testLogger() zerowrap.Logger {
19	return zerowrap.Default()
20}
21
22func TestRegistryAuth_ValidCredentials(t *testing.T) {
23	log := testLogger()
24	middleware := RegistryAuth("admin", "secret", log)
25
26	handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27		w.WriteHeader(http.StatusOK)
28		w.Write([]byte("authenticated"))
29	}))
30
31	req := httptest.NewRequest("GET", "/v2/", nil)
32	req.SetBasicAuth("admin", "secret")
33	rec := httptest.NewRecorder()
34
35	handler.ServeHTTP(rec, req)
36
37	assert.Equal(t, http.StatusOK, rec.Code)
38	assert.Equal(t, "authenticated", rec.Body.String())
39}
40
41func TestRegistryAuth_InvalidUsername(t *testing.T) {
42	log := testLogger()
43	middleware := RegistryAuth("admin", "secret", log)
44
45	handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
46		w.WriteHeader(http.StatusOK)
47	}))
48
49	req := httptest.NewRequest("GET", "/v2/", nil)
50	req.SetBasicAuth("wronguser", "secret")
51	rec := httptest.NewRecorder()
52
53	handler.ServeHTTP(rec, req)
54
55	assert.Equal(t, http.StatusUnauthorized, rec.Code)
56	assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
57	assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
58}
59
60func TestRegistryAuth_InvalidPassword(t *testing.T) {
61	log := testLogger()
62	middleware := RegistryAuth("admin", "secret", log)
63
64	handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
65		w.WriteHeader(http.StatusOK)
66	}))
67
68	req := httptest.NewRequest("GET", "/v2/", nil)
69	req.SetBasicAuth("admin", "wrongpassword")
70	rec := httptest.NewRecorder()
71
72	handler.ServeHTTP(rec, req)
73
74	assert.Equal(t, http.StatusUnauthorized, rec.Code)
75}
76
77func TestRegistryAuth_NoCredentials(t *testing.T) {
78	log := testLogger()
79	middleware := RegistryAuth("admin", "secret", log)
80
81	handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
82		w.WriteHeader(http.StatusOK)
83	}))
84
85	req := httptest.NewRequest("GET", "/v2/", nil)
86	// No auth header
87	rec := httptest.NewRecorder()
88
89	handler.ServeHTTP(rec, req)
90
91	assert.Equal(t, http.StatusUnauthorized, rec.Code)
92	assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
93}
94
95func TestRegistryAuth_MalformedAuthHeader(t *testing.T) {
96	log := testLogger()
97	middleware := RegistryAuth("admin", "secret", log)
98
99	handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100		w.WriteHeader(http.StatusOK)
101	}))
102
103	req := httptest.NewRequest("GET", "/v2/", nil)
104	req.Header.Set("Authorization", "Basic notbase64!!!") // Invalid base64
105	rec := httptest.NewRecorder()
106
107	handler.ServeHTTP(rec, req)
108
109	assert.Equal(t, http.StatusUnauthorized, rec.Code)
110}
111
112func TestRegistryAuth_WrongAuthScheme(t *testing.T) {
113	log := testLogger()
114	middleware := RegistryAuth("admin", "secret", log)
115
116	handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117		w.WriteHeader(http.StatusOK)
118	}))
119
120	req := httptest.NewRequest("GET", "/v2/", nil)
121	req.Header.Set("Authorization", "Bearer some-token")
122	rec := httptest.NewRecorder()
123
124	handler.ServeHTTP(rec, req)
125
126	assert.Equal(t, http.StatusUnauthorized, rec.Code)
127}
128
129func TestRegistryAuth_EmptyCredentials(t *testing.T) {
130	log := testLogger()
131	middleware := RegistryAuth("admin", "secret", log)
132
133	handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134		w.WriteHeader(http.StatusOK)
135	}))
136
137	req := httptest.NewRequest("GET", "/v2/", nil)
138	req.SetBasicAuth("", "")
139	rec := httptest.NewRecorder()
140
141	handler.ServeHTTP(rec, req)
142
143	assert.Equal(t, http.StatusUnauthorized, rec.Code)
144}
145
146func TestRegistryAuth_PartialCredentials(t *testing.T) {
147	log := testLogger()
148	middleware := RegistryAuth("admin", "secret", log)
149
150	handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151		w.WriteHeader(http.StatusOK)
152	}))
153
154	// Only username, empty password
155	req := httptest.NewRequest("GET", "/v2/", nil)
156	req.SetBasicAuth("admin", "")
157	rec := httptest.NewRecorder()
158
159	handler.ServeHTTP(rec, req)
160
161	assert.Equal(t, http.StatusUnauthorized, rec.Code)
162}
163
164func TestIsAuthenticated_ValidCredentials(t *testing.T) {
165	log := testLogger()
166
167	req := httptest.NewRequest("GET", "/v2/", nil)
168	req.SetBasicAuth("admin", "secret")
169
170	result := isAuthenticated(req, "admin", "secret", log)
171
172	assert.True(t, result)
173}
174
175func TestIsAuthenticated_InvalidCredentials(t *testing.T) {
176	log := testLogger()
177
178	req := httptest.NewRequest("GET", "/v2/", nil)
179	req.SetBasicAuth("admin", "wrong")
180
181	result := isAuthenticated(req, "admin", "secret", log)
182
183	assert.False(t, result)
184}
185
186func TestIsAuthenticated_NoAuthHeader(t *testing.T) {
187	log := testLogger()
188
189	req := httptest.NewRequest("GET", "/v2/", nil)
190
191	result := isAuthenticated(req, "admin", "secret", log)
192
193	assert.False(t, result)
194}
195
196func TestIsAuthenticated_TimingAttackPrevention(t *testing.T) {
197	// This test verifies that constant-time comparison is used
198	// by checking both username and password are validated
199	log := testLogger()
200
201	tests := []struct {
202		name         string
203		username     string
204		password     string
205		expectedUser string
206		expectedPass string
207		shouldAuth   bool
208	}{
209		{
210			name:         "both correct",
211			username:     "admin",
212			password:     "secret",
213			expectedUser: "admin",
214			expectedPass: "secret",
215			shouldAuth:   true,
216		},
217		{
218			name:         "username wrong first char",
219			username:     "bdmin",
220			password:     "secret",
221			expectedUser: "admin",
222			expectedPass: "secret",
223			shouldAuth:   false,
224		},
225		{
226			name:         "username wrong last char",
227			username:     "admio",
228			password:     "secret",
229			expectedUser: "admin",
230			expectedPass: "secret",
231			shouldAuth:   false,
232		},
233		{
234			name:         "password wrong first char",
235			username:     "admin",
236			password:     "aecret",
237			expectedUser: "admin",
238			expectedPass: "secret",
239			shouldAuth:   false,
240		},
241		{
242			name:         "password wrong last char",
243			username:     "admin",
244			password:     "secreo",
245			expectedUser: "admin",
246			expectedPass: "secret",
247			shouldAuth:   false,
248		},
249		{
250			name:         "different length username",
251			username:     "adm",
252			password:     "secret",
253			expectedUser: "admin",
254			expectedPass: "secret",
255			shouldAuth:   false,
256		},
257		{
258			name:         "different length password",
259			username:     "admin",
260			password:     "sec",
261			expectedUser: "admin",
262			expectedPass: "secret",
263			shouldAuth:   false,
264		},
265	}
266
267	for _, tt := range tests {
268		t.Run(tt.name, func(t *testing.T) {
269			req := httptest.NewRequest("GET", "/v2/", nil)
270			req.SetBasicAuth(tt.username, tt.password)
271
272			result := isAuthenticated(req, tt.expectedUser, tt.expectedPass, log)
273
274			assert.Equal(t, tt.shouldAuth, result)
275		})
276	}
277}
278
279func TestRegistryAuth_SpecialCharactersInCredentials(t *testing.T) {
280	log := testLogger()
281
282	tests := []struct {
283		name     string
284		username string
285		password string
286	}{
287		{"unicode username", "用户", "password"},
288		{"unicode password", "admin", "密码"},
289		{"special chars", "admin@example.com", "p@ss:w0rd!"},
290		{"spaces", "admin user", "pass word"},
291		{"colon in password", "admin", "pass:word"},
292	}
293
294	for _, tt := range tests {
295		t.Run(tt.name, func(t *testing.T) {
296			middleware := RegistryAuth(tt.username, tt.password, log)
297
298			handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299				w.WriteHeader(http.StatusOK)
300			}))
301
302			req := httptest.NewRequest("GET", "/v2/", nil)
303			req.SetBasicAuth(tt.username, tt.password)
304			rec := httptest.NewRecorder()
305
306			handler.ServeHTTP(rec, req)
307
308			assert.Equal(t, http.StatusOK, rec.Code)
309		})
310	}
311}
312
313func TestRegistryAuth_Base64EdgeCases(t *testing.T) {
314	log := testLogger()
315	middleware := RegistryAuth("admin", "secret", log)
316
317	handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
318		w.WriteHeader(http.StatusOK)
319	}))
320
321	tests := []struct {
322		name       string
323		authHeader string
324		wantStatus int
325	}{
326		{
327			name:       "empty basic auth",
328			authHeader: "Basic ",
329			wantStatus: http.StatusUnauthorized,
330		},
331		{
332			name:       "basic auth with only colon",
333			authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte(":")),
334			wantStatus: http.StatusUnauthorized,
335		},
336		{
337			name:       "missing colon in decoded value",
338			authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("adminpassword")),
339			wantStatus: http.StatusUnauthorized,
340		},
341	}
342
343	for _, tt := range tests {
344		t.Run(tt.name, func(t *testing.T) {
345			req := httptest.NewRequest("GET", "/v2/", nil)
346			req.Header.Set("Authorization", tt.authHeader)
347			rec := httptest.NewRecorder()
348
349			handler.ServeHTTP(rec, req)
350
351			assert.Equal(t, tt.wantStatus, rec.Code)
352		})
353	}
354}
355
356func TestRegistryAuthV2_LocalhostBypassRequiresInternalAuth(t *testing.T) {
357	log := testLogger()
358	called := false
359	authSvc := stubAuthService{
360		enabled:  true,
361		authType: domain.AuthTypePassword,
362		validatePassword: func(_ context.Context, _ string, _ string) bool {
363			called = true
364			return false
365		},
366	}
367	internalAuth := InternalRegistryAuth{
368		Username: "internal",
369		Password: "secret",
370	}
371
372	handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
373		w.WriteHeader(http.StatusOK)
374	}))
375
376	req := httptest.NewRequest("GET", "/v2/", nil)
377	req.RemoteAddr = "127.0.0.1:12345"
378	req.SetBasicAuth("internal", "secret")
379	rec := httptest.NewRecorder()
380
381	handler.ServeHTTP(rec, req)
382
383	assert.Equal(t, http.StatusOK, rec.Code)
384	assert.False(t, called, "auth service should not be used for internal loopback auth")
385}
386
387func TestRegistryAuthV2_LocalhostWrongInternalAuthFallsBack(t *testing.T) {
388	log := testLogger()
389	called := false
390	authSvc := stubAuthService{
391		enabled:  true,
392		authType: domain.AuthTypePassword,
393		validatePassword: func(_ context.Context, _ string, _ string) bool {
394			called = true
395			return false
396		},
397	}
398	internalAuth := InternalRegistryAuth{
399		Username: "internal",
400		Password: "secret",
401	}
402
403	handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
404		w.WriteHeader(http.StatusOK)
405	}))
406
407	req := httptest.NewRequest("GET", "/v2/", nil)
408	req.RemoteAddr = "127.0.0.1:12345"
409	req.SetBasicAuth("internal", "wrong")
410	rec := httptest.NewRecorder()
411
412	handler.ServeHTTP(rec, req)
413
414	assert.Equal(t, http.StatusUnauthorized, rec.Code)
415	assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
416	assert.True(t, called, "auth service should be used when internal auth fails")
417}
418
419func TestRegistryAuthV2_NonLocalhostIgnoresInternalAuth(t *testing.T) {
420	log := testLogger()
421	called := false
422	authSvc := stubAuthService{
423		enabled:  true,
424		authType: domain.AuthTypePassword,
425		validatePassword: func(_ context.Context, _ string, _ string) bool {
426			called = true
427			return false
428		},
429	}
430	internalAuth := InternalRegistryAuth{
431		Username: "internal",
432		Password: "secret",
433	}
434
435	handler := RegistryAuthV2(authSvc, internalAuth, log)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
436		w.WriteHeader(http.StatusOK)
437	}))
438
439	req := httptest.NewRequest("GET", "/v2/", nil)
440	req.RemoteAddr = "192.168.1.10:12345"
441	req.SetBasicAuth("internal", "secret")
442	rec := httptest.NewRecorder()
443
444	handler.ServeHTTP(rec, req)
445
446	assert.Equal(t, http.StatusUnauthorized, rec.Code)
447	assert.Equal(t, `Basic realm="Gordon Registry"`, rec.Header().Get("WWW-Authenticate"))
448	assert.True(t, called, "auth service should be used for non-localhost requests")
449}
450
451type stubAuthService struct {
452	enabled          bool
453	authType         domain.AuthType
454	validatePassword func(ctx context.Context, username, password string) bool
455	validateToken    func(ctx context.Context, tokenString string) (*domain.TokenClaims, error)
456}
457
458func (s stubAuthService) GetAuthType() domain.AuthType {
459	return s.authType
460}
461
462func (s stubAuthService) IsEnabled() bool {
463	return s.enabled
464}
465
466func (s stubAuthService) ValidatePassword(ctx context.Context, username, password string) bool {
467	if s.validatePassword != nil {
468		return s.validatePassword(ctx, username, password)
469	}
470	return false
471}
472
473func (s stubAuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.TokenClaims, error) {
474	if s.validateToken != nil {
475		return s.validateToken(ctx, tokenString)
476	}
477	return nil, errors.New("not implemented")
478}
479
480func (s stubAuthService) GenerateToken(context.Context, string, []string, time.Duration) (string, error) {
481	return "", errors.New("not implemented")
482}
483
484func (s stubAuthService) GenerateAccessToken(context.Context, string, []string, time.Duration) (string, error) {
485	return "", errors.New("not implemented")
486}
487
488func (s stubAuthService) RevokeToken(context.Context, string) error {
489	return errors.New("not implemented")
490}
491
492func (s stubAuthService) RevokeAllTokens(context.Context) (int, error) {
493	return 0, errors.New("not implemented")
494}
495
496func (s stubAuthService) ListTokens(context.Context) ([]domain.Token, error) {
497	return nil, errors.New("not implemented")
498}
499
500func (s stubAuthService) GeneratePasswordHash(string) (string, error) {
501	return "", errors.New("not implemented")
502}
503
504func (s stubAuthService) GetAuthStatus(context.Context) (*domain.AuthStatus, error) {
505	return nil, errors.New("not implemented")
506}
507
508// Tests for checkScopeAccess function
509
510func TestCheckScopeAccess_ActionMapping(t *testing.T) {
511	log := testLogger()
512
513	tests := []struct {
514		name           string
515		method         string
516		expectedAction string
517	}{
518		{"GET maps to pull", http.MethodGet, "pull"},
519		{"HEAD maps to pull", http.MethodHead, "pull"},
520		{"PUT maps to push", http.MethodPut, "push"},
521		{"POST maps to push", http.MethodPost, "push"},
522		{"PATCH maps to push", http.MethodPatch, "push"},
523		{"DELETE maps to push", http.MethodDelete, "push"},
524		{"OPTIONS defaults to pull", http.MethodOptions, "pull"},
525	}
526
527	for _, tt := range tests {
528		t.Run(tt.name, func(t *testing.T) {
529			claims := &domain.TokenClaims{
530				Scopes: []string{"repository:myrepo:" + tt.expectedAction},
531			}
532
533			req := httptest.NewRequest(tt.method, "/v2/myrepo/manifests/latest", nil)
534			result := checkScopeAccess(req, claims, log)
535
536			assert.True(t, result, "method %s should be allowed with %s scope", tt.method, tt.expectedAction)
537		})
538	}
539}
540
541func TestCheckScopeAccess_RepoNameExtraction(t *testing.T) {
542	log := testLogger()
543
544	tests := []struct {
545		name     string
546		path     string
547		wantRepo string
548		scopes   []string
549		want     bool
550	}{
551		{
552			name:   "simple repo with manifests",
553			path:   "/v2/myrepo/manifests/latest",
554			scopes: []string{"repository:myrepo:pull"},
555			want:   true,
556		},
557		{
558			name:   "simple repo with blobs",
559			path:   "/v2/myrepo/blobs/sha256:abc123",
560			scopes: []string{"repository:myrepo:pull"},
561			want:   true,
562		},
563		{
564			name:   "nested repo with manifests",
565			path:   "/v2/myorg/myapp/manifests/v1.0",
566			scopes: []string{"repository:myorg/myapp:pull"},
567			want:   true,
568		},
569		{
570			name:   "deeply nested repo",
571			path:   "/v2/myorg/team/myapp/manifests/latest",
572			scopes: []string{"repository:myorg/team/myapp:pull"},
573			want:   true,
574		},
575		{
576			name:   "repo with tags list",
577			path:   "/v2/myrepo/tags/list",
578			scopes: []string{"repository:myrepo:pull"},
579			want:   true,
580		},
581		{
582			name:   "wrong repo name",
583			path:   "/v2/myrepo/manifests/latest",
584			scopes: []string{"repository:otherrepo:pull"},
585			want:   false,
586		},
587	}
588
589	for _, tt := range tests {
590		t.Run(tt.name, func(t *testing.T) {
591			claims := &domain.TokenClaims{Scopes: tt.scopes}
592			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
593			result := checkScopeAccess(req, claims, log)
594			assert.Equal(t, tt.want, result)
595		})
596	}
597}
598
599func TestCheckScopeAccess_WildcardMatching(t *testing.T) {
600	log := testLogger()
601
602	tests := []struct {
603		name   string
604		path   string
605		scopes []string
606		want   bool
607	}{
608		{
609			name:   "wildcard repo matches any",
610			path:   "/v2/anyrepo/manifests/latest",
611			scopes: []string{"repository:*:pull"},
612			want:   true,
613		},
614		{
615			name:   "org wildcard matches repo in org",
616			path:   "/v2/myorg/myapp/manifests/latest",
617			scopes: []string{"repository:myorg/*:pull"},
618			want:   true,
619		},
620		{
621			name:   "org wildcard does not match other org",
622			path:   "/v2/otherorg/myapp/manifests/latest",
623			scopes: []string{"repository:myorg/*:pull"},
624			want:   false,
625		},
626		{
627			name:   "wildcard action grants push",
628			path:   "/v2/myrepo/manifests/latest",
629			scopes: []string{"repository:myrepo:*"},
630			want:   true,
631		},
632	}
633
634	for _, tt := range tests {
635		t.Run(tt.name, func(t *testing.T) {
636			claims := &domain.TokenClaims{Scopes: tt.scopes}
637			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
638			result := checkScopeAccess(req, claims, log)
639			assert.Equal(t, tt.want, result)
640		})
641	}
642}
643
644func TestCheckScopeAccess_SpecialRoutes(t *testing.T) {
645	log := testLogger()
646
647	tests := []struct {
648		name   string
649		path   string
650		scopes []string
651		want   bool
652	}{
653		{
654			name:   "v2 root is allowed",
655			path:   "/v2/",
656			scopes: []string{"repository:myrepo:pull"},
657			want:   true,
658		},
659		{
660			name:   "non-v2 path is allowed",
661			path:   "/healthz",
662			scopes: []string{"repository:myrepo:pull"},
663			want:   true,
664		},
665		{
666			name:   "catalog path is handled",
667			path:   "/v2/_catalog",
668			scopes: []string{"repository:myrepo:pull"},
669			want:   true,
670		},
671	}
672
673	for _, tt := range tests {
674		t.Run(tt.name, func(t *testing.T) {
675			claims := &domain.TokenClaims{Scopes: tt.scopes}
676			req := httptest.NewRequest(http.MethodGet, tt.path, nil)
677			result := checkScopeAccess(req, claims, log)
678			assert.Equal(t, tt.want, result)
679		})
680	}
681}
682
683func TestCheckScopeAccess_DenialCases(t *testing.T) {
684	log := testLogger()
685
686	tests := []struct {
687		name   string
688		method string
689		path   string
690		scopes []string
691		want   bool
692	}{
693		{
694			name:   "pull scope denies push",
695			method: http.MethodPut,
696			path:   "/v2/myrepo/manifests/latest",
697			scopes: []string{"repository:myrepo:pull"},
698			want:   false,
699		},
700		{
701			name:   "wrong repo denies access",
702			method: http.MethodGet,
703			path:   "/v2/myrepo/manifests/latest",
704			scopes: []string{"repository:otherrepo:pull"},
705			want:   false,
706		},
707		{
708			name:   "empty scopes denies access",
709			method: http.MethodGet,
710			path:   "/v2/myrepo/manifests/latest",
711			scopes: []string{},
712			want:   false,
713		},
714		{
715			name:   "invalid scope format is skipped",
716			method: http.MethodGet,
717			path:   "/v2/myrepo/manifests/latest",
718			scopes: []string{"invalid-scope-format"},
719			want:   false,
720		},
721	}
722
723	for _, tt := range tests {
724		t.Run(tt.name, func(t *testing.T) {
725			claims := &domain.TokenClaims{Scopes: tt.scopes}
726			req := httptest.NewRequest(tt.method, tt.path, nil)
727			result := checkScopeAccess(req, claims, log)
728			assert.Equal(t, tt.want, result)
729		})
730	}
731}

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