v2.7.1 b3a38c8

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	"gordon/internal/adapters/in/http/middleware"
14	inmocks "gordon/internal/boundaries/in/mocks"
15	outmocks "gordon/internal/boundaries/out/mocks"
16	"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}
45
46func TestAuthMiddleware_PerIPRateLimitHit(t *testing.T) {
47	authSvc := inmocks.NewMockAuthService(t)
48	globalLimiter := outmocks.NewMockRateLimiter(t)
49	ipLimiter := outmocks.NewMockRateLimiter(t)
50
51	// Global limiter allows, per-IP limiter denies
52	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
53	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(false)
54
55	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
56		w.WriteHeader(http.StatusOK)
57	})
58
59	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
60	wrappedHandler := mw(handler)
61
62	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
63	req.RemoteAddr = "192.168.1.100:12345"
64	rec := httptest.NewRecorder()
65	wrappedHandler.ServeHTTP(rec, req)
66
67	assert.Equal(t, http.StatusTooManyRequests, rec.Code)
68	assert.Contains(t, rec.Body.String(), "rate limit exceeded")
69}
70
71func TestAuthMiddleware_TrustedProxy(t *testing.T) {
72	authSvc := inmocks.NewMockAuthService(t)
73	globalLimiter := outmocks.NewMockRateLimiter(t)
74	ipLimiter := outmocks.NewMockRateLimiter(t)
75
76	// Request from trusted proxy, should use X-Forwarded-For IP
77	trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1"})
78
79	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
80	ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
81	authSvc.EXPECT().IsEnabled().Return(false)
82
83	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
84		w.WriteHeader(http.StatusOK)
85	})
86
87	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, trustedNets, adminTestLogger())
88	wrappedHandler := mw(handler)
89
90	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
91	req.RemoteAddr = "127.0.0.1:12345"
92	req.Header.Set("X-Forwarded-For", "203.0.113.50")
93	rec := httptest.NewRecorder()
94	wrappedHandler.ServeHTTP(rec, req)
95
96	assert.Equal(t, http.StatusOK, rec.Code)
97}
98
99func TestAuthMiddleware_NoRateLimiting(t *testing.T) {
100	authSvc := inmocks.NewMockAuthService(t)
101
102	// Auth disabled, no rate limiters
103	authSvc.EXPECT().IsEnabled().Return(false)
104
105	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
106		w.WriteHeader(http.StatusOK)
107	})
108
109	mw := AuthMiddleware(authSvc, nil, nil, nil, adminTestLogger())
110	wrappedHandler := mw(handler)
111
112	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
113	rec := httptest.NewRecorder()
114	wrappedHandler.ServeHTTP(rec, req)
115
116	assert.Equal(t, http.StatusOK, rec.Code)
117}
118
119func TestAuthMiddleware_AuthDisabled(t *testing.T) {
120	authSvc := inmocks.NewMockAuthService(t)
121	globalLimiter := outmocks.NewMockRateLimiter(t)
122	ipLimiter := outmocks.NewMockRateLimiter(t)
123
124	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
125	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
126	authSvc.EXPECT().IsEnabled().Return(false)
127
128	handlerCalled := false
129	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
130		handlerCalled = true
131		w.WriteHeader(http.StatusOK)
132	})
133
134	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
135	wrappedHandler := mw(handler)
136
137	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
138	req.RemoteAddr = "192.168.1.100:12345"
139	rec := httptest.NewRecorder()
140	wrappedHandler.ServeHTTP(rec, req)
141
142	assert.Equal(t, http.StatusOK, rec.Code)
143	assert.True(t, handlerCalled)
144}
145
146func TestAuthMiddleware_MissingAuthHeader(t *testing.T) {
147	authSvc := inmocks.NewMockAuthService(t)
148	globalLimiter := outmocks.NewMockRateLimiter(t)
149	ipLimiter := outmocks.NewMockRateLimiter(t)
150
151	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
152	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
153	authSvc.EXPECT().IsEnabled().Return(true)
154
155	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
156		w.WriteHeader(http.StatusOK)
157	})
158
159	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
160	wrappedHandler := mw(handler)
161
162	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
163	req.RemoteAddr = "192.168.1.100:12345"
164	rec := httptest.NewRecorder()
165	wrappedHandler.ServeHTTP(rec, req)
166
167	assert.Equal(t, http.StatusUnauthorized, rec.Code)
168	assert.Contains(t, rec.Body.String(), "missing authorization header")
169}
170
171func TestAuthMiddleware_InvalidToken(t *testing.T) {
172	authSvc := inmocks.NewMockAuthService(t)
173	globalLimiter := outmocks.NewMockRateLimiter(t)
174	ipLimiter := outmocks.NewMockRateLimiter(t)
175
176	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
177	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
178	authSvc.EXPECT().IsEnabled().Return(true)
179	authSvc.EXPECT().ValidateToken(mock.Anything, "invalid-token").Return(nil, assert.AnError)
180
181	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
182		w.WriteHeader(http.StatusOK)
183	})
184
185	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
186	wrappedHandler := mw(handler)
187
188	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
189	req.RemoteAddr = "192.168.1.100:12345"
190	req.Header.Set("Authorization", "Bearer invalid-token")
191	rec := httptest.NewRecorder()
192	wrappedHandler.ServeHTTP(rec, req)
193
194	assert.Equal(t, http.StatusUnauthorized, rec.Code)
195	assert.Contains(t, rec.Body.String(), "invalid token")
196}
197
198func TestAuthMiddleware_MissingAdminScope(t *testing.T) {
199	authSvc := inmocks.NewMockAuthService(t)
200	globalLimiter := outmocks.NewMockRateLimiter(t)
201	ipLimiter := outmocks.NewMockRateLimiter(t)
202
203	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
204	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
205	authSvc.EXPECT().IsEnabled().Return(true)
206	authSvc.EXPECT().ValidateToken(mock.Anything, "valid-token").Return(&domain.TokenClaims{
207		Subject: "user1",
208		Scopes:  []string{"push", "pull"}, // No admin scopes
209	}, nil)
210
211	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
212		w.WriteHeader(http.StatusOK)
213	})
214
215	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
216	wrappedHandler := mw(handler)
217
218	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
219	req.RemoteAddr = "192.168.1.100:12345"
220	req.Header.Set("Authorization", "Bearer valid-token")
221	rec := httptest.NewRecorder()
222	wrappedHandler.ServeHTTP(rec, req)
223
224	assert.Equal(t, http.StatusForbidden, rec.Code)
225	assert.Contains(t, rec.Body.String(), "admin scope required")
226}
227
228func TestAuthMiddleware_Success(t *testing.T) {
229	authSvc := inmocks.NewMockAuthService(t)
230	globalLimiter := outmocks.NewMockRateLimiter(t)
231	ipLimiter := outmocks.NewMockRateLimiter(t)
232
233	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
234	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
235	authSvc.EXPECT().IsEnabled().Return(true)
236	authSvc.EXPECT().ValidateToken(mock.Anything, "valid-admin-token").Return(&domain.TokenClaims{
237		Subject: "admin",
238		Scopes:  []string{"admin:*:*"},
239	}, nil)
240
241	var capturedCtx context.Context
242	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243		capturedCtx = r.Context()
244		w.WriteHeader(http.StatusOK)
245	})
246
247	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
248	wrappedHandler := mw(handler)
249
250	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
251	req.RemoteAddr = "192.168.1.100:12345"
252	req.Header.Set("Authorization", "Bearer valid-admin-token")
253	rec := httptest.NewRecorder()
254	wrappedHandler.ServeHTTP(rec, req)
255
256	assert.Equal(t, http.StatusOK, rec.Code)
257
258	// Verify context has scopes and subject
259	scopes := GetScopes(capturedCtx)
260	assert.Equal(t, []string{"admin:*:*"}, scopes)
261	assert.Equal(t, "admin", GetSubject(capturedCtx))
262}
263
264func TestAuthMiddleware_DirectToken(t *testing.T) {
265	authSvc := inmocks.NewMockAuthService(t)
266	globalLimiter := outmocks.NewMockRateLimiter(t)
267	ipLimiter := outmocks.NewMockRateLimiter(t)
268
269	globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
270	ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
271	authSvc.EXPECT().IsEnabled().Return(true)
272	// Token without "Bearer " prefix
273	authSvc.EXPECT().ValidateToken(mock.Anything, "direct-token").Return(&domain.TokenClaims{
274		Subject: "admin",
275		Scopes:  []string{"admin:routes:read"},
276	}, nil)
277
278	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
279		w.WriteHeader(http.StatusOK)
280	})
281
282	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
283	wrappedHandler := mw(handler)
284
285	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
286	req.RemoteAddr = "192.168.1.100:12345"
287	req.Header.Set("Authorization", "direct-token") // No "Bearer " prefix
288	rec := httptest.NewRecorder()
289	wrappedHandler.ServeHTTP(rec, req)
290
291	assert.Equal(t, http.StatusOK, rec.Code)
292}
293
294func TestRequireScope(t *testing.T) {
295	tests := []struct {
296		name       string
297		scopes     []string
298		resource   string
299		action     string
300		wantStatus int
301	}{
302		{
303			name:       "wildcard scope grants access",
304			scopes:     []string{"admin:*:*"},
305			resource:   "routes",
306			action:     "write",
307			wantStatus: http.StatusOK,
308		},
309		{
310			name:       "exact scope grants access",
311			scopes:     []string{"admin:routes:read"},
312			resource:   "routes",
313			action:     "read",
314			wantStatus: http.StatusOK,
315		},
316		{
317			name:       "wrong resource denies access",
318			scopes:     []string{"admin:secrets:read"},
319			resource:   "routes",
320			action:     "read",
321			wantStatus: http.StatusForbidden,
322		},
323		{
324			name:       "wrong action denies access",
325			scopes:     []string{"admin:routes:read"},
326			resource:   "routes",
327			action:     "write",
328			wantStatus: http.StatusForbidden,
329		},
330	}
331
332	for _, tt := range tests {
333		t.Run(tt.name, func(t *testing.T) {
334			handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
335				w.WriteHeader(http.StatusOK)
336			})
337
338			mw := RequireScope(tt.resource, tt.action)
339			wrappedHandler := mw(handler)
340
341			req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil)
342			// Add scopes to context
343			ctx := context.WithValue(req.Context(), ContextKeyScopes, tt.scopes)
344			req = req.WithContext(ctx)
345
346			rec := httptest.NewRecorder()
347			wrappedHandler.ServeHTTP(rec, req)
348
349			assert.Equal(t, tt.wantStatus, rec.Code)
350		})
351	}
352}
353
354func TestHasAccess(t *testing.T) {
355	ctx := context.WithValue(context.Background(), ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
356
357	assert.True(t, HasAccess(ctx, "routes", "read"))
358	assert.False(t, HasAccess(ctx, "routes", "write"))
359	assert.True(t, HasAccess(ctx, "secrets", "read"))
360	assert.True(t, HasAccess(ctx, "secrets", "write"))
361	assert.False(t, HasAccess(ctx, "config", "read"))
362}

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