1package registry
2
3import (
4 "context"
5 "net"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11
12 "github.com/bnema/gordon/internal/adapters/in/http/middleware"
13 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
14 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15)
16
17func TestRateLimitMiddleware_Disabled(t *testing.T) {
18 // When limiters are nil, middleware should pass through
19 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
20 w.WriteHeader(http.StatusOK)
21 })
22
23 middleware := RateLimitMiddleware(nil, nil, nil, testLogger())
24 wrappedHandler := middleware(handler)
25
26 // Should pass through without rate limiting
27 for i := 0; i < 100; i++ {
28 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
29 rec := httptest.NewRecorder()
30 wrappedHandler.ServeHTTP(rec, req)
31 assert.Equal(t, http.StatusOK, rec.Code)
32 }
33}
34
35func TestRateLimitMiddleware_GlobalLimitHit(t *testing.T) {
36 globalLimiter := outmocks.NewMockRateLimiter(t)
37 ipLimiter := outmocks.NewMockRateLimiter(t)
38
39 // Global limiter returns false (rate limited)
40 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false)
41
42 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
43 w.WriteHeader(http.StatusOK)
44 })
45
46 middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger())
47 wrappedHandler := middleware(handler)
48
49 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
50 req.RemoteAddr = "192.168.1.100:12345"
51 rec := httptest.NewRecorder()
52 wrappedHandler.ServeHTTP(rec, req)
53
54 // Should return 429 when global limit hit
55 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
56 assert.Contains(t, rec.Body.String(), "TOOMANYREQUESTS")
57}
58
59func TestRateLimitMiddleware_PerIPLimitHit(t *testing.T) {
60 globalLimiter := outmocks.NewMockRateLimiter(t)
61 ipLimiter := outmocks.NewMockRateLimiter(t)
62
63 // Global limiter allows, IP limiter denies
64 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
65 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(false)
66
67 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
68 w.WriteHeader(http.StatusOK)
69 })
70
71 middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger())
72 wrappedHandler := middleware(handler)
73
74 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
75 req.RemoteAddr = "192.168.1.100:12345"
76 rec := httptest.NewRecorder()
77 wrappedHandler.ServeHTTP(rec, req)
78
79 // Should return 429 when per-IP limit hit
80 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
81}
82
83func TestRateLimitMiddleware_BothLimitsPass(t *testing.T) {
84 globalLimiter := outmocks.NewMockRateLimiter(t)
85 ipLimiter := outmocks.NewMockRateLimiter(t)
86
87 // Both limiters allow
88 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
89 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
90
91 handlerCalled := false
92 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
93 handlerCalled = true
94 w.WriteHeader(http.StatusOK)
95 })
96
97 middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger())
98 wrappedHandler := middleware(handler)
99
100 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
101 req.RemoteAddr = "192.168.1.100:12345"
102 rec := httptest.NewRecorder()
103 wrappedHandler.ServeHTTP(rec, req)
104
105 // Should pass through to handler
106 assert.Equal(t, http.StatusOK, rec.Code)
107 assert.True(t, handlerCalled, "handler should have been called")
108}
109
110func TestRateLimitMiddleware_TrustedProxy(t *testing.T) {
111 globalLimiter := outmocks.NewMockRateLimiter(t)
112 ipLimiter := outmocks.NewMockRateLimiter(t)
113
114 // Request comes from trusted proxy (127.0.0.1) with XFF header
115 // Should use the X-Forwarded-For IP (203.0.113.50) for rate limiting
116 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
117 ipLimiter.EXPECT().Allow(context.Background(), "ip:203.0.113.50").Return(true)
118
119 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
120 w.WriteHeader(http.StatusOK)
121 })
122
123 middleware := RateLimitMiddleware(globalLimiter, ipLimiter, []string{"127.0.0.1"}, testLogger())
124 wrappedHandler := middleware(handler)
125
126 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
127 req.RemoteAddr = "127.0.0.1:12345"
128 req.Header.Set("X-Forwarded-For", "203.0.113.50")
129 rec := httptest.NewRecorder()
130 wrappedHandler.ServeHTTP(rec, req)
131
132 assert.Equal(t, http.StatusOK, rec.Code)
133}
134
135func TestRateLimitMiddleware_UntrustedProxy(t *testing.T) {
136 globalLimiter := outmocks.NewMockRateLimiter(t)
137 ipLimiter := outmocks.NewMockRateLimiter(t)
138
139 // Request from untrusted IP with XFF header should use RemoteAddr IP
140 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(true)
141 ipLimiter.EXPECT().Allow(context.Background(), "ip:192.168.1.100").Return(true)
142
143 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
144 w.WriteHeader(http.StatusOK)
145 })
146
147 middleware := RateLimitMiddleware(globalLimiter, ipLimiter, []string{"127.0.0.1"}, testLogger())
148 wrappedHandler := middleware(handler)
149
150 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
151 req.RemoteAddr = "192.168.1.100:12345"
152 req.Header.Set("X-Forwarded-For", "203.0.113.50") // Should be ignored
153 rec := httptest.NewRecorder()
154 wrappedHandler.ServeHTTP(rec, req)
155
156 assert.Equal(t, http.StatusOK, rec.Code)
157}
158
159func TestRateLimitMiddleware_ResponseFormat(t *testing.T) {
160 globalLimiter := outmocks.NewMockRateLimiter(t)
161 ipLimiter := outmocks.NewMockRateLimiter(t)
162
163 globalLimiter.EXPECT().Allow(context.Background(), "global").Return(false)
164
165 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
166 w.WriteHeader(http.StatusOK)
167 })
168
169 middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, testLogger())
170 wrappedHandler := middleware(handler)
171
172 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
173 req.RemoteAddr = "192.168.1.100:12345"
174 rec := httptest.NewRecorder()
175 wrappedHandler.ServeHTTP(rec, req)
176
177 // Verify response format
178 assert.Equal(t, http.StatusTooManyRequests, rec.Code)
179 assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))
180 assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version"))
181 assert.Equal(t, "1", rec.Header().Get("Retry-After"))
182 assert.Contains(t, rec.Body.String(), "TOOMANYREQUESTS")
183}
184
185// Integration test using real MemoryStore implementation
186func TestRateLimitMiddleware_Integration(t *testing.T) {
187 log := testLogger()
188 globalLimiter := ratelimit.NewMemoryStore(1000, 100, log) // High global limit
189 ipLimiter := ratelimit.NewMemoryStore(2, 2, log) // Low per-IP limit
190
191 handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
192 w.WriteHeader(http.StatusOK)
193 })
194
195 middleware := RateLimitMiddleware(globalLimiter, ipLimiter, nil, log)
196 wrappedHandler := middleware(handler)
197
198 // First 2 requests from same IP should succeed (burst)
199 for i := 0; i < 2; i++ {
200 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
201 req.RemoteAddr = "192.168.1.100:12345"
202 rec := httptest.NewRecorder()
203 wrappedHandler.ServeHTTP(rec, req)
204 assert.Equal(t, http.StatusOK, rec.Code, "request %d should succeed", i+1)
205 }
206
207 // 3rd request from same IP should be rate limited
208 req := httptest.NewRequest(http.MethodGet, "/v2/", nil)
209 req.RemoteAddr = "192.168.1.100:12345"
210 rec := httptest.NewRecorder()
211 wrappedHandler.ServeHTTP(rec, req)
212 assert.Equal(t, http.StatusTooManyRequests, rec.Code, "3rd request should be rate limited")
213
214 // Request from different IP should still succeed (independent limit)
215 req2 := httptest.NewRequest(http.MethodGet, "/v2/", nil)
216 req2.RemoteAddr = "192.168.1.101:12345"
217 rec2 := httptest.NewRecorder()
218 wrappedHandler.ServeHTTP(rec2, req2)
219 assert.Equal(t, http.StatusOK, rec2.Code, "different IP should succeed")
220}
221
222func TestGetClientIP(t *testing.T) {
223 // Parse trusted proxies for tests
224 trustedNets := middleware.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"})
225 noTrustedNets := []*net.IPNet{}
226
227 tests := []struct {
228 name string
229 remoteAddr string
230 xff string
231 xRealIP string
232 trustedNets []*net.IPNet
233 wantIP string
234 }{
235 {
236 name: "from RemoteAddr with port",
237 remoteAddr: "192.168.1.100:12345",
238 trustedNets: noTrustedNets,
239 wantIP: "192.168.1.100",
240 },
241 {
242 name: "from RemoteAddr without port",
243 remoteAddr: "192.168.1.100",
244 trustedNets: noTrustedNets,
245 wantIP: "192.168.1.100",
246 },
247 {
248 name: "XFF ignored when no trusted proxies",
249 remoteAddr: "192.168.1.100:12345",
250 xff: "203.0.113.50",
251 trustedNets: noTrustedNets,
252 wantIP: "192.168.1.100",
253 },
254 {
255 name: "XFF ignored when remote is not trusted",
256 remoteAddr: "192.168.1.100:12345",
257 xff: "203.0.113.50",
258 trustedNets: trustedNets, // trusts 127.0.0.1 and 10.0.0.0/8
259 wantIP: "192.168.1.100",
260 },
261 {
262 name: "XFF honored from trusted proxy (single IP)",
263 remoteAddr: "127.0.0.1:12345",
264 xff: "203.0.113.50",
265 trustedNets: trustedNets,
266 wantIP: "203.0.113.50",
267 },
268 {
269 name: "XFF honored from trusted proxy (multiple IPs)",
270 remoteAddr: "127.0.0.1:12345",
271 xff: "203.0.113.50, 10.0.0.1, 172.16.0.1",
272 trustedNets: trustedNets,
273 wantIP: "203.0.113.50",
274 },
275 {
276 name: "XFF honored from trusted CIDR",
277 remoteAddr: "10.1.2.3:12345",
278 xff: "203.0.113.50",
279 trustedNets: trustedNets,
280 wantIP: "203.0.113.50",
281 },
282 {
283 name: "X-Real-IP honored from trusted proxy",
284 remoteAddr: "127.0.0.1:12345",
285 xRealIP: "203.0.113.50",
286 trustedNets: trustedNets,
287 wantIP: "203.0.113.50",
288 },
289 {
290 name: "X-Real-IP ignored when not trusted",
291 remoteAddr: "192.168.1.100:12345",
292 xRealIP: "203.0.113.50",
293 trustedNets: trustedNets,
294 wantIP: "192.168.1.100",
295 },
296 {
297 name: "XFF takes precedence over X-Real-IP",
298 remoteAddr: "127.0.0.1:12345",
299 xff: "203.0.113.50",
300 xRealIP: "203.0.113.60",
301 trustedNets: trustedNets,
302 wantIP: "203.0.113.50",
303 },
304 }
305
306 for _, tt := range tests {
307 t.Run(tt.name, func(t *testing.T) {
308 req := httptest.NewRequest(http.MethodGet, "/", nil)
309 req.RemoteAddr = tt.remoteAddr
310 if tt.xff != "" {
311 req.Header.Set("X-Forwarded-For", tt.xff)
312 }
313 if tt.xRealIP != "" {
314 req.Header.Set("X-Real-IP", tt.xRealIP)
315 }
316
317 ip := middleware.GetClientIP(req, tt.trustedNets)
318 assert.Equal(t, tt.wantIP, ip)
319 })
320 }
321}
322
323func TestParseTrustedProxies(t *testing.T) {
324 tests := []struct {
325 name string
326 proxies []string
327 testIP string
328 want bool
329 }{
330 {
331 name: "empty list",
332 proxies: []string{},
333 testIP: "192.168.1.1",
334 want: false,
335 },
336 {
337 name: "single IP match",
338 proxies: []string{"192.168.1.1"},
339 testIP: "192.168.1.1",
340 want: true,
341 },
342 {
343 name: "single IP no match",
344 proxies: []string{"192.168.1.1"},
345 testIP: "192.168.1.2",
346 want: false,
347 },
348 {
349 name: "CIDR match",
350 proxies: []string{"10.0.0.0/8"},
351 testIP: "10.1.2.3",
352 want: true,
353 },
354 {
355 name: "CIDR no match",
356 proxies: []string{"10.0.0.0/8"},
357 testIP: "192.168.1.1",
358 want: false,
359 },
360 {
361 name: "mixed IP and CIDR",
362 proxies: []string{"127.0.0.1", "10.0.0.0/8", "172.16.0.0/12"},
363 testIP: "172.20.1.1",
364 want: true,
365 },
366 {
367 name: "invalid entries ignored",
368 proxies: []string{"not-an-ip", "10.0.0.0/8"},
369 testIP: "10.1.2.3",
370 want: true,
371 },
372 }
373
374 for _, tt := range tests {
375 t.Run(tt.name, func(t *testing.T) {
376 nets := middleware.ParseTrustedProxies(tt.proxies)
377 got := middleware.IsTrustedProxy(tt.testIP, nets)
378 assert.Equal(t, tt.want, got)
379 })
380 }
381}