v2.16.0 ebd2ab3

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

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