v2.30.0-pre 7341e0e

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

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