v0.8.0 eaf4295

handler_test.go RawHistoryFolder

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}

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 3

Tags 44

↑↓ moveenter openesc close