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}