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