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