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}