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