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}