v0.28.0 fca9155

handler_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package githubapi
4
5import (
6	"context"
7	"errors"
8	"io"
9	"net"
10	"net/http"
11	"net/http/httptest"
12	"strings"
13	"sync"
14	"testing"
15	"time"
16
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/mock"
19	"github.com/stretchr/testify/require"
20
21	"bnema.dev/ermite/internal/domain"
22	"bnema.dev/ermite/internal/ports/mocks"
23)
24
25type rig struct {
26	h    *Handler
27	srv  *httptest.Server
28	api  *mocks.GitHubAPIService
29	auth *mocks.AuthService
30}
31
32func newRig(t *testing.T, mut func(*Config)) *rig {
33	t.Helper()
34	r := &rig{api: mocks.NewGitHubAPIService(t), auth: mocks.NewAuthService(t)}
35	cfg := Config{Auth: r.auth, API: r.api}
36	if mut != nil {
37		mut(&cfg)
38	}
39	r.h = New(cfg)
40	mux := http.NewServeMux()
41	mux.Handle(Prefix+"/", r.h)
42	r.srv = httptest.NewServer(mux)
43	t.Cleanup(r.srv.Close)
44	return r
45}
46
47func (r *rig) do(t *testing.T, method, path, authorization string) (*http.Response, string) {
48	t.Helper()
49	req, err := http.NewRequest(method, r.srv.URL+Prefix+path, nil)
50	require.NoError(t, err)
51	if authorization != "" {
52		req.Header.Set("Authorization", authorization)
53	}
54	resp, err := http.DefaultClient.Do(req)
55	require.NoError(t, err)
56	defer func() { _ = resp.Body.Close() }()
57	b, err := io.ReadAll(resp.Body)
58	require.NoError(t, err)
59	return resp, string(b)
60}
61
62const tarballPath = "/repos/acme/proj/tarball/main"
63
64// blockingArchive makes Tarball write one chunk and then wait for release, so
65// that the archive stays in progress while the test looks at the others.
66type blockingArchive struct {
67	started chan struct{}
68	release chan struct{}
69}
70
71func newBlockingArchive() *blockingArchive {
72	return &blockingArchive{started: make(chan struct{}, 32), release: make(chan struct{})}
73}
74
75func (b *blockingArchive) run(ctx context.Context, _ domain.Principal, _, _, _ string, begin func(string) io.Writer) error {
76	w := begin("acme-proj-abcdef0.tar.gz")
77	_, _ = w.Write([]byte("chunk"))
78	b.started <- struct{}{}
79	select {
80	case <-b.release:
81	case <-ctx.Done():
82	}
83	return nil
84}
85
86// A HEAD resolves the ref and answers the headers of the archive: git archive is
87// not run (the mock fails on a call to Tarball), no slot is taken, and an error
88// is answered as for a GET.
89func TestHeadOfTheTarballRunsNoArchive(t *testing.T) {
90	t.Parallel()
91	r := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 1, 1 })
92	r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "main").Return("acme-proj-abcdef0.tar.gz", nil)
93	r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "nope").Return("", domain.ErrNotFound)
94	r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "").Return("", errors.New("disk"))
95
96	resp, body := r.do(t, http.MethodHead, tarballPath, "")
97	assert.Equal(t, http.StatusOK, resp.StatusCode)
98	assert.Empty(t, body)
99	assert.Equal(t, "attachment; filename=acme-proj-abcdef0.tar.gz", resp.Header.Get("Content-Disposition"))
100	assert.Equal(t, "application/x-gzip", resp.Header.Get("Content-Type"))
101
102	resp, body = r.do(t, http.MethodHead, "/repos/acme/proj/tarball/nope", "")
103	assert.Equal(t, http.StatusNotFound, resp.StatusCode)
104	assert.Empty(t, body, "a HEAD has no body, the status says it")
105	resp, _ = r.do(t, http.MethodHead, "/repos/acme/proj/tarball", "")
106	assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
107
108	// The one slot is free for a GET after any number of HEADs.
109	for range 5 {
110		resp, _ := r.do(t, http.MethodHead, tarballPath, "")
111		assert.Equal(t, http.StatusOK, resp.StatusCode)
112	}
113}
114
115// The per-client cap answers 429 with Retry-After, the global cap 503 with
116// Retry-After, and a finished archive gives its slot back.
117func TestArchiveSlots(t *testing.T) {
118	t.Parallel()
119	t.Run("per client", func(t *testing.T) {
120		t.Parallel()
121		arch := newBlockingArchive()
122		r := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 8, 2 })
123		r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch.run)
124
125		var wg sync.WaitGroup
126		for range 2 {
127			wg.Add(1)
128			go func() { defer wg.Done(); r.do(t, http.MethodGet, tarballPath, "") }()
129		}
130		for range 2 {
131			<-arch.started
132		}
133		resp, body := r.do(t, http.MethodGet, tarballPath, "")
134		assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode)
135		assert.Equal(t, "5", resp.Header.Get("Retry-After"))
136		assert.Contains(t, body, "Too many archives")
137		assert.Contains(t, resp.Header.Get("Content-Type"), "application/json")
138
139		// Other endpoints and HEAD are not archives.
140		r.api.EXPECT().HashAlgorithm(mock.Anything, mock.Anything, "acme", "proj").Return("sha1", nil)
141		resp, _ = r.do(t, http.MethodGet, "/repos/acme/proj/hash-algorithm", "")
142		assert.Equal(t, http.StatusOK, resp.StatusCode)
143
144		close(arch.release)
145		wg.Wait()
146		// The slots are given back: two more go through.
147		arch2 := newBlockingArchive()
148		close(arch2.release)
149		r.api.ExpectedCalls = nil
150		r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch2.run)
151		for range 4 {
152			resp, _ := r.do(t, http.MethodGet, tarballPath, "")
153			assert.Equal(t, http.StatusOK, resp.StatusCode)
154		}
155	})
156
157	t.Run("global", func(t *testing.T) {
158		t.Parallel()
159		arch := newBlockingArchive()
160		r := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 1, 8 })
161		r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch.run)
162
163		done := make(chan struct{})
164		go func() { defer close(done); r.do(t, http.MethodGet, tarballPath, "") }()
165		<-arch.started
166		resp, body := r.do(t, http.MethodGet, tarballPath, "")
167		assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
168		assert.Equal(t, "5", resp.Header.Get("Retry-After"))
169		assert.Contains(t, body, "Server busy")
170		close(arch.release)
171		<-done
172
173		// The client's own count was given back when the global slot refused it:
174		// with a cap of one per client, the next one still gets a slot.
175		r2 := newRig(t, func(c *Config) { c.MaxArchives, c.MaxArchivesPerClient = 1, 1 })
176		arch2 := newBlockingArchive()
177		r2.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(arch2.run)
178		done2 := make(chan struct{})
179		go func() { defer close(done2); r2.do(t, http.MethodGet, tarballPath, "") }()
180		<-arch2.started
181		resp, _ = r2.do(t, http.MethodGet, tarballPath, "")
182		assert.Equal(t, http.StatusTooManyRequests, resp.StatusCode, "the client cap answers first")
183		close(arch2.release)
184		<-done2
185		close(arch.started)
186	})
187
188	t.Run("a server busy answer does not leak the client slot", func(t *testing.T) {
189		t.Parallel()
190		h := New(Config{MaxArchives: 1, MaxArchivesPerClient: 3, Auth: mocks.NewAuthService(t), API: mocks.NewGitHubAPIService(t)})
191		h.archives <- struct{}{} // the server is full
192		for range 10 {
193			rec := httptest.NewRecorder()
194			_, ok := h.acquire(rec, "client")
195			require.False(t, ok)
196			assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
197			assert.Equal(t, "5", rec.Header().Get("Retry-After"))
198		}
199		assert.Empty(t, h.clients, "no client count was left behind")
200		<-h.archives
201		release, ok := h.acquire(httptest.NewRecorder(), "client")
202		require.True(t, ok)
203		release()
204		assert.Empty(t, h.clients)
205		assert.Empty(t, h.archives)
206	})
207}
208
209// fakeRW is a response writer that can set a write deadline, like the one of
210// net/http, and records the deadlines and the writes.
211type fakeRW struct {
212	header    http.Header
213	body      strings.Builder
214	deadlines []time.Time
215	failWith  error
216}
217
218func (f *fakeRW) Header() http.Header { return f.header }
219func (f *fakeRW) WriteHeader(int)     {}
220func (f *fakeRW) Write(b []byte) (int, error) {
221	if f.failWith != nil {
222		return 0, f.failWith
223	}
224	return f.body.Write(b)
225}
226func (f *fakeRW) SetWriteDeadline(t time.Time) error {
227	f.deadlines = append(f.deadlines, t)
228	return nil
229}
230
231// The deadline of the connection moves forward before every chunk, by the idle
232// timeout, so that only a client that stops reading is cut.
233func TestIdleWriterMovesTheDeadlineForEveryChunk(t *testing.T) {
234	t.Parallel()
235	rw := &fakeRW{header: http.Header{}}
236	w := &idleWriter{w: rw, rc: http.NewResponseController(rw), idle: 90 * time.Second}
237
238	before := time.Now()
239	for i := range 3 {
240		n, err := w.Write([]byte("chunk"))
241		require.NoError(t, err, i)
242		assert.Equal(t, 5, n)
243		time.Sleep(5 * time.Millisecond)
244	}
245	assert.Equal(t, "chunkchunkchunk", rw.body.String())
246	require.Len(t, rw.deadlines, 3, "one deadline per chunk")
247	for i, d := range rw.deadlines {
248		assert.WithinDuration(t, before.Add(90*time.Second), d, 2*time.Second, i)
249		if i > 0 {
250			assert.True(t, d.After(rw.deadlines[i-1]), "it moves forward: %d", i)
251		}
252	}
253
254	// A write error is returned as it is.
255	rw.failWith = errors.New("broken pipe")
256	_, err := w.Write([]byte("x"))
257	assert.EqualError(t, err, "broken pipe")
258
259	// A writer that cannot set a deadline still writes: the bound is then not
260	// applied here, and nothing fails.
261	plain := httptest.NewRecorder()
262	pw := &idleWriter{w: plain, rc: http.NewResponseController(plain), idle: time.Second}
263	_, err = pw.Write([]byte("ok"))
264	require.NoError(t, err)
265	assert.Equal(t, "ok", plain.Body.String())
266}
267
268// A client that stops reading is cut after the idle timeout, with a real
269// connection: the archive is cut short, the slot is given back, and the next
270// download goes through.
271func TestAClientThatStopsReadingIsCut(t *testing.T) {
272	t.Parallel()
273	var writeErr = make(chan error, 1)
274	r := newRig(t, func(c *Config) {
275		c.MaxArchives, c.MaxArchivesPerClient, c.WriteIdleTimeout = 2, 1, 300*time.Millisecond
276	})
277	chunk := make([]byte, 1<<20)
278	r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(
279		func(ctx context.Context, _ domain.Principal, _, _, _ string, begin func(string) io.Writer) error {
280			w := begin("a.tar.gz")
281			// More than the socket buffers hold: the write blocks once the client
282			// reads nothing, and fails when the idle deadline passes.
283			for range 1024 {
284				if _, err := w.Write(chunk); err != nil {
285					writeErr <- err
286					return err
287				}
288			}
289			writeErr <- nil
290			return nil
291		}).Once()
292
293	// The client takes the headers and reads no body.
294	conn, err := net.Dial("tcp", strings.TrimPrefix(r.srv.URL, "http://"))
295	require.NoError(t, err)
296	t.Cleanup(func() { _ = conn.Close() })
297	_, err = io.WriteString(conn, "GET "+Prefix+tarballPath+" HTTP/1.1\r\nHost: x\r\n\r\n")
298	require.NoError(t, err)
299
300	select {
301	case err := <-writeErr:
302		require.Error(t, err, "the server gave up on a client that reads nothing")
303		assert.Contains(t, err.Error(), "i/o timeout")
304	case <-time.After(15 * time.Second):
305		t.Fatal("the stalled client was never cut")
306	}
307
308	// The slot came back: with a cap of one per client, another download starts.
309	r.api.EXPECT().Tarball(mock.Anything, mock.Anything, "acme", "proj", "main", mock.Anything).RunAndReturn(
310		func(_ context.Context, _ domain.Principal, _, _, _ string, begin func(string) io.Writer) error {
311			_, err := begin("a.tar.gz").Write([]byte("fine"))
312			return err
313		}).Once()
314	require.Eventually(t, func() bool {
315		resp, body := r.do(t, http.MethodGet, tarballPath, "")
316		return resp.StatusCode == http.StatusOK && body == "fine"
317	}, 5*time.Second, 50*time.Millisecond)
318}
319
320// The credentials of a request are the forge's: Basic with any user, Bearer or
321// token; none is anonymous and does not touch the failure limiter.
322func TestCredentialsAndNotFound(t *testing.T) {
323	t.Parallel()
324	r := newRig(t, nil)
325	p := domain.Root()
326	r.auth.EXPECT().Authenticate(mock.Anything, "good").Return(p, nil)
327	r.auth.EXPECT().Authenticate(mock.Anything, "bad").Return(domain.Principal{}, domain.ErrUnauthenticated)
328	// A header that carries no usable token authenticates the empty secret.
329	r.auth.EXPECT().Authenticate(mock.Anything, "").Return(domain.Principal{}, domain.ErrUnauthenticated)
330	r.api.EXPECT().HashAlgorithm(mock.Anything, p, "acme", "proj").Return("sha1", nil)
331	r.api.EXPECT().HashAlgorithm(mock.Anything, domain.Anonymous(), "acme", "proj").Return("sha1", nil)
332
333	path := "/repos/acme/proj/hash-algorithm"
334	for _, h := range []string{"Basic eC1hY2Nlc3MtdG9rZW46Z29vZA==", "Bearer good", "token good", "bearer good"} {
335		resp, body := r.do(t, http.MethodGet, path, h)
336		assert.Equal(t, http.StatusOK, resp.StatusCode, h)
337		assert.JSONEq(t, `{"hash_algorithm":"sha1"}`, body)
338	}
339	resp, _ := r.do(t, http.MethodGet, path, "")
340	assert.Equal(t, http.StatusOK, resp.StatusCode, "anonymous")
341	for _, h := range []string{"Bearer bad", "Digest x", "Basic !!!", "Bearer"} {
342		resp, body := r.do(t, http.MethodGet, path, h)
343		assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, h)
344		assert.Contains(t, body, "Bad credentials")
345	}
346
347	// Not found is GitHub's body, for an unknown endpoint and for a use case that
348	// says so, and a method other than GET and HEAD is 405.
349	const gh = `{"message":"Not Found","documentation_url":"https://docs.github.com/rest"}`
350	for _, p := range []string{"", "/", "/user", "/repos", "/repos/acme", "/repos//proj", "/repos/acme/proj/issues", "/repos/acme/proj/hash-algorithm/x"} {
351		resp, body := r.do(t, http.MethodGet, p, "")
352		assert.Equal(t, http.StatusNotFound, resp.StatusCode, p)
353		assert.JSONEq(t, gh, body, p)
354	}
355	r.api.EXPECT().GetRepo(mock.Anything, domain.Anonymous(), "acme", "gone").Return(nil, domain.ErrNotFound)
356	resp, body := r.do(t, http.MethodGet, "/repos/acme/gone", "")
357	assert.Equal(t, http.StatusNotFound, resp.StatusCode)
358	assert.JSONEq(t, gh, body)
359	req, _ := http.NewRequest(http.MethodPost, r.srv.URL+Prefix+path, nil)
360	post, err := http.DefaultClient.Do(req)
361	require.NoError(t, err)
362	_ = post.Body.Close()
363	assert.Equal(t, http.StatusMethodNotAllowed, post.StatusCode)
364	assert.Equal(t, "GET, HEAD", post.Header.Get("Allow"))
365
366	// A ref with slashes is passed whole.
367	r.api.EXPECT().TarballName(mock.Anything, domain.Anonymous(), "acme", "proj", "feature/x/y").Return("n.tar.gz", nil)
368	resp, _ = r.do(t, http.MethodHead, "/repos/acme/proj/tarball/feature/x/y", "")
369	assert.Equal(t, http.StatusOK, resp.StatusCode)
370}

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 2

Tags 45

↑↓ moveenter openesc close