v2.31.0-dev5 4162c86

middleware_test.go RawHistoryFolder

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

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