v2.13.0 7748089

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(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_Rejected(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
273	handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
274		w.WriteHeader(http.StatusOK)
275	})
276
277	mw := AuthMiddleware(authSvc, globalLimiter, ipLimiter, nil, adminTestLogger())
278	wrappedHandler := mw(handler)
279
280	// Token without "Bearer " prefix should be rejected (RFC 6750)
281	req := httptest.NewRequest(http.MethodGet, "/admin/status", nil)
282	req.RemoteAddr = "192.168.1.100:12345"
283	req.Header.Set("Authorization", "direct-token")
284	rec := httptest.NewRecorder()
285	wrappedHandler.ServeHTTP(rec, req)
286
287	assert.Equal(t, http.StatusUnauthorized, rec.Code)
288	assert.Contains(t, rec.Body.String(), "Bearer scheme")
289}
290
291func TestRequireScope(t *testing.T) {
292	tests := []struct {
293		name       string
294		scopes     []string
295		resource   string
296		action     string
297		wantStatus int
298	}{
299		{
300			name:       "wildcard scope grants access",
301			scopes:     []string{"admin:*:*"},
302			resource:   "routes",
303			action:     "write",
304			wantStatus: http.StatusOK,
305		},
306		{
307			name:       "exact scope grants access",
308			scopes:     []string{"admin:routes:read"},
309			resource:   "routes",
310			action:     "read",
311			wantStatus: http.StatusOK,
312		},
313		{
314			name:       "wrong resource denies access",
315			scopes:     []string{"admin:secrets:read"},
316			resource:   "routes",
317			action:     "read",
318			wantStatus: http.StatusForbidden,
319		},
320		{
321			name:       "wrong action denies access",
322			scopes:     []string{"admin:routes:read"},
323			resource:   "routes",
324			action:     "write",
325			wantStatus: http.StatusForbidden,
326		},
327	}
328
329	for _, tt := range tests {
330		t.Run(tt.name, func(t *testing.T) {
331			handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
332				w.WriteHeader(http.StatusOK)
333			})
334
335			mw := RequireScope(tt.resource, tt.action)
336			wrappedHandler := mw(handler)
337
338			req := httptest.NewRequest(http.MethodGet, "/admin/routes", nil)
339			// Add scopes to context
340			ctx := context.WithValue(req.Context(), domain.ContextKeyScopes, tt.scopes)
341			req = req.WithContext(ctx)
342
343			rec := httptest.NewRecorder()
344			wrappedHandler.ServeHTTP(rec, req)
345
346			assert.Equal(t, tt.wantStatus, rec.Code)
347		})
348	}
349}
350
351func TestHasAccess(t *testing.T) {
352	ctx := context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:routes:read", "admin:secrets:*"})
353
354	assert.True(t, HasAccess(ctx, "routes", "read"))
355	assert.False(t, HasAccess(ctx, "routes", "write"))
356	assert.True(t, HasAccess(ctx, "secrets", "read"))
357	assert.True(t, HasAccess(ctx, "secrets", "write"))
358	assert.False(t, HasAccess(ctx, "config", "read"))
359}

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