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}