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 "github.com/bnema/gordon/internal/boundaries/in/mocks"
16 "github.com/bnema/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}