v0.18.1 e08f000

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

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