v0.35.0 e86dbd6

limits_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package githttp_test
4
5import (
6	"context"
7	"net/http"
8	"net/http/httptest"
9	"net/netip"
10	"path/filepath"
11	"strings"
12	"sync"
13	"testing"
14	"time"
15
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19
20	"bnema.dev/ermite/internal/adapters/githttp"
21	"bnema.dev/ermite/internal/ports"
22	"bnema.dev/ermite/internal/ports/mocks"
23)
24
25const (
26	pathUpload  = "/pub.git/git-upload-pack"
27	pathRefs    = "/pub.git/info/refs?service=git-upload-pack"
28	pathReceive = "/pub.git/git-receive-pack"
29)
30
31// held is a request kept inside the backend until the gate opens.
32type held struct{ path, client string }
33
34// gatedRig is a rig whose backend blocks every request that reaches it until
35// open is called, so a test decides exactly which slots are taken.
36type gatedRig struct {
37	*rig
38	entered chan struct{} // a request reached the backend
39	served  chan struct{} // a request left the handler, its slots released
40	gate    chan struct{}
41	done    sync.WaitGroup
42}
43
44func newGatedRig(t *testing.T, mut func(*githttp.Config)) *gatedRig {
45	t.Helper()
46	g := &gatedRig{entered: make(chan struct{}, 64), served: make(chan struct{}, 64), gate: make(chan struct{})}
47	script := cgiScript(t)
48	be := mocks.NewGitBackend(t)
49	be.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
50		func(ctx context.Context, _ bool) (ports.HTTPBackend, error) {
51			g.entered <- struct{}{}
52			select {
53			case <-g.gate:
54			case <-ctx.Done():
55			}
56			return ports.HTTPBackend{Path: script, Dir: filepath.Dir(script), Env: []string{"PATH=/usr/bin:/bin"}}, nil
57		}).Maybe()
58	g.rig = newRig(t, func(c *githttp.Config) {
59		c.Backend = be
60		c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
61		if mut != nil {
62			mut(c)
63		}
64	})
65	// A client sees the response before the handler returns and gives its
66	// slots back: serve through a wrapper that tells when it has.
67	g.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68		defer func() { g.served <- struct{}{} }()
69		g.h.ServeHTTP(w, r)
70	}))
71	t.Cleanup(g.srv.Close)
72	t.Cleanup(func() { g.open(); g.done.Wait() })
73	return g
74}
75
76// waitServed waits until n requests have left the handler.
77func (g *gatedRig) waitServed(n int) {
78	for range n {
79		<-g.served
80	}
81}
82
83// hold starts the requests and returns once all of them are inside the backend.
84func (g *gatedRig) hold(t *testing.T, reqs []held) {
85	t.Helper()
86	for _, h := range reqs {
87		method := http.MethodPost
88		if h.path == pathRefs {
89			method = http.MethodGet
90		}
91		g.done.Go(func() {
92			req, err := http.NewRequest(method, g.srv.URL+h.path, nil)
93			if err != nil {
94				return
95			}
96			req.SetBasicAuth("u", "root-secret")
97			req.Header.Set("X-Forwarded-For", h.client)
98			if resp, err := http.DefaultClient.Do(req); err == nil {
99				_ = resp.Body.Close()
100			}
101		})
102	}
103	for range reqs {
104		<-g.entered
105	}
106}
107
108func (g *gatedRig) open() {
109	select {
110	case <-g.gate:
111	default:
112		close(g.gate)
113	}
114}
115
116func TestHandlerBackendLimits(t *testing.T) {
117	t.Parallel()
118	tests := []struct {
119		name  string
120		cfg   func(*githttp.Config)
121		hold  []held
122		probe held
123		want  int
124	}{
125		{
126			name:  "free upload slot",
127			cfg:   func(c *githttp.Config) { c.MaxUploadPacks = 2 },
128			hold:  []held{{pathUpload, "198.51.100.1"}},
129			probe: held{pathUpload, "198.51.100.2"},
130			want:  http.StatusOK,
131		},
132		{
133			name:  "upload slots full",
134			cfg:   func(c *githttp.Config) { c.MaxUploadPacks = 2 },
135			hold:  []held{{pathUpload, "198.51.100.1"}, {pathRefs, "198.51.100.2"}},
136			probe: held{pathUpload, "198.51.100.3"},
137			want:  http.StatusServiceUnavailable,
138		},
139		{
140			name:  "ref advertisement counts as an upload",
141			cfg:   func(c *githttp.Config) { c.MaxUploadPacks = 1 },
142			hold:  []held{{pathUpload, "198.51.100.1"}},
143			probe: held{pathRefs, "198.51.100.2"},
144			want:  http.StatusServiceUnavailable,
145		},
146		{
147			name:  "receive slots full",
148			cfg:   func(c *githttp.Config) { c.MaxReceivePacks = 1 },
149			hold:  []held{{pathReceive, "198.51.100.1"}},
150			probe: held{pathReceive, "198.51.100.2"},
151			want:  http.StatusServiceUnavailable,
152		},
153		{
154			name:  "pushes do not take upload slots",
155			cfg:   func(c *githttp.Config) { c.MaxUploadPacks = 1; c.MaxReceivePacks = 1 },
156			hold:  []held{{pathUpload, "198.51.100.1"}},
157			probe: held{pathReceive, "198.51.100.2"},
158			want:  http.StatusOK,
159		},
160		{
161			name:  "fetches do not take receive slots",
162			cfg:   func(c *githttp.Config) { c.MaxUploadPacks = 1; c.MaxReceivePacks = 1 },
163			hold:  []held{{pathReceive, "198.51.100.1"}},
164			probe: held{pathUpload, "198.51.100.2"},
165			want:  http.StatusOK,
166		},
167		{
168			name:  "client at its cap",
169			cfg:   func(c *githttp.Config) { c.MaxPerClient = 2 },
170			hold:  []held{{pathUpload, "198.51.100.1"}, {pathReceive, "198.51.100.1"}},
171			probe: held{pathUpload, "198.51.100.1"},
172			want:  http.StatusTooManyRequests,
173		},
174		{
175			name:  "another client is not affected by the cap",
176			cfg:   func(c *githttp.Config) { c.MaxPerClient = 2 },
177			hold:  []held{{pathUpload, "198.51.100.1"}, {pathUpload, "198.51.100.1"}},
178			probe: held{pathUpload, "198.51.100.2"},
179			want:  http.StatusOK,
180		},
181		{
182			name:  "addresses of one IPv6 /64 are one client",
183			cfg:   func(c *githttp.Config) { c.MaxPerClient = 1 },
184			hold:  []held{{pathUpload, "2001:db8::1"}},
185			probe: held{pathUpload, "2001:db8::2"},
186			want:  http.StatusTooManyRequests,
187		},
188	}
189	for _, tt := range tests {
190		t.Run(tt.name, func(t *testing.T) {
191			t.Parallel()
192			g := newGatedRig(t, func(c *githttp.Config) {
193				c.MaxUploadPacks, c.MaxReceivePacks, c.MaxPerClient = 8, 8, 8
194				tt.cfg(c)
195			})
196			g.hold(t, tt.hold)
197			if tt.want == http.StatusOK {
198				g.open() // the probe has to get through the backend gate too
199			}
200			code, _, hdr := g.do(t, http.MethodPost, tt.probe.path, "u", "root-secret", nil,
201				map[string]string{"X-Forwarded-For": tt.probe.client})
202			if tt.probe.path == pathRefs {
203				code, _, hdr = g.do(t, http.MethodGet, tt.probe.path, "u", "root-secret", nil,
204					map[string]string{"X-Forwarded-For": tt.probe.client})
205			}
206			assert.Equal(t, tt.want, code)
207			if tt.want != http.StatusOK {
208				assert.NotEmpty(t, hdr.Get("Retry-After"))
209			}
210		})
211	}
212}
213
214func TestHandlerBackendSlotsAreReleased(t *testing.T) {
215	t.Parallel()
216	g := newGatedRig(t, func(c *githttp.Config) {
217		c.MaxUploadPacks, c.MaxReceivePacks, c.MaxPerClient = 1, 1, 1
218	})
219	hdr := map[string]string{"X-Forwarded-For": "198.51.100.1"}
220
221	g.hold(t, []held{{pathUpload, "198.51.100.1"}})
222	code, _, _ := g.do(t, http.MethodPost, pathUpload, "u", "root-secret", nil, hdr)
223	require.Equal(t, http.StatusTooManyRequests, code)
224	g.waitServed(1)
225
226	g.open()
227	g.waitServed(1)
228	for _, p := range []string{pathUpload, pathReceive, pathUpload} {
229		code, _, _ = g.do(t, http.MethodPost, p, "u", "root-secret", nil, hdr)
230		assert.Equal(t, http.StatusOK, code, "the slots came back: %s", p)
231		g.waitServed(1)
232	}
233}
234
235func TestHandlerBackendFailureReleasesSlots(t *testing.T) {
236	t.Parallel()
237	r := newRig(t, func(c *githttp.Config) { c.MaxUploadPacks, c.MaxPerClient = 1, 1 })
238	r.backendErr = context.DeadlineExceeded
239	for range 3 {
240		code, _, _ := r.do(t, http.MethodPost, pathUpload, "", "", nil, nil)
241		assert.Equal(t, http.StatusInternalServerError, code, "never a refusal: the slot is released on failure")
242	}
243}
244
245// deadlineRecorder is a ResponseWriter that supports the read deadline, as
246// the writer of net/http does, and records it.
247type deadlineRecorder struct {
248	*httptest.ResponseRecorder
249	deadlines []time.Time
250}
251
252func (d *deadlineRecorder) SetReadDeadline(t time.Time) error {
253	d.deadlines = append(d.deadlines, t)
254	return nil
255}
256
257func TestHandlerBodyReadDeadline(t *testing.T) {
258	t.Parallel()
259	tests := []struct {
260		name string
261		path string
262		max  time.Duration
263		min  time.Duration
264	}{
265		{"fetch body", pathUpload, time.Minute, 30 * time.Second},
266		{"push body is bounded by idleness", pathReceive, 5 * time.Minute, time.Minute},
267	}
268	for _, tt := range tests {
269		t.Run(tt.name, func(t *testing.T) {
270			t.Parallel()
271			r := newRig(t, nil)
272			req := httptest.NewRequest(http.MethodPost, tt.path, stringsReader("payload"))
273			req.SetBasicAuth("u", "root-secret")
274			w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
275			start := time.Now()
276			r.h.ServeHTTP(w, req)
277			require.Equal(t, http.StatusOK, w.Code)
278
279			require.GreaterOrEqual(t, len(w.deadlines), 2)
280			first, last := w.deadlines[0], w.deadlines[len(w.deadlines)-1]
281			assert.WithinRange(t, first, start.Add(tt.min), time.Now().Add(tt.max))
282			assert.True(t, last.IsZero(), "the deadline is lifted once the body is read")
283		})
284	}
285}
286
287func stringsReader(s string) *strings.Reader { return strings.NewReader(s) }

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 4

Tags 45

↑↓ moveenter openesc close