v2.31.0-dev23 58a92f3

handler_test.go RawHistoryFolder

1package auth
2
3import (
4	"bytes"
5	"encoding/json"
6	"fmt"
7	"net/http"
8	"net/http/httptest"
9	"testing"
10	"time"
11
12	"github.com/bnema/zerowrap"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15
16	"github.com/bnema/gordon/internal/adapters/in/http/httphelper"
17	"github.com/bnema/gordon/internal/boundaries/in/mocks"
18	"github.com/bnema/gordon/internal/domain"
19)
20
21func testLogger() zerowrap.Logger {
22	return zerowrap.Default()
23}
24
25func TestHandler_Password_AlwaysGone(t *testing.T) {
26	handler := NewHandler(nil, InternalAuth{}, testLogger())
27
28	body := `{"username":"admin","password":"secret"}`
29	req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
30	req.Header.Set("Content-Type", "application/json")
31	rec := httptest.NewRecorder()
32
33	handler.ServeHTTP(rec, req)
34
35	assert.Equal(t, http.StatusGone, rec.Code)
36	assert.Contains(t, rec.Body.String(), "password authentication has been removed")
37}
38
39func TestHandler_Token_Success(t *testing.T) {
40	authSvc := mocks.NewMockAuthService(t)
41
42	authSvc.EXPECT().IsEnabled().Return(true)
43	authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
44		Subject: "admin",
45		Scopes:  []string{"repository:myrepo:pull"},
46	}, nil)
47	authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
48	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 15*time.Minute).
49		Return("access-token", nil)
50
51	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
52
53	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
54	req.SetBasicAuth("admin", "existing-jwt-token")
55	rec := httptest.NewRecorder()
56
57	handler.ServeHTTP(rec, req)
58
59	assert.Equal(t, http.StatusOK, rec.Code)
60
61	var resp map[string]any
62	err := json.NewDecoder(rec.Body).Decode(&resp)
63	assert.NoError(t, err)
64	assert.Equal(t, "access-token", resp["token"])
65	assert.Equal(t, float64(900), resp["expires_in"])
66}
67
68func TestHandler_Token_MissingCredentials(t *testing.T) {
69	authSvc := mocks.NewMockAuthService(t)
70
71	authSvc.EXPECT().IsEnabled().Return(true)
72
73	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
74
75	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
76	rec := httptest.NewRecorder()
77
78	handler.ServeHTTP(rec, req)
79
80	assert.Equal(t, http.StatusUnauthorized, rec.Code)
81}
82
83func TestHandler_Token_AuthDisabled(t *testing.T) {
84	authSvc := mocks.NewMockAuthService(t)
85
86	authSvc.EXPECT().IsEnabled().Return(false)
87
88	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
89
90	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
91	rec := httptest.NewRecorder()
92
93	handler.ServeHTTP(rec, req)
94
95	assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
96	assert.Contains(t, rec.Body.String(), "authentication is required")
97}
98
99func TestHandler_Token_InvalidCredentials(t *testing.T) {
100	authSvc := mocks.NewMockAuthService(t)
101
102	authSvc.EXPECT().IsEnabled().Return(true)
103	authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, fmt.Errorf("invalid token"))
104
105	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
106
107	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
108	req.SetBasicAuth("admin", "invalid-token")
109	rec := httptest.NewRecorder()
110
111	handler.ServeHTTP(rec, req)
112
113	assert.Equal(t, http.StatusUnauthorized, rec.Code)
114}
115
116func TestHandler_Token_InternalAuth(t *testing.T) {
117	authSvc := mocks.NewMockAuthService(t)
118
119	authSvc.EXPECT().IsEnabled().Return(true)
120	authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
121	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 15*time.Minute).
122		Return("internal-access-token", nil)
123
124	internalAuth := InternalAuth{
125		Username: "gordon-internal",
126		Password: "internal-secret",
127	}
128	handler := NewHandler(authSvc, internalAuth, testLogger())
129
130	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
131	req.RemoteAddr = "127.0.0.1:12345"
132	req.SetBasicAuth("gordon-internal", "internal-secret")
133	rec := httptest.NewRecorder()
134
135	handler.ServeHTTP(rec, req)
136
137	assert.Equal(t, http.StatusOK, rec.Code)
138
139	var resp map[string]any
140	err := json.NewDecoder(rec.Body).Decode(&resp)
141	assert.NoError(t, err)
142	assert.Equal(t, "internal-access-token", resp["token"])
143}
144
145func TestHandler_Token_TokenAuth(t *testing.T) {
146	authSvc := mocks.NewMockAuthService(t)
147
148	authSvc.EXPECT().IsEnabled().Return(true)
149	authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
150		Subject: "myuser",
151		Scopes:  []string{"repository:*:pull"},
152	}, nil)
153	authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
154	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 15*time.Minute).
155		Return("short-lived-token", nil)
156
157	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
158
159	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
160	req.SetBasicAuth("myuser", "existing-jwt-token")
161	rec := httptest.NewRecorder()
162
163	handler.ServeHTTP(rec, req)
164
165	assert.Equal(t, http.StatusOK, rec.Code)
166
167	var resp map[string]any
168	err := json.NewDecoder(rec.Body).Decode(&resp)
169	assert.NoError(t, err)
170	assert.Equal(t, "short-lived-token", resp["token"])
171}
172
173func TestHandler_Token_TokenAuthScopeIntersection(t *testing.T) {
174	authSvc := mocks.NewMockAuthService(t)
175
176	authSvc.EXPECT().IsEnabled().Return(true)
177	authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
178		Subject: "myuser",
179		Scopes:  []string{"repository:myrepo:pull"},
180	}, nil)
181	authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
182	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
183		Return("scoped-token", nil)
184
185	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
186
187	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull,push", nil)
188	req.SetBasicAuth("myuser", "existing-jwt-token")
189	rec := httptest.NewRecorder()
190
191	handler.ServeHTTP(rec, req)
192
193	assert.Equal(t, http.StatusOK, rec.Code)
194	var resp map[string]any
195	err := json.NewDecoder(rec.Body).Decode(&resp)
196	assert.NoError(t, err)
197	assert.Equal(t, "scoped-token", resp["token"])
198}
199
200func TestHandler_Token_TokenAuthScopeDenied(t *testing.T) {
201	authSvc := mocks.NewMockAuthService(t)
202
203	authSvc.EXPECT().IsEnabled().Return(true)
204	authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
205		Subject: "myuser",
206		Scopes:  []string{"repository:myrepo:pull"},
207	}, nil)
208
209	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
210
211	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:push", nil)
212	req.SetBasicAuth("myuser", "existing-jwt-token")
213	rec := httptest.NewRecorder()
214
215	handler.ServeHTTP(rec, req)
216
217	assert.Equal(t, http.StatusForbidden, rec.Code)
218	assert.Contains(t, rec.Body.String(), "insufficient scope")
219}
220
221func TestHandler_Token_PasswordAuthWithJWT(t *testing.T) {
222	authSvc := mocks.NewMockAuthService(t)
223
224	// Auth type is password, but the client sends a JWT (from gordon auth login)
225	authSvc.EXPECT().IsEnabled().Return(true)
226	authSvc.EXPECT().ValidateToken(mock.Anything, "jwt-from-login").Return(&domain.TokenClaims{
227		Subject: "brissou",
228		Scopes:  []string{"*"},
229	}, nil)
230	authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
231	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "brissou", []string{"repository:*:push,pull"}, 15*time.Minute).
232		Return("short-lived-token", nil)
233
234	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
235
236	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:*:push,pull", nil)
237	req.SetBasicAuth("brissou", "jwt-from-login")
238	rec := httptest.NewRecorder()
239
240	handler.ServeHTTP(rec, req)
241
242	assert.Equal(t, http.StatusOK, rec.Code)
243	var resp map[string]any
244	err := json.NewDecoder(rec.Body).Decode(&resp)
245	assert.NoError(t, err)
246	assert.Equal(t, "short-lived-token", resp["token"])
247}
248
249func TestHandler_Token_MethodNotAllowed(t *testing.T) {
250	handler := NewHandler(nil, InternalAuth{}, testLogger())
251
252	methods := []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
253
254	for _, method := range methods {
255		t.Run(method, func(t *testing.T) {
256			req := httptest.NewRequest(method, "/auth/token", nil)
257			rec := httptest.NewRecorder()
258
259			handler.ServeHTTP(rec, req)
260
261			assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
262		})
263	}
264}
265
266func TestHandler_NotFound(t *testing.T) {
267	handler := NewHandler(nil, InternalAuth{}, testLogger())
268
269	paths := []string{"/auth/unknown", "/auth/", "/auth"}
270
271	for _, path := range paths {
272		t.Run(path, func(t *testing.T) {
273			req := httptest.NewRequest(http.MethodGet, path, nil)
274			rec := httptest.NewRecorder()
275
276			handler.ServeHTTP(rec, req)
277
278			assert.Equal(t, http.StatusNotFound, rec.Code)
279		})
280	}
281}
282
283func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
284	log := testLogger()
285	h := &Handler{log: log}
286
287	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
288
289	scopes := h.parseRequestedScopes(req, log)
290
291	assert.Len(t, scopes, 2)
292	assert.Contains(t, scopes, "repository:repo1:pull")
293	assert.Contains(t, scopes, "repository:repo2:push")
294}
295
296func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
297	log := testLogger()
298	h := &Handler{log: log}
299
300	tests := []struct {
301		name       string
302		query      string
303		wantScopes []string
304	}{
305		{
306			name:       "invalid format is skipped",
307			query:      "scope=invalid-no-colons&scope=repository:valid:pull",
308			wantScopes: []string{"repository:valid:pull"},
309		},
310		{
311			name:       "missing actions is skipped",
312			query:      "scope=repository:myrepo&scope=repository:valid:pull",
313			wantScopes: []string{"repository:valid:pull"},
314		},
315		{
316			name:       "all invalid returns default",
317			query:      "scope=invalid&scope=also-invalid",
318			wantScopes: []string{"repository:*:pull"},
319		},
320	}
321
322	for _, tt := range tests {
323		t.Run(tt.name, func(t *testing.T) {
324			req := httptest.NewRequest(http.MethodGet, "/auth/token?"+tt.query, nil)
325			scopes := h.parseRequestedScopes(req, log)
326
327			assert.Equal(t, tt.wantScopes, scopes)
328		})
329	}
330}
331
332func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
333	log := testLogger()
334	h := &Handler{log: log}
335
336	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
337	scopes := h.parseRequestedScopes(req, log)
338
339	assert.Equal(t, []string{"repository:*:pull"}, scopes)
340}
341
342func TestIsLocalhostRequest(t *testing.T) {
343	tests := []struct {
344		name       string
345		remoteAddr string
346		want       bool
347	}{
348		{"127.0.0.1 is localhost", "127.0.0.1:12345", true},
349		{"127.0.0.2 is localhost", "127.0.0.2:12345", true},
350		{"::1 is localhost", "[::1]:12345", true},
351		{"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
352		{"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
353		{"public IP is not localhost", "8.8.8.8:12345", false},
354	}
355
356	for _, tt := range tests {
357		t.Run(tt.name, func(t *testing.T) {
358			req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
359			req.RemoteAddr = tt.remoteAddr
360
361			result := httphelper.IsLocalhostRequest(req)
362
363			assert.Equal(t, tt.want, result)
364		})
365	}
366}
367
368func TestHandler_isInternalAuth(t *testing.T) {
369	tests := []struct {
370		name         string
371		username     string
372		password     string
373		internalAuth InternalAuth
374		wantResult   bool
375	}{
376		{
377			name:     "correct credentials",
378			username: "gordon-internal",
379			password: "internal-secret",
380			internalAuth: InternalAuth{
381				Username: "gordon-internal",
382				Password: "internal-secret",
383			},
384			wantResult: true,
385		},
386		{
387			name:     "incorrect username",
388			username: "wrong-username",
389			password: "internal-secret",
390			internalAuth: InternalAuth{
391				Username: "gordon-internal",
392				Password: "internal-secret",
393			},
394			wantResult: false,
395		},
396		{
397			name:     "incorrect password",
398			username: "gordon-internal",
399			password: "wrong-password",
400			internalAuth: InternalAuth{
401				Username: "gordon-internal",
402				Password: "internal-secret",
403			},
404			wantResult: false,
405		},
406		{
407			name:     "empty internal auth credentials",
408			username: "gordon-internal",
409			password: "internal-secret",
410			internalAuth: InternalAuth{
411				Username: "",
412				Password: "",
413			},
414			wantResult: false,
415		},
416		{
417			name:     "partially empty internal auth - empty password",
418			username: "gordon-internal",
419			password: "internal-secret",
420			internalAuth: InternalAuth{
421				Username: "gordon-internal",
422				Password: "",
423			},
424			wantResult: false,
425		},
426		{
427			name:     "partially empty internal auth - empty username",
428			username: "gordon-internal",
429			password: "internal-secret",
430			internalAuth: InternalAuth{
431				Username: "",
432				Password: "internal-secret",
433			},
434			wantResult: false,
435		},
436	}
437
438	for _, tt := range tests {
439		t.Run(tt.name, func(t *testing.T) {
440			h := &Handler{
441				internalAuth: tt.internalAuth,
442			}
443
444			result := h.isInternalAuth(tt.username, tt.password)
445
446			assert.Equal(t, tt.wantResult, result)
447		})
448	}
449}
450
451func TestHandler_Token_EphemeralTokenRejected(t *testing.T) {
452	authSvc := mocks.NewMockAuthService(t)
453
454	authSvc.EXPECT().IsEnabled().Return(true)
455	authSvc.EXPECT().ValidateToken(mock.Anything, "ephemeral-jwt-token").Return(&domain.TokenClaims{
456		Subject:     "myuser",
457		Scopes:      []string{"repository:*:pull"},
458		IsEphemeral: true,
459	}, nil)
460
461	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
462
463	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
464	req.SetBasicAuth("myuser", "ephemeral-jwt-token")
465	rec := httptest.NewRecorder()
466
467	handler.ServeHTTP(rec, req)
468
469	assert.Equal(t, http.StatusUnauthorized, rec.Code)
470	assert.Contains(t, rec.Body.String(), "unauthorized")
471}
472
473func TestHandler_Token_LongLivedTokenAccepted(t *testing.T) {
474	authSvc := mocks.NewMockAuthService(t)
475
476	authSvc.EXPECT().IsEnabled().Return(true)
477	authSvc.EXPECT().ValidateToken(mock.Anything, "long-lived-jwt-token").Return(&domain.TokenClaims{
478		Subject:     "myuser",
479		Scopes:      []string{"repository:*:pull"},
480		IsEphemeral: false,
481	}, nil)
482	authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
483	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:myrepo:pull"}, 15*time.Minute).
484		Return("new-access-token", nil)
485
486	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
487
488	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
489	req.SetBasicAuth("myuser", "long-lived-jwt-token")
490	rec := httptest.NewRecorder()
491
492	handler.ServeHTTP(rec, req)
493
494	assert.Equal(t, http.StatusOK, rec.Code)
495
496	var resp map[string]any
497	err := json.NewDecoder(rec.Body).Decode(&resp)
498	assert.NoError(t, err)
499	assert.Equal(t, "new-access-token", resp["token"])
500}
501
502func TestHandler_Token_AdminScopeSuccess(t *testing.T) {
503	authSvc := mocks.NewMockAuthService(t)
504
505	authSvc.EXPECT().IsEnabled().Return(true)
506	authSvc.EXPECT().ValidateToken(mock.Anything, "admin-jwt-token").Return(&domain.TokenClaims{
507		Subject: "admin",
508		Scopes:  []string{"admin:routes:read,write"},
509	}, nil)
510	authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
511	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"admin:routes:read"}, 15*time.Minute).
512		Return("admin-access-token", nil)
513
514	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
515
516	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
517	req.SetBasicAuth("admin", "admin-jwt-token")
518	rec := httptest.NewRecorder()
519
520	handler.ServeHTTP(rec, req)
521
522	assert.Equal(t, http.StatusOK, rec.Code)
523
524	var resp map[string]any
525	err := json.NewDecoder(rec.Body).Decode(&resp)
526	assert.NoError(t, err)
527	assert.Equal(t, "admin-access-token", resp["token"])
528}
529
530func TestHandler_Token_AdminScopeDeniedWithRegistryOnly(t *testing.T) {
531	authSvc := mocks.NewMockAuthService(t)
532
533	authSvc.EXPECT().IsEnabled().Return(true)
534	authSvc.EXPECT().ValidateToken(mock.Anything, "registry-jwt-token").Return(&domain.TokenClaims{
535		Subject: "myuser",
536		Scopes:  []string{"repository:myrepo:pull,push"},
537	}, nil)
538
539	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
540
541	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
542	req.SetBasicAuth("myuser", "registry-jwt-token")
543	rec := httptest.NewRecorder()
544
545	handler.ServeHTTP(rec, req)
546
547	assert.Equal(t, http.StatusForbidden, rec.Code)
548	assert.Contains(t, rec.Body.String(), "insufficient scope")
549}
550
551func TestHandler_Token_AdminScopeWildcard(t *testing.T) {
552	authSvc := mocks.NewMockAuthService(t)
553
554	authSvc.EXPECT().IsEnabled().Return(true)
555	authSvc.EXPECT().ValidateToken(mock.Anything, "admin-wildcard-token").Return(&domain.TokenClaims{
556		Subject: "superadmin",
557		Scopes:  []string{"admin:*:*"},
558	}, nil)
559	authSvc.EXPECT().GetAccessTokenTTL().Return(15 * time.Minute)
560	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "superadmin", []string{"admin:routes:read"}, 15*time.Minute).
561		Return("wildcard-access-token", nil)
562
563	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
564
565	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=admin:routes:read", nil)
566	req.SetBasicAuth("superadmin", "admin-wildcard-token")
567	rec := httptest.NewRecorder()
568
569	handler.ServeHTTP(rec, req)
570
571	assert.Equal(t, http.StatusOK, rec.Code)
572
573	var resp map[string]any
574	err := json.NewDecoder(rec.Body).Decode(&resp)
575	assert.NoError(t, err)
576	assert.Equal(t, "wildcard-access-token", resp["token"])
577}

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