v2.31.0-dev17 bd898f0

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/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}

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