v2.30.0-pre 7341e0e

middleware_test.go RawHistoryFolder

1package admin
2
3import (
4	"context"
5	"errors"
6	"net/http"
7	"net/http/httptest"
8	"testing"
9
10	"github.com/bnema/zerowrap"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13
14	"github.com/bnema/gordon/internal/adapters/in/http/middleware"
15	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
16	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20func adminTestLogger() zerowrap.Logger {
21	return zerowrap.Default()
22}
23
24func TestAuthMiddleware_GlobalRateLimitHit(t *testing.T) {
25	authSvc := inmocks.NewMockAuthService(t)
26	globalLimiter := outmocks.NewMockRateLimiter(t)
27	ipLimiter := outmocks.NewMockRateLimiter(t)
28
29	// Global limiter returns false (rate limited)
30	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false)
31
32	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
33		w.WriteHeader(http.StatusOK)
34	})
35
36	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
37	wrappedHandler := mw(handler)
38
39	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
40	rec := httptest.NewRecorder()
41	wrappedHandler.ServeHTTP(rec, req)
42
43	assert.Equal(t, http.StatusTooManyRequests, rec.Code)
44	assert.Contains(t, rec.Body.String(), "rate limit exceeded")
45	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
46	assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
47}
48
49func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
50	authSvc := inmocks.NewMockAuthService(t)
51	globalLimiter := outmocks.NewMockRateLimiter(t)
52	ipLimiter := outmocks.NewMockRateLimiter(t)
53
54	// Global limiter allows, per-IP limiter denies
55	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
56	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(false)
57
58	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
59		w.WriteHeader(http.StatusOK)
60	})
61
62	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
63	wrappedHandler := mw(handler)
64
65	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
66	req.RemoteAddr = "192.168.1.100:12345"
67	rec := httptest.NewRecorder()
68	wrappedHandler.ServeHTTP(rec, req)
69
70	assert.Equal(t, http.StatusTooManyRequests, rec.Code)
71	assert.Contains(t, rec.Body.String(), "rate limit exceeded")
72	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
73	assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
74}
75
76func TestAuthMiddleware_TrustedProxy(t *testing.T) {
77	authSvc := inmocks.NewMockAuthService(t)
78	globalLimiter := outmocks.NewMockRateLimiter(t)
79	ipLimiter := outmocks.NewMockRateLimiter(t)
80
81	// Request from trusted proxy, should use X-Forwarded-For IP
82	trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1"})
83
84	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
85	ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
86	authSvc.EXPECT().IsEnabled().Return(true)
87	authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
88		Subject: "admin",
89		Scopes:  []string{"admin:*:*"},
90	}, nil)
91	// ExtendToken is called after successful validation; non-fatal if it fails
92	authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
93
94	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
95		w.WriteHeader(http.StatusOK)
96	})
97
98	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, trustedNets, adminTestLogger())
99	wrappedHandler := mw(handler)
100
101	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
102	req.RemoteAddr = "127.0.0.1:12345"
103	req.Header.Set("X-Forwarded-For", "203.0.113.50")
104	req.Header.Set("Authorization", "Bearer valid-admin-token")
105	rec := httptest.NewRecorder()
106	wrappedHandler.ServeHTTP(rec, req)
107
108	assert.Equal(t, http.StatusOK, rec.Code)
109}
110
111func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
112	authSvc := inmocks.NewMockAuthService(t)
113
114	// No rate limiters, auth still required
115	authSvc.EXPECT().IsEnabled().Return(true)
116	authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
117		Subject: "admin",
118		Scopes:  []string{"admin:*:*"},
119	}, nil)
120	// ExtendToken is called after successful validation; non-fatal if it fails
121	authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
122
123	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
124		w.WriteHeader(http.StatusOK)
125	})
126
127	mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
128	wrappedHandler := mw(handler)
129
130	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
131	req.Header.Set("Authorization", "Bearer valid-admin-token")
132	rec := httptest.NewRecorder()
133	wrappedHandler.ServeHTTP(rec, req)
134
135	assert.Equal(t, http.StatusOK, rec.Code)
136}
137
138func TestAuthMiddleware_AuthDisabled(t *testing.T) {
139	authSvc := inmocks.NewMockAuthService(t)
140	globalLimiter := outmocks.NewMockRateLimiter(t)
141	ipLimiter := outmocks.NewMockRateLimiter(t)
142
143	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
144	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
145	authSvc.EXPECT().IsEnabled().Return(false)
146
147	handlerCalled := false
148	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
149		handlerCalled = true
150		w.WriteHeader(http.StatusOK)
151	})
152
153	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
154	wrappedHandler := mw(handler)
155
156	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
157	req.RemoteAddr = "192.168.1.100:12345"
158	rec := httptest.NewRecorder()
159	wrappedHandler.ServeHTTP(rec, req)
160
161	assert.Equal(t, http.StatusUnauthorized, rec.Code)
162	assert.False(t, handlerCalled)
163}
164
165func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
166	authSvc := inmocks.NewMockAuthService(t)
167	globalLimiter := outmocks.NewMockRateLimiter(t)
168	ipLimiter := outmocks.NewMockRateLimiter(t)
169
170	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
171	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
172	authSvc.EXPECT().IsEnabled().Return(true)
173
174	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
175		w.WriteHeader(http.StatusOK)
176	})
177
178	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
179	wrappedHandler := mw(handler)
180
181	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
182	req.RemoteAddr = "192.168.1.100:12345"
183	rec := httptest.NewRecorder()
184	wrappedHandler.ServeHTTP(rec, req)
185
186	assert.Equal(t, http.StatusUnauthorized, rec.Code)
187	assert.Contains(t, rec.Body.String(), "missing authorization header")
188	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
189	assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
190}
191
192func TestAuthMiddleware_InvalidToken(t *testing.T) {
193	authSvc := inmocks.NewMockAuthService(t)
194	globalLimiter := outmocks.NewMockRateLimiter(t)
195	ipLimiter := outmocks.NewMockRateLimiter(t)
196
197	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
198	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
199	authSvc.EXPECT().IsEnabled().Return(true)
200	authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, assert.AnError)
201
202	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
203		w.WriteHeader(http.StatusOK)
204	})
205
206	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
207	wrappedHandler := mw(handler)
208
209	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
210	req.RemoteAddr = "192.168.1.100:12345"
211	req.Header.Set("Authorization", "Bearer invalid-token")
212	rec := httptest.NewRecorder()
213	wrappedHandler.ServeHTTP(rec, req)
214
215	assert.Equal(t, http.StatusUnauthorized, rec.Code)
216	assert.Contains(t, rec.Body.String(), "invalid token")
217}
218
219func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
220	authSvc := inmocks.NewMockAuthService(t)
221	globalLimiter := outmocks.NewMockRateLimiter(t)
222	ipLimiter := outmocks.NewMockRateLimiter(t)
223
224	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
225	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
226	authSvc.EXPECT().IsEnabled().Return(true)
227	authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{
228		Subject: "user1",
229		Scopes:  []string{"push", "pull"}, // No admin scopes
230	}, nil)
231
232	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
233		w.WriteHeader(http.StatusOK)
234	})
235
236	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
237	wrappedHandler := mw(handler)
238
239	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
240	req.RemoteAddr = "192.168.1.100:12345"
241	req.Header.Set("Authorization", "Bearer valid-token")
242	rec := httptest.NewRecorder()
243	wrappedHandler.ServeHTTP(rec, req)
244
245	assert.Equal(t, http.StatusForbidden, rec.Code)
246	assert.Contains(t, rec.Body.String(), "admin scope required")
247}
248
249func TestAuthMiddleware_Success(t *testing.T) {
250	authSvc := inmocks.NewMockAuthService(t)
251	globalLimiter := outmocks.NewMockRateLimiter(t)
252	ipLimiter := outmocks.NewMockRateLimiter(t)
253
254	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
255	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
256	authSvc.EXPECT().IsEnabled().Return(true)
257	authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
258		Subject: "admin",
259		Scopes:  []string{"admin:*:*"},
260	}, nil)
261	// ExtendToken is called after successful validation; non-fatal if it fails
262	authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("valid-admin-token", nil)
263
264	var capturedCtx context.Context
265	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
266		capturedCtx = r.Context()
267		w.WriteHeader(http.StatusOK)
268	})
269
270	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
271	wrappedHandler := mw(handler)
272
273	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
274	req.RemoteAddr = "192.168.1.100:12345"
275	req.Header.Set("Authorization", "Bearer valid-admin-token")
276	rec := httptest.NewRecorder()
277	wrappedHandler.ServeHTTP(rec, req)
278
279	assert.Equal(t, http.StatusOK, rec.Code)
280
281	// Verify context has scopes and subject
282	scopes := GetScopes(capturedCtx)
283	assert.Equal(t, []string{"admin:*:*"}, scopes)
284	assert.Equal(t, "admin", GetSubject(capturedCtx))
285}
286
287func TestAuthMiddleware_DirectToken_Rejected(t *testing.T) {
288	authSvc := inmocks.NewMockAuthService(t)
289	globalLimiter := outmocks.NewMockRateLimiter(t)
290	ipLimiter := outmocks.NewMockRateLimiter(t)
291
292	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
293	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
294	authSvc.EXPECT().IsEnabled().Return(true)
295
296	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
297		w.WriteHeader(http.StatusOK)
298	})
299
300	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
301	wrappedHandler := mw(handler)
302
303	// Token without "Bearer " prefix should be rejected (RFC 6750)
304	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
305	req.RemoteAddr = "192.168.1.100:12345"
306	req.Header.Set("Authorization", "direct-token")
307	rec := httptest.NewRecorder()
308	wrappedHandler.ServeHTTP(rec, req)
309
310	assert.Equal(t, http.StatusUnauthorized, rec.Code)
311	assert.Contains(t, rec.Body.String(), "Bearer scheme")
312}
313
314func TestRequireScope(t *testing.T) {
315	tests := []struct {
316		name       string
317		scopes     []string
318		resource   string
319		action     string
320		wantStatus int
321	}{
322		{
323			name:       "wildcard scope grants access",
324			scopes:     []string{"admin:*:*"},
325			resource:   "routes",
326			action:     "write",
327			wantStatus: http.StatusOK,
328		},
329		{
330			name:       "exact scope grants access",
331			scopes:     []string{"admin:routes:read"},
332			resource:   "routes",
333			action:     "read",
334			wantStatus: http.StatusOK,
335		},
336		{
337			name:       "wrong resource denies access",
338			scopes:     []string{"admin:secrets:read"},
339			resource:   "routes",
340			action:     "read",
341			wantStatus: http.StatusForbidden,
342		},
343		{
344			name:       "wrong action denies access",
345			scopes:     []string{"admin:routes:read"},
346			resource:   "routes",
347			action:     "write",
348			wantStatus: http.StatusForbidden,
349		},
350	}
351
352	for _, tt := range tests {
353		t.Run(tt.name, func(t *testing.T) {
354			handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
355				w.WriteHeader(http.StatusOK)
356			})
357
358			mw := RequireScope(tt.resource, tt.action)
359			wrappedHandler := mw(handler)
360
361			req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil)
362			// Add scopes to context
363			ctx := context.WithValue(req.Context(), domain.ContextKeyScopes, tt.scopes)
364			req = req.WithContext(ctx)
365
366			rec := httptest.NewRecorder()
367			wrappedHandler.ServeHTTP(rec, req)
368
369			assert.Equal(t, tt.wantStatus, rec.Code)
370		})
371	}
372}
373
374func TestAuthMiddleware_ExtendTokenFailureIsNonFatal(t *testing.T) {
375	authSvc := inmocks.NewMockAuthService(t)
376
377	authSvc.EXPECT().IsEnabled().Return(true)
378	authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
379		Subject: "admin",
380		Scopes:  []string{"admin:*:*"},
381	}, nil)
382	// ExtendToken fails — response must still be 200 (non-fatal)
383	authSvc.EXPECT().ExtendToken(mock.Anything, "valid-admin-token").Return("", errors.New("store unavailable"))
384
385	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
386		w.WriteHeader(http.StatusOK)
387	})
388
389	mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
390	wrappedHandler := mw(handler)
391
392	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
393	req.Header.Set("Authorization", "Bearer valid-admin-token")
394	rec := httptest.NewRecorder()
395	wrappedHandler.ServeHTTP(rec, req)
396
397	assert.Equal(t, http.StatusOK, rec.Code)
398	assert.Empty(t, rec.Header().Get("X-Gordon-Token"))
399	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
400	assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
401}
402
403func TestAuthMiddleware_ExtendTokenRotationHeader(t *testing.T) {
404	authSvc := inmocks.NewMockAuthService(t)
405
406	authSvc.EXPECT().IsEnabled().Return(true)
407	authSvc.EXPECT().ValidateToken(mock.Anything, "old-token").Return(&domain.TokenClaims{
408		Subject: "admin",
409		Scopes:  []string{"admin:*:*"},
410	}, nil)
411	// ExtendToken returns a rotated token — must appear in response header
412	authSvc.EXPECT().ExtendToken(mock.Anything, "old-token").Return("rotated-token", nil)
413
414	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
415		w.WriteHeader(http.StatusOK)
416	})
417
418	mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
419	wrappedHandler := mw(handler)
420
421	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
422	req.Header.Set("Authorization", "Bearer old-token")
423	rec := httptest.NewRecorder()
424	wrappedHandler.ServeHTTP(rec, req)
425
426	assert.Equal(t, http.StatusOK, rec.Code)
427	assert.Equal(t, "rotated-token", rec.Header().Get("X-Gordon-Token"))
428	assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
429	assert.Equal(t, "no-cache", rec.Header().Get("Pragma"))
430}
431
432func TestHasAccess(t *testing.T) {
433	ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
434
435	assert.True(t, HasAccess(ctx, "routes", "read"))
436	assert.False(t, HasAccess(ctx, "routes", "write"))
437	assert.True(t, HasAccess(ctx, "secrets", "read"))
438	assert.True(t, HasAccess(ctx, "secrets", "write"))
439	assert.False(t, HasAccess(ctx, "config", "read"))
440}

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 8

Tags 106

↑↓ moveenter openesc close