v2.30.0-pre 7341e0e

ratelimit_test.go RawHistoryFolder

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 returns first untrusted IP from right",
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:      "172.16.0.1",
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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close