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}