1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package githttp_test
4
5import (
6 "context"
7 "encoding/base64"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "net/netip"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/mock"
21 "github.com/stretchr/testify/require"
22
23 "bnema.dev/ermite/internal/adapters/githttp"
24 "bnema.dev/ermite/internal/clientlimit"
25 "bnema.dev/ermite/internal/domain"
26 "bnema.dev/ermite/internal/ports"
27 "bnema.dev/ermite/internal/ports/mocks"
28)
29
30// cgiScript is a stand-in for git-http-backend: it answers with the
31// environment and the body it received. Like Git, it reads the whole request
32// before it answers (net/http cannot read a request body once the response
33// has started).
34//
35// The script is written once, in TestMain, before any test runs: writing an
36// executable while parallel tests fork lets a child inherit the open write
37// descriptor, and exec then fails with ETXTBSY (golang/go#22315).
38func cgiScript(t *testing.T) string {
39 t.Helper()
40 require.NotEmpty(t, backendScript, "TestMain writes the backend script")
41 return backendScript
42}
43
44var backendScript string
45
46func TestMain(m *testing.M) {
47 os.Exit(runWithBackendScript(m))
48}
49
50func runWithBackendScript(m *testing.M) int {
51 dir, err := os.MkdirTemp("", "githttp-backend")
52 if err != nil {
53 panic(err)
54 }
55 defer func() { _ = os.RemoveAll(dir) }()
56 backendScript = filepath.Join(dir, "backend")
57 body := "#!/bin/sh\nin=$(mktemp)\ncat > \"$in\"\nprintf 'Content-Type: text/plain\\r\\n\\r\\n'\nenv | sort\nprintf '\\n--body--\\n'\ncat \"$in\"\nrm -f \"$in\"\n"
58 if err := os.WriteFile(backendScript, []byte(body), 0o755); err != nil {
59 panic(err)
60 }
61 return m.Run()
62}
63
64type rig struct {
65 h *githttp.Handler
66 srv *httptest.Server
67 pushes *mocks.PushService
68 backend *mocks.GitBackend
69 hooks *mocks.HookTokens
70 auth *mocks.AuthService
71
72 pushFlags []bool
73 backendErr error
74
75 mu sync.Mutex // guards what the mocks record while the server runs
76 pushErr error // what Pushes.Open answers a named caller
77 pushCalls []bool // the create flag of each Pushes.Open
78 authCalls int // Authenticator.Authenticate calls
79 released int // hook tokens released
80 hookRepo domain.RepoName
81}
82
83func (r *rig) setPushErr(err error) {
84 r.mu.Lock()
85 defer r.mu.Unlock()
86 r.pushErr = err
87}
88
89func (r *rig) takePushCalls() []bool {
90 r.mu.Lock()
91 defer r.mu.Unlock()
92 c := r.pushCalls
93 r.pushCalls = nil
94 return c
95}
96
97func (r *rig) authenticateCalls() int {
98 r.mu.Lock()
99 defer r.mu.Unlock()
100 return r.authCalls
101}
102
103func (r *rig) releasedTokens() int {
104 r.mu.Lock()
105 defer r.mu.Unlock()
106 return r.released
107}
108
109func (r *rig) hookedRepo() domain.RepoName {
110 r.mu.Lock()
111 defer r.mu.Unlock()
112 return r.hookRepo
113}
114
115// stubUseCases answers like the use cases behind the handler: a root and a
116// reader token, a public repository "pub" and a private one "priv".
117func (r *rig) stubUseCases(repos *mocks.RepoLookup) {
118 r.auth.EXPECT().Authenticate(mock.Anything, mock.Anything).RunAndReturn(
119 func(_ context.Context, secret string) (domain.Principal, error) {
120 r.mu.Lock()
121 r.authCalls++
122 r.mu.Unlock()
123 switch secret {
124 case "root-secret":
125 return domain.Root(), nil
126 case "reader-secret":
127 return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
128 }
129 return domain.Principal{}, domain.ErrUnauthenticated
130 }).Maybe()
131
132 repos.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
133 func(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
134 switch n.String() {
135 case "pub":
136 return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
137 case "priv":
138 if p.Allows(domain.ScopeRepoRead, n) {
139 return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
140 }
141 }
142 return domain.Repo{}, domain.ErrNotFound
143 }).Maybe()
144
145 r.pushes.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
146 func(_ context.Context, p domain.Principal, _ domain.RepoName, create bool) error {
147 r.mu.Lock()
148 defer r.mu.Unlock()
149 r.pushCalls = append(r.pushCalls, create)
150 if p.IsAnonymous() {
151 return domain.ErrUnauthenticated
152 }
153 return r.pushErr
154 }).Maybe()
155
156 r.hooks.EXPECT().Register(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
157 func(_ context.Context, n domain.RepoName, _ domain.Principal) (string, func()) {
158 r.mu.Lock()
159 defer r.mu.Unlock()
160 r.hookRepo = n
161 return "hook-token", func() { r.mu.Lock(); r.released++; r.mu.Unlock() }
162 }).Maybe()
163 r.hooks.EXPECT().Socket().Return("/run/hook.sock").Maybe()
164}
165
166func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
167 t.Helper()
168 r := &rig{
169 pushes: mocks.NewPushService(t), backend: mocks.NewGitBackend(t),
170 hooks: mocks.NewHookTokens(t), auth: mocks.NewAuthService(t),
171 }
172 repos := mocks.NewRepoLookup(t)
173 r.stubUseCases(repos)
174 cfg := githttp.Config{
175 Auth: r.auth, Repos: repos, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,
176 }
177 script := cgiScript(t)
178 r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
179 func(_ context.Context, push bool) (ports.HTTPBackend, error) {
180 r.pushFlags = append(r.pushFlags, push)
181 if r.backendErr != nil {
182 return ports.HTTPBackend{}, r.backendErr
183 }
184 return ports.HTTPBackend{Path: script, Dir: filepath.Dir(script), Env: []string{"PATH=/usr/bin:/bin", "GIT_PROJECT_ROOT=/x"}}, nil
185 }).Maybe()
186 if mut != nil {
187 mut(&cfg)
188 }
189 r.h = githttp.New(cfg)
190 mux := http.NewServeMux()
191 for _, p := range githttp.Patterns {
192 mux.Handle(p, r.h)
193 }
194 r.srv = httptest.NewServer(mux)
195 t.Cleanup(r.srv.Close)
196 return r
197}
198
199func (r *rig) do(t *testing.T, method, path, user, pass string, body io.Reader, hdr map[string]string) (int, string, http.Header) {
200 t.Helper()
201 req, err := http.NewRequest(method, r.srv.URL+path, body)
202 require.NoError(t, err)
203 if pass != "" {
204 req.SetBasicAuth(user, pass)
205 }
206 for k, v := range hdr {
207 req.Header.Set(k, v)
208 }
209 resp, err := http.DefaultClient.Do(req)
210 require.NoError(t, err)
211 defer func() { _ = resp.Body.Close() }()
212 b, err := io.ReadAll(resp.Body)
213 require.NoError(t, err)
214 return resp.StatusCode, string(b), resp.Header
215}
216
217func envOf(body string) map[string]string {
218 head, _, _ := strings.Cut(body, "\n--body--\n")
219 m := map[string]string{}
220 for _, l := range strings.Split(head, "\n") {
221 k, v, ok := strings.Cut(l, "=")
222 if ok {
223 m[k] = v
224 }
225 }
226 return m
227}
228
229func TestHandlerRoutesAndStatuses(t *testing.T) {
230 t.Parallel()
231 tests := []struct {
232 name string
233 method string
234 path string
235 pass string
236 want int
237 auth bool // expects a WWW-Authenticate challenge
238 }{
239 {"public, anonymous", "GET", "/pub.git/info/refs?service=git-upload-pack", "", 200, false},
240 {"public without .git", "GET", "/pub/info/refs?service=git-upload-pack", "", 200, false},
241 {"private, anonymous", "GET", "/priv.git/info/refs?service=git-upload-pack", "", 401, true},
242 {"missing, anonymous", "GET", "/none.git/info/refs?service=git-upload-pack", "", 401, true},
243 {"private, reader", "GET", "/priv.git/info/refs?service=git-upload-pack", "reader-secret", 200, false},
244 {"private, wrong token", "GET", "/priv.git/info/refs?service=git-upload-pack", "bad", 401, true},
245 {"missing, valid token", "GET", "/none.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
246 {"invalid name", "GET", "/Bad.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
247 {"name ending in .git.git", "GET", "/x.git.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
248 {"no service", "GET", "/pub.git/info/refs", "", 404, false},
249 {"unknown service", "GET", "/pub.git/info/refs?service=git-evil", "", 404, false},
250 {"HEAD file", "GET", "/pub.git/HEAD", "", 404, false},
251 {"objects", "GET", "/pub.git/objects/info/packs", "", 404, false},
252 {"upload-pack wrong method", "GET", "/pub.git/git-upload-pack", "", 405, false},
253 {"info/refs wrong method", "POST", "/pub.git/info/refs?service=git-upload-pack", "", 405, false},
254 {"receive-pack, anonymous", "GET", "/pub.git/info/refs?service=git-receive-pack", "", 401, true},
255 {"receive-pack, root", "GET", "/pub.git/info/refs?service=git-receive-pack", "root-secret", 200, false},
256 }
257 for _, tt := range tests {
258 t.Run(tt.name, func(t *testing.T) {
259 t.Parallel()
260 r := newRig(t, nil) // a rig per subtest: its mocks record without locking
261 user := ""
262 if tt.pass != "" {
263 user = "whatever" // the username is ignored
264 }
265 code, _, hdr := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil)
266 assert.Equal(t, tt.want, code)
267 if tt.auth {
268 assert.Equal(t, `Basic realm="ermite"`, hdr.Get("WWW-Authenticate"))
269 } else {
270 assert.Empty(t, hdr.Get("WWW-Authenticate"))
271 }
272 })
273 }
274}
275
276func TestHandlerPushAuthorizationAnswers(t *testing.T) {
277 t.Parallel()
278 r := newRig(t, nil)
279 get := func() int {
280 code, _, _ := r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
281 return code
282 }
283 assert.Equal(t, 200, get())
284 r.setPushErr(domain.ErrForbidden)
285 assert.Equal(t, 403, get())
286 r.setPushErr(domain.ErrNotFound)
287 assert.Equal(t, 404, get())
288 r.setPushErr(domain.ErrUnauthenticated)
289 assert.Equal(t, 401, get())
290 r.setPushErr(errBoom{})
291 assert.Equal(t, 500, get())
292
293 // Only the first request of a push may create.
294 r.setPushErr(nil)
295 r.takePushCalls()
296 r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
297 r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"})
298 assert.Equal(t, []bool{true, false}, r.takePushCalls())
299 // Reads never reach the push use case.
300 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
301 assert.Empty(t, r.takePushCalls())
302}
303
304type errBoom struct{}
305
306func (errBoom) Error() string { return "boom with /internal/detail" }
307
308func TestHandlerBackendEnvironment(t *testing.T) {
309 t.Parallel()
310 r := newRig(t, nil)
311 hdr := map[string]string{
312 "Cookie": "session=secret",
313 "Proxy-Authorization": "Basic proxy-should-not-leak",
314 "Git-Protocol": "version=2",
315 "Content-Type": "application/x-git-receive-pack-request",
316 "X-Forwarded-For": "1.2.3.4",
317 "X-Auth-Token": "arbitrary-header-secret",
318 "Accept": "application/x-git-upload-pack-result",
319 "User-Agent": "git/2.50",
320 }
321
322 // A read: no hook environment.
323 code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, hdr)
324 require.Equal(t, 200, code)
325 e := envOf(body)
326 assert.Equal(t, "/pub.git/info/refs", e["PATH_INFO"])
327 assert.Equal(t, "service=git-upload-pack", e["QUERY_STRING"])
328 assert.Equal(t, "version=2", e["HTTP_GIT_PROTOCOL"])
329 assert.Equal(t, "/x", e["GIT_PROJECT_ROOT"], "the backend environment is used")
330 assert.NotContains(t, e, "ERMITE_HOOK_TOKEN")
331 assert.NotContains(t, body, "should-not-leak")
332 assert.NotContains(t, body, "root-secret")
333 assert.NotContains(t, body, base64.StdEncoding.EncodeToString([]byte("u:root-secret")), "the Authorization header is not passed on")
334 assert.NotContains(t, body, "session=secret")
335 assert.NotContains(t, e, "HTTP_AUTHORIZATION")
336 assert.NotContains(t, e, "HTTP_PROXY_AUTHORIZATION")
337 assert.NotContains(t, e, "HTTP_COOKIE")
338 assert.Equal(t, "application/x-git-upload-pack-result", e["HTTP_ACCEPT"])
339 assert.Equal(t, "git/2.50", e["HTTP_USER_AGENT"])
340 assert.Equal(t, []bool{false}, r.pushFlags)
341
342 // A receive-pack POST gets the hook environment and releases the token.
343 code, _, _ = r.do(t, "POST", "/Pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("PACKDATA"), hdr)
344 assert.Equal(t, 404, code, "invalid name")
345 code, body, _ = r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("PACKDATA"), hdr)
346 require.Equal(t, 200, code)
347 e = envOf(body)
348 assert.Equal(t, "hook-token", e["ERMITE_HOOK_TOKEN"])
349 assert.Equal(t, "/run/hook.sock", e["ERMITE_HOOK_SOCKET"])
350 assert.Equal(t, "/pub.git/git-receive-pack", e["PATH_INFO"])
351 assert.Equal(t, "8", e["CONTENT_LENGTH"])
352 assert.True(t, strings.HasSuffix(body, "\n--body--\nPACKDATA"), "the body reaches the backend")
353 assert.NotContains(t, body, "root-secret")
354 assert.NotContains(t, body, "arbitrary-header-secret", "only allowlisted headers reach the backend, and so its hooks")
355 assert.NotContains(t, e, "HTTP_X_AUTH_TOKEN")
356 assert.NotContains(t, e, "HTTP_X_FORWARDED_FOR")
357 assert.Equal(t, 1, r.releasedTokens(), "the hook token is revoked when the request ends")
358 assert.Equal(t, "pub", r.hookedRepo().String())
359
360 // The info/refs advertisement of a push does not register a hook token.
361 r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
362 assert.Equal(t, 1, r.releasedTokens())
363}
364
365func TestHandlerNormalizesRepositoryPath(t *testing.T) {
366 t.Parallel()
367 r := newRig(t, nil)
368 _, body, _ := r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
369 assert.Equal(t, "/pub.git/info/refs", envOf(body)["PATH_INFO"], "the backend always sees <name>.git")
370}
371
372func TestHandlerChunkedBodyIsStreamed(t *testing.T) {
373 t.Parallel()
374 r := newRig(t, nil)
375 payload := strings.Repeat("x", 300_000)
376 // An io.Reader of unknown length makes the client send chunked.
377 body := io.MultiReader(strings.NewReader(payload))
378 code, out, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", body, map[string]string{"Content-Type": "application/x-git-upload-pack-request"})
379 require.Equal(t, 200, code)
380 e := envOf(out)
381 assert.NotContains(t, e, "CONTENT_LENGTH", "the length is unknown to the backend: the body is streamed, not buffered")
382 assert.NotContains(t, e, "HTTP_TRANSFER_ENCODING")
383 _, got, _ := strings.Cut(out, "\n--body--\n")
384 assert.Equal(t, len(payload), len(got))
385 assert.True(t, payload == got, "the body arrives intact")
386}
387
388func TestHandlerBodyLimit(t *testing.T) {
389 t.Parallel()
390 r := newRig(t, func(c *githttp.Config) { c.MaxBodyBytes = 1000 })
391 big := strings.Repeat("x", 5000)
392 code, _, _ := r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader(big), nil)
393 assert.Equal(t, http.StatusRequestEntityTooLarge, code, "declared length over the limit")
394
395 // A chunked body has no declared length: it is cut off at the limit, so
396 // the backend sees at most the limit and never the rest.
397 code, out, _ := r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", io.MultiReader(strings.NewReader(big)), nil)
398 _, got, _ := strings.Cut(out, "\n--body--\n")
399 assert.LessOrEqual(t, len(got), 1000, "the body is cut off at the limit (status %d)", code)
400 assert.NotEqual(t, big, got)
401
402 code, _, _ = r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("ok"), nil)
403 assert.Equal(t, 200, code)
404}
405
406func TestHandlerUploadPackBodyIsBoundedBelowThePushLimit(t *testing.T) {
407 t.Parallel()
408 r := newRig(t, nil) // the push limit is 4 GiB
409 big := strings.NewReader(strings.Repeat("x", 64<<20+1))
410 code, _, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", big, nil)
411 assert.Equal(t, http.StatusRequestEntityTooLarge, code, "a fetch request is a short list of wants")
412}
413
414func TestHandlerServiceQuery(t *testing.T) {
415 t.Parallel()
416 r := newRig(t, nil)
417 for _, q := range []string{
418 "service=git-upload-pack&service=git-receive-pack",
419 "service=git-upload-pack&service=git-upload-pack",
420 "service=git-upload-pack&service=",
421 "service=",
422 } {
423 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?"+q, "u", "root-secret", nil, nil)
424 assert.Equal(t, 404, code, q)
425 }
426 // Other parameters do not reach the backend; the service is rebuilt.
427 code, body, _ := r.do(t, "GET", "/pub.git/info/refs?x=1&service=git-upload-pack&y=%0a", "", "", nil, nil)
428 require.Equal(t, 200, code)
429 assert.Equal(t, "service=git-upload-pack", envOf(body)["QUERY_STRING"])
430}
431
432func TestHandlerGitProtocolHeader(t *testing.T) {
433 t.Parallel()
434 r := newRig(t, nil)
435 tests := []struct {
436 value string
437 want string
438 }{
439 {"version=2", "version=2"},
440 {"version=1", "version=1"},
441 {"version=2:object-format=sha1", "version=2:object-format=sha1"},
442 {"VERSION=2", ""},
443 {"version=2;rm", ""},
444 {"version", ""},
445 {"version=2:", ""},
446 {"a=b c", ""},
447 {"version=" + strings.Repeat("2", 200), ""},
448 }
449 for _, tt := range tests {
450 _, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"Git-Protocol": tt.value})
451 e := envOf(body)
452 if tt.want == "" {
453 assert.NotContains(t, e, "HTTP_GIT_PROTOCOL", tt.value)
454 } else {
455 assert.Equal(t, tt.want, e["HTTP_GIT_PROTOCOL"], tt.value)
456 }
457 }
458}
459
460func TestHandlerDoesNotInheritTheProcessEnvironment(t *testing.T) {
461 t.Setenv("LD_LIBRARY_PATH", "/evil/lib")
462 t.Setenv("ERMITE_ADMIN_TOKEN", "must-not-leak")
463 r := newRig(t, nil)
464 _, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
465 e := envOf(body)
466 assert.Empty(t, e["LD_LIBRARY_PATH"], "net/http/cgi inherits it by default")
467 assert.NotContains(t, body, "must-not-leak")
468}
469
470func TestHandlerBackendFailure(t *testing.T) {
471 t.Parallel()
472 r := newRig(t, nil)
473 r.backendErr = io.ErrUnexpectedEOF
474 code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
475 assert.Equal(t, 500, code)
476 assert.NotContains(t, body, "EOF", "no internal detail in the response")
477}
478
479func TestHandlerLimiter(t *testing.T) {
480 t.Parallel()
481 r := newRig(t, func(c *githttp.Config) { c.Limiter = clientlimit.NewLimiter(nil) })
482 for i := range clientlimit.MaxFailures {
483 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil)
484 require.Equal(t, 401, code, i)
485 }
486 code, _, hdr := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
487 assert.Equal(t, 429, code)
488 assert.NotEmpty(t, hdr.Get("Retry-After"))
489 // A blocked request does not even reach the authenticator.
490 n := r.authenticateCalls()
491 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
492 assert.Equal(t, n, r.authenticateCalls())
493}
494
495func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
496 t.Parallel()
497 r := newRig(t, func(c *githttp.Config) {
498 c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
499 })
500 for range clientlimit.MaxFailures {
501 r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
502 }
503 code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
504 assert.Equal(t, 429, code)
505 code, _, _ = r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.2"})
506 assert.Equal(t, 200, code)
507}
508
509func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
510 t.Parallel()
511 r := newRig(t, nil)
512 path := "/priv.git/info/refs?service=git-upload-pack"
513 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "Bearer reader-secret"})
514 assert.Equal(t, 200, code)
515 code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})
516 assert.Equal(t, 200, code, "the scheme is case-insensitive")
517 for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {
518 code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})
519 assert.Equal(t, 401, code, h)
520 }
521 // An empty password is a failed attempt, not anonymous access.
522 req, _ := http.NewRequest("GET", r.srv.URL+"/pub.git/info/refs?service=git-upload-pack", nil)
523 req.SetBasicAuth("u", "")
524 resp, err := http.DefaultClient.Do(req)
525 require.NoError(t, err)
526 _ = resp.Body.Close()
527 assert.Equal(t, 401, resp.StatusCode)
528}
529
530func TestHandlerUsesTheFailureLimiter(t *testing.T) {
531 t.Parallel()
532 path := "/pub.git/info/refs?service=git-upload-pack"
533 t.Run("a blocked client is refused before authentication", func(t *testing.T) {
534 t.Parallel()
535 limiter := mocks.NewFailureLimiter(t)
536 limiter.EXPECT().Blocked(mock.Anything, "127.0.0.1").Return(true, 3*time.Second).Once()
537 r := newRig(t, func(c *githttp.Config) { c.Limiter = limiter })
538 code, _, hdr := r.do(t, "GET", path, "u", "root-secret", nil, nil)
539 assert.Equal(t, 429, code)
540 assert.Equal(t, "3", hdr.Get("Retry-After"))
541 assert.Zero(t, r.authenticateCalls())
542 })
543 t.Run("an invalid secret is recorded as a failure", func(t *testing.T) {
544 t.Parallel()
545 limiter := mocks.NewFailureLimiter(t)
546 limiter.EXPECT().Blocked(mock.Anything, "127.0.0.1").Return(false, time.Duration(0)).Once()
547 limiter.EXPECT().Fail(mock.Anything, "127.0.0.1").Once()
548 r := newRig(t, func(c *githttp.Config) { c.Limiter = limiter })
549 code, _, _ := r.do(t, "GET", path, "u", "wrong", nil, nil)
550 assert.Equal(t, 401, code)
551 })
552 t.Run("a valid secret is not a failure", func(t *testing.T) {
553 t.Parallel()
554 limiter := mocks.NewFailureLimiter(t)
555 limiter.EXPECT().Blocked(mock.Anything, "127.0.0.1").Return(false, time.Duration(0)).Once()
556 r := newRig(t, func(c *githttp.Config) { c.Limiter = limiter })
557 code, _, _ := r.do(t, "GET", path, "u", "root-secret", nil, nil)
558 assert.Equal(t, 200, code)
559 })
560}