v2.7.0 fac7705

handler_test.go RawHistoryFolder

1package auth
2
3import (
4	"bytes"
5	"encoding/json"
6	"net/http"
7	"net/http/httptest"
8	"testing"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/mock"
14
15	"gordon/internal/boundaries/in/mocks"
16	"gordon/internal/domain"
17)
18
19func testLogger() zerowrap.Logger {
20	return zerowrap.Default()
21}
22
23func TestHandler_Password_Success(t *testing.T) {
24	authSvc := mocks.NewMockAuthService(t)
25
26	authSvc.EXPECT().IsEnabled().Return(true)
27	authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
28	authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
29	authSvc.EXPECT().GenerateToken(mock.Anything, "admin", []string{"push", "pull", "admin:*:*"}, 7*24*time.Hour).
30		Return("jwt-token-here", nil)
31
32	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
33
34	body := `{"username":"admin","password":"secret"}`
35	req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
36	req.Header.Set("Content-Type", "application/json")
37	rec := httptest.NewRecorder()
38
39	handler.ServeHTTP(rec, req)
40
41	assert.Equal(t, http.StatusOK, rec.Code)
42
43	var resp PasswordResponse
44	err := json.NewDecoder(rec.Body).Decode(&resp)
45	assert.NoError(t, err)
46	assert.Equal(t, "jwt-token-here", resp.Token)
47	assert.Equal(t, 7*24*60*60, resp.ExpiresIn) // 7 days in seconds
48	assert.NotEmpty(t, resp.IssuedAt)
49}
50
51func TestHandler_Password_InvalidCredentials(t *testing.T) {
52	authSvc := mocks.NewMockAuthService(t)
53
54	authSvc.EXPECT().IsEnabled().Return(true)
55	authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
56	authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
57
58	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
59
60	body := `{"username":"admin","password":"wrongpassword"}`
61	req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
62	req.Header.Set("Content-Type", "application/json")
63	rec := httptest.NewRecorder()
64
65	handler.ServeHTTP(rec, req)
66
67	assert.Equal(t, http.StatusUnauthorized, rec.Code)
68	assert.Contains(t, rec.Body.String(), "invalid credentials")
69}
70
71func TestHandler_Password_AuthTypeToken(t *testing.T) {
72	authSvc := mocks.NewMockAuthService(t)
73
74	authSvc.EXPECT().IsEnabled().Return(true)
75	authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
76
77	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
78
79	body := `{"username":"admin","password":"secret"}`
80	req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
81	req.Header.Set("Content-Type", "application/json")
82	rec := httptest.NewRecorder()
83
84	handler.ServeHTTP(rec, req)
85
86	assert.Equal(t, http.StatusBadRequest, rec.Code)
87	assert.Contains(t, rec.Body.String(), "password authentication not configured")
88}
89
90func TestHandler_Password_AuthDisabled(t *testing.T) {
91	authSvc := mocks.NewMockAuthService(t)
92
93	authSvc.EXPECT().IsEnabled().Return(false)
94
95	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
96
97	body := `{"username":"admin","password":"secret"}`
98	req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(body))
99	req.Header.Set("Content-Type", "application/json")
100	rec := httptest.NewRecorder()
101
102	handler.ServeHTTP(rec, req)
103
104	assert.Equal(t, http.StatusBadRequest, rec.Code)
105	assert.Contains(t, rec.Body.String(), "authentication is disabled")
106}
107
108func TestHandler_Password_MissingFields(t *testing.T) {
109	authSvc := mocks.NewMockAuthService(t)
110
111	authSvc.EXPECT().IsEnabled().Return(true)
112	authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
113
114	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
115
116	tests := []struct {
117		name string
118		body string
119	}{
120		{"missing username", `{"password":"secret"}`},
121		{"missing password", `{"username":"admin"}`},
122		{"empty username", `{"username":"","password":"secret"}`},
123		{"empty password", `{"username":"admin","password":""}`},
124	}
125
126	for _, tt := range tests {
127		t.Run(tt.name, func(t *testing.T) {
128			req := httptest.NewRequest(http.MethodPost, "/auth/password", bytes.NewBufferString(tt.body))
129			req.Header.Set("Content-Type", "application/json")
130			rec := httptest.NewRecorder()
131
132			handler.ServeHTTP(rec, req)
133
134			assert.Equal(t, http.StatusBadRequest, rec.Code)
135		})
136	}
137}
138
139func TestHandler_Password_MethodNotAllowed(t *testing.T) {
140	handler := NewHandler(nil, InternalAuth{}, testLogger())
141
142	methods := []string{http.MethodGet, http.MethodPut, http.MethodDelete, http.MethodPatch}
143
144	for _, method := range methods {
145		t.Run(method, func(t *testing.T) {
146			req := httptest.NewRequest(method, "/auth/password", nil)
147			rec := httptest.NewRecorder()
148
149			handler.ServeHTTP(rec, req)
150
151			assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
152		})
153	}
154}
155
156func TestHandler_Token_Success(t *testing.T) {
157	authSvc := mocks.NewMockAuthService(t)
158
159	authSvc.EXPECT().IsEnabled().Return(true)
160	authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
161	authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "secret").Return(true)
162	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "admin", []string{"repository:myrepo:pull"}, 5*time.Minute).
163		Return("access-token", nil)
164
165	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
166
167	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:myrepo:pull", nil)
168	req.SetBasicAuth("admin", "secret")
169	rec := httptest.NewRecorder()
170
171	handler.ServeHTTP(rec, req)
172
173	assert.Equal(t, http.StatusOK, rec.Code)
174
175	var resp map[string]any
176	err := json.NewDecoder(rec.Body).Decode(&resp)
177	assert.NoError(t, err)
178	assert.Equal(t, "access-token", resp["token"])
179	assert.Equal(t, float64(300), resp["expires_in"])
180}
181
182func TestHandler_Token_Anonymous(t *testing.T) {
183	authSvc := mocks.NewMockAuthService(t)
184
185	authSvc.EXPECT().IsEnabled().Return(true)
186
187	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
188
189	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
190	rec := httptest.NewRecorder()
191
192	handler.ServeHTTP(rec, req)
193
194	assert.Equal(t, http.StatusOK, rec.Code)
195
196	var resp map[string]any
197	err := json.NewDecoder(rec.Body).Decode(&resp)
198	assert.NoError(t, err)
199	assert.Equal(t, "", resp["token"])
200	assert.Equal(t, float64(60), resp["expires_in"])
201}
202
203func TestHandler_Token_AuthDisabled(t *testing.T) {
204	authSvc := mocks.NewMockAuthService(t)
205
206	authSvc.EXPECT().IsEnabled().Return(false)
207
208	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
209
210	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
211	rec := httptest.NewRecorder()
212
213	handler.ServeHTTP(rec, req)
214
215	// Should return anonymous token when auth is disabled
216	assert.Equal(t, http.StatusOK, rec.Code)
217
218	var resp map[string]any
219	err := json.NewDecoder(rec.Body).Decode(&resp)
220	assert.NoError(t, err)
221	assert.Equal(t, "", resp["token"])
222}
223
224func TestHandler_Token_InvalidCredentials(t *testing.T) {
225	authSvc := mocks.NewMockAuthService(t)
226
227	authSvc.EXPECT().IsEnabled().Return(true)
228	authSvc.EXPECT().GetAuthType().Return(domain.AuthTypePassword)
229	authSvc.EXPECT().ValidatePassword(mock.Anything, "admin", "wrongpassword").Return(false)
230
231	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
232
233	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
234	req.SetBasicAuth("admin", "wrongpassword")
235	rec := httptest.NewRecorder()
236
237	handler.ServeHTTP(rec, req)
238
239	assert.Equal(t, http.StatusUnauthorized, rec.Code)
240}
241
242func TestHandler_Token_InternalAuth(t *testing.T) {
243	authSvc := mocks.NewMockAuthService(t)
244
245	authSvc.EXPECT().IsEnabled().Return(true)
246	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "gordon-internal", []string{"repository:*:pull"}, 5*time.Minute).
247		Return("internal-access-token", nil)
248
249	internalAuth := InternalAuth{
250		Username: "gordon-internal",
251		Password: "internal-secret",
252	}
253	handler := NewHandler(authSvc, internalAuth, testLogger())
254
255	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
256	req.RemoteAddr = "127.0.0.1:12345"
257	req.SetBasicAuth("gordon-internal", "internal-secret")
258	rec := httptest.NewRecorder()
259
260	handler.ServeHTTP(rec, req)
261
262	assert.Equal(t, http.StatusOK, rec.Code)
263
264	var resp map[string]any
265	err := json.NewDecoder(rec.Body).Decode(&resp)
266	assert.NoError(t, err)
267	assert.Equal(t, "internal-access-token", resp["token"])
268}
269
270func TestHandler_Token_TokenAuth(t *testing.T) {
271	authSvc := mocks.NewMockAuthService(t)
272
273	authSvc.EXPECT().IsEnabled().Return(true)
274	authSvc.EXPECT().GetAuthType().Return(domain.AuthTypeToken)
275	authSvc.EXPECT().ValidateToken(mock.Anything, "existing-jwt-token").Return(&domain.TokenClaims{
276		Subject: "myuser",
277	}, nil)
278	authSvc.EXPECT().GenerateAccessToken(mock.Anything, "myuser", []string{"repository:*:pull"}, 5*time.Minute).
279		Return("short-lived-token", nil)
280
281	handler := NewHandler(authSvc, InternalAuth{}, testLogger())
282
283	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
284	req.SetBasicAuth("myuser", "existing-jwt-token")
285	rec := httptest.NewRecorder()
286
287	handler.ServeHTTP(rec, req)
288
289	assert.Equal(t, http.StatusOK, rec.Code)
290
291	var resp map[string]any
292	err := json.NewDecoder(rec.Body).Decode(&resp)
293	assert.NoError(t, err)
294	assert.Equal(t, "short-lived-token", resp["token"])
295}
296
297func TestHandler_Token_MethodNotAllowed(t *testing.T) {
298	handler := NewHandler(nil, InternalAuth{}, testLogger())
299
300	methods := []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
301
302	for _, method := range methods {
303		t.Run(method, func(t *testing.T) {
304			req := httptest.NewRequest(method, "/auth/token", nil)
305			rec := httptest.NewRecorder()
306
307			handler.ServeHTTP(rec, req)
308
309			assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
310		})
311	}
312}
313
314func TestHandler_NotFound(t *testing.T) {
315	handler := NewHandler(nil, InternalAuth{}, testLogger())
316
317	paths := []string{"/auth/unknown", "/auth/", "/auth"}
318
319	for _, path := range paths {
320		t.Run(path, func(t *testing.T) {
321			req := httptest.NewRequest(http.MethodGet, path, nil)
322			rec := httptest.NewRecorder()
323
324			handler.ServeHTTP(rec, req)
325
326			assert.Equal(t, http.StatusNotFound, rec.Code)
327		})
328	}
329}
330
331func TestParseRequestedScopes_MultipleScopeParams(t *testing.T) {
332	log := testLogger()
333	h := &Handler{log: log}
334
335	req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:repo1:pull&scope=repository:repo2:push", nil)
336
337	scopes := h.parseRequestedScopes(req, log)
338
339	assert.Len(t, scopes, 2)
340	assert.Contains(t, scopes, "repository:repo1:pull")
341	assert.Contains(t, scopes, "repository:repo2:push")
342}
343
344func TestParseRequestedScopes_InvalidScopeFiltered(t *testing.T) {
345	log := testLogger()
346	h := &Handler{log: log}
347
348	tests := []struct {
349		name       string
350		query      string
351		wantScopes []string
352	}{
353		{
354			name:       "invalid format is skipped",
355			query:      "scope=invalid-no-colons&scope=repository:valid:pull",
356			wantScopes: []string{"repository:valid:pull"},
357		},
358		{
359			name:       "missing actions is skipped",
360			query:      "scope=repository:myrepo&scope=repository:valid:pull",
361			wantScopes: []string{"repository:valid:pull"},
362		},
363		{
364			name:       "all invalid returns default",
365			query:      "scope=invalid&scope=also-invalid",
366			wantScopes: []string{"repository:*:pull"},
367		},
368	}
369
370	for _, tt := range tests {
371		t.Run(tt.name, func(t *testing.T) {
372			req := httptest.NewRequest(http.MethodGet, "/auth/token?"+tt.query, nil)
373			scopes := h.parseRequestedScopes(req, log)
374
375			assert.Equal(t, tt.wantScopes, scopes)
376		})
377	}
378}
379
380func TestParseRequestedScopes_NoScopeReturnsDefault(t *testing.T) {
381	log := testLogger()
382	h := &Handler{log: log}
383
384	req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
385	scopes := h.parseRequestedScopes(req, log)
386
387	assert.Equal(t, []string{"repository:*:pull"}, scopes)
388}
389
390func TestIsLocalhostRequest(t *testing.T) {
391	tests := []struct {
392		name       string
393		remoteAddr string
394		want       bool
395	}{
396		{"127.0.0.1 is localhost", "127.0.0.1:12345", true},
397		{"127.0.0.2 is localhost", "127.0.0.2:12345", true},
398		{"::1 is localhost", "[::1]:12345", true},
399		{"::1 without brackets", "::1:12345", true},
400		{"192.168.1.1 is not localhost", "192.168.1.1:12345", false},
401		{"10.0.0.1 is not localhost", "10.0.0.1:12345", false},
402		{"public IP is not localhost", "8.8.8.8:12345", false},
403	}
404
405	for _, tt := range tests {
406		t.Run(tt.name, func(t *testing.T) {
407			req := httptest.NewRequest(http.MethodGet, "/auth/token", nil)
408			req.RemoteAddr = tt.remoteAddr
409
410			result := isLocalhostRequest(req)
411
412			assert.Equal(t, tt.want, result)
413		})
414	}
415}

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