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