v0.35.0 e86dbd6

releases_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package githubapi
4
5import (
6	"context"
7	"encoding/json"
8	"errors"
9	"fmt"
10	"io"
11	"net/http"
12	"net/http/httptest"
13	"net/url"
14	"os"
15	"strings"
16	"testing"
17	"time"
18
19	"bnema.dev/ermite/internal/domain"
20	"bnema.dev/ermite/internal/ports"
21	"bnema.dev/ermite/internal/ports/mocks"
22	"github.com/stretchr/testify/mock"
23	"github.com/stretchr/testify/require"
24)
25
26const releasePath = "/repos/acme/proj/releases"
27const releaseAssetPath = releasePath + "/assets/7"
28const releaseUploadPath = "/uploads/repos/acme/proj/releases/4/assets"
29
30func releaseRequest(t *testing.T, h *Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
31	t.Helper()
32	req := httptest.NewRequest(method, Prefix+path, strings.NewReader(body))
33	for k, v := range headers {
34		req.Header.Set(k, v)
35	}
36	w := httptest.NewRecorder()
37	h.ServeHTTP(w, req)
38	return w
39}
40func TestReleaseRoutes(t *testing.T) {
41	cases := []struct{ path, kind, ref, allow string }{
42		{releasePath, "release-list", "", "GET, HEAD, POST"},
43		{releasePath + "/latest", "release-latest", "", "GET, HEAD"},
44		{releasePath + "/tags/v1/patch", "release-tag", "v1/patch", "GET, HEAD"},
45		{releasePath + "/4", "release-id", "4", "GET, HEAD, PATCH, DELETE"},
46		{releasePath + "/4/assets", "release-assets", "4", "GET, HEAD"},
47		{releaseAssetPath, "release-asset", "7", "GET, HEAD, PATCH, DELETE"},
48		{releaseUploadPath, "release-upload", "4", "POST"},
49	}
50	for _, tc := range cases {
51		t.Run(tc.kind, func(t *testing.T) {
52			kind, owner, repo, ref, ok := route(tc.path)
53			require.True(t, ok)
54			require.Equal(t, tc.kind, kind)
55			require.Equal(t, "acme", owner)
56			require.Equal(t, "proj", repo)
57			require.Equal(t, tc.ref, ref)
58			api := mocks.NewGitHubReleaseAPI(t)
59			h := New(Config{Releases: api})
60			for _, method := range []string{"GET", "HEAD", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"} {
61				if methodAllowed(method, tc.allow) {
62					continue
63				}
64				w := releaseRequest(t, h, method, tc.path, "", nil)
65				require.Equal(t, 405, w.Code)
66				require.Equal(t, tc.allow, w.Header().Get("Allow"))
67			}
68			w := releaseRequest(t, New(Config{}), "GET", tc.path, "", nil)
69			require.Equal(t, 404, w.Code)
70			require.Equal(t, notFoundBody, w.Body.String())
71		})
72	}
73	for _, path := range []string{releasePath + "/0", releasePath + "/-1", releasePath + "/01", releasePath + "/9223372036854775808", releasePath + "/tags/", releasePath + "/4/extra", releaseUploadPath + "/extra", "/uploads" + releasePath} {
74		_, _, _, _, ok := route(path)
75		require.False(t, ok, path)
76	}
77}
78
79// GoReleaser's go-github enterprise client appends api/v3 to the documented
80// custom API URL. The alias must dispatch writes through the same auth/service.
81func TestReleaseEnterpriseBase(t *testing.T) {
82	api := mocks.NewGitHubReleaseAPI(t)
83	h := New(Config{Releases: api})
84	body := []byte(`{"tag_name":"v1"}`)
85	api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", body).Return([]byte(`{"id":4}`), nil).Once()
86	w := releaseRequest(t, h, "POST", "/api/v3"+releasePath, string(body), nil)
87	require.Equal(t, 201, w.Code)
88	require.JSONEq(t, `{"id":4}`, w.Body.String())
89	for _, path := range []string{releasePath, releasePath + "/4", releasePath + "/4/assets", releaseAssetPath, releasePath + "/latest", releasePath + "/tags/v1/patch"} {
90		kind, owner, repo, ref, ok := route(path)
91		aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK := route("/api/v3" + path)
92		require.Equal(t, []any{kind, owner, repo, ref, ok}, []any{aliasKind, aliasOwner, aliasRepo, aliasRef, aliasOK})
93	}
94	_, _, _, _, ok := route("/api/v3repos/acme/proj/releases")
95	require.False(t, ok)
96}
97
98func TestReleaseDispatch(t *testing.T) {
99	for _, method := range []string{"GET", "HEAD"} {
100		t.Run(method, func(t *testing.T) {
101			api := mocks.NewGitHubReleaseAPI(t)
102			h := New(Config{Releases: api})
103			api.EXPECT().ListReleases(mock.Anything, domain.Anonymous(), "acme", "proj", 2, 10).Return([]byte(`[]`), `<next>; rel="next"`, nil).Once()
104			w := releaseRequest(t, h, method, releasePath+"?page=2&per_page=10", "", nil)
105			require.Equal(t, 200, w.Code)
106			require.Equal(t, `<next>; rel="next"`, w.Header().Get("Link"))
107			require.Equal(t, "2", w.Header().Get("Content-Length"))
108			if method == "HEAD" {
109				require.Empty(t, w.Body.String())
110			} else {
111				require.Equal(t, "[]", w.Body.String())
112			}
113			for _, tc := range []struct {
114				path string
115				ref  ports.ReleaseRef
116			}{{releasePath + "/latest", ports.ReleaseRef{Latest: true}}, {releasePath + "/tags/v1/x", ports.ReleaseRef{Tag: "v1/x"}}, {releasePath + "/4", ports.ReleaseRef{ID: 4}}} {
117				api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", tc.ref).Return([]byte(`{"id":4}`), nil).Once()
118				w = releaseRequest(t, h, method, tc.path, "", nil)
119				require.Equal(t, 200, w.Code)
120			}
121			api.EXPECT().ListAssets(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), 0, 0).Return([]byte(`[]`), "asset-link", nil).Once()
122			w = releaseRequest(t, h, method, releasePath+"/4/assets", "", nil)
123			require.Equal(t, 200, w.Code)
124			require.Equal(t, "asset-link", w.Header().Get("Link"))
125			api.EXPECT().GetAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return([]byte(`{"id":7}`), nil).Once()
126			w = releaseRequest(t, h, method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json"})
127			require.Equal(t, 200, w.Code)
128		})
129	}
130	api := mocks.NewGitHubReleaseAPI(t)
131	h := New(Config{Releases: api})
132	b := []byte(`{"tag_name":"v1"}`)
133	api.EXPECT().CreateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", b).Return([]byte(`{"id":4}`), nil).Once()
134	w := releaseRequest(t, h, "POST", releasePath, string(b), nil)
135	require.Equal(t, 201, w.Code)
136	require.Equal(t, `{"id":4}`, w.Body.String())
137	api.EXPECT().UpdateRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), b).Return([]byte(`{"id":4}`), nil).Once()
138	w = releaseRequest(t, h, "PATCH", releasePath+"/4", string(b), nil)
139	require.Equal(t, 200, w.Code)
140	api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), b).Return([]byte(`{"id":7}`), nil).Once()
141	w = releaseRequest(t, h, "PATCH", releaseAssetPath, string(b), nil)
142	require.Equal(t, 200, w.Code)
143	api.EXPECT().DeleteRelease(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4)).Return(nil).Once()
144	w = releaseRequest(t, h, "DELETE", releasePath+"/4", "", nil)
145	require.Equal(t, 204, w.Code)
146	require.Empty(t, w.Body.String())
147	api.EXPECT().DeleteAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(nil).Once()
148	w = releaseRequest(t, h, "DELETE", releaseAssetPath, "", nil)
149	require.Equal(t, 204, w.Code)
150	require.Empty(t, w.Body.String())
151}
152func TestReleaseUpload(t *testing.T) {
153	api := mocks.NewGitHubReleaseAPI(t)
154	h := New(Config{Releases: api})
155	req := httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a", strings.NewReader("abc"))
156	req.ContentLength = -1
157	w := httptest.NewRecorder()
158	h.ServeHTTP(w, req)
159	require.Equal(t, 411, w.Code)
160	require.Equal(t, `{"message":"Length Required"}`, w.Body.String())
161	// HTTP distinguishes an omitted length from an explicitly empty asset.
162	w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", nil)
163	require.Equal(t, 411, w.Code)
164	api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "empty", "", "application/octet-stream", int64(0), mock.Anything).Return([]byte(`{"id":8}`), nil).Once()
165	w = releaseRequest(t, h, "POST", releaseUploadPath+"?name=empty", "", map[string]string{"Content-Length": "0"})
166	require.Equal(t, 201, w.Code)
167	w = releaseRequest(t, h, "POST", releaseUploadPath, "abc", nil)
168	require.Equal(t, 422, w.Code)
169	require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String())
170	for _, ct := range []string{"", "text/plain"} {
171		expectedType := ct
172		if ct == "" {
173			expectedType = "application/octet-stream"
174		}
175		api.EXPECT().UploadAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(4), "a b", "label", expectedType, int64(3), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _, _ string, _ int64, _, _, _ string, size int64, r io.Reader) ([]byte, error) {
176			got, err := io.ReadAll(r)
177			require.NoError(t, err)
178			require.Equal(t, "abc", string(got))
179			return []byte(`{"id":7}`), nil
180		}).Once()
181		req = httptest.NewRequest("POST", Prefix+releaseUploadPath+"?name=a%20b&label=label", strings.NewReader("abcdef"))
182		req.ContentLength = 3
183		req.Header.Set("Content-Type", ct)
184		w = httptest.NewRecorder()
185		h.ServeHTTP(w, req)
186		require.Equal(t, 201, w.Code)
187		require.Equal(t, `{"id":7}`, w.Body.String())
188	}
189	// The real response template expands to precisely the upload route used above.
190	b, err := os.ReadFile("testdata/releases/release.json")
191	require.NoError(t, err)
192	var object struct {
193		UploadURL string `json:"upload_url"`
194	}
195	require.NoError(t, json.Unmarshal(b, &object))
196	expanded := strings.Split(object.UploadURL, "{")[0] + "?name=" + url.QueryEscape("archive.tar.gz")
197	u, err := url.Parse(expanded)
198	require.NoError(t, err)
199	kind, owner, repo, ref, ok := route(strings.TrimPrefix(u.Path, Prefix))
200	require.True(t, ok)
201	require.Equal(t, "release-upload", kind)
202	require.Equal(t, "acme", owner)
203	require.Equal(t, "releases", repo)
204	require.Equal(t, "1", ref)
205}
206func TestReleaseBodyLimit(t *testing.T) {
207	h := New(Config{Releases: mocks.NewGitHubReleaseAPI(t)})
208	for _, tc := range []struct{ method, path string }{{"POST", releasePath}, {"PATCH", releasePath + "/4"}, {"PATCH", releaseAssetPath}} {
209		w := releaseRequest(t, h, tc.method, tc.path, strings.Repeat("x", (1<<20)+1), nil)
210		require.Equal(t, 422, w.Code)
211		require.Equal(t, `{"message":"Body too large"}`, w.Body.String())
212	}
213}
214func TestReleaseErrors(t *testing.T) {
215	rn, err := domain.ParseRepoName("proj")
216	require.NoError(t, err)
217	job := domain.RepoTokenPrincipal(domain.CIRepoToken{Repo: rn, Permissions: map[string]string{domain.PermContents: domain.PermissionRead}})
218	cases := []struct {
219		name, kind string
220		p          domain.Principal
221		err        error
222		status     int
223		body       string
224	}{
225		{"hidden draft", "release-id", domain.Anonymous(), domain.ErrNotFound, 404, notFoundBody},
226		{"anonymous write", "release-list", domain.Anonymous(), domain.ErrUnauthenticated, 401, `{"message":"Bad credentials"}`},
227		{"job read only", "release-list", job, domain.ErrForbidden, 403, `{"message":"Resource not accessible by integration"}`},
228		{"stored token", "release-list", domain.Root(), domain.ErrForbidden, 403, `{"message":"Forbidden"}`},
229		{"duplicate release", "release-list", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}`},
230		{"duplicate upload", "release-upload", domain.Root(), domain.ErrAlreadyExists, 422, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"already_exists","field":"name"}],"documentation_url":"https://docs.github.com/rest/releases/assets#upload-a-release-asset"}`},
231		{"invalid field", "release-list", domain.Root(), fmt.Errorf("%w: make_latest", domain.ErrInvalid), 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"make_latest"}]}`},
232		{"invalid", "release-list", domain.Root(), domain.ErrInvalid, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid"}]}`},
233		{"tag missing", "release-id", domain.Root(), domain.ErrPreconditionFailed, 422, `{"message":"Validation Failed","errors":[{"resource":"Release","code":"invalid","field":"target_commitish"}]}`},
234		{"size", "release-upload", domain.Root(), fmt.Errorf("%w: asset size limit", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: asset size limit"}`},
235		{"count", "release-upload", domain.Root(), fmt.Errorf("%w: release asset count limit is 1000", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release asset count limit is 1000"}`},
236		{"quota", "release-upload", domain.Root(), fmt.Errorf("%w: release quota is 20 GiB", domain.ErrResourceExhausted), 422, `{"message":"resource exhausted: release quota is 20 GiB"}`},
237		{"upload cap", "release-upload", domain.Root(), fmt.Errorf("%w: too many uploads in progress, retry", domain.ErrResourceExhausted), 429, `{"message":"resource exhausted: too many uploads in progress, retry"}`},
238		{"internal", "release-id", domain.Root(), errors.New("secret internal error"), 500, `{"message":"Server Error"}`},
239	}
240	for _, tc := range cases {
241		t.Run(tc.name, func(t *testing.T) {
242			w := httptest.NewRecorder()
243			New(Config{}).failRelease(t.Context(), w, tc.p, tc.err, tc.kind, "")
244			require.Equal(t, tc.status, w.Code)
245			require.Equal(t, tc.body, w.Body.String())
246			if tc.status == 429 {
247				require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
248			}
249		})
250	}
251	// Verify the route actually forwards a use-case visibility failure unchanged.
252	api := mocks.NewGitHubReleaseAPI(t)
253	api.EXPECT().GetRelease(mock.Anything, domain.Anonymous(), "acme", "proj", ports.ReleaseRef{ID: 4}).Return(nil, domain.ErrNotFound).Once()
254	w := releaseRequest(t, New(Config{Releases: api}), "GET", releasePath+"/4", "", nil)
255	require.Equal(t, 404, w.Code)
256	require.Equal(t, notFoundBody, w.Body.String())
257}
258func downloadAsset(t *testing.T) domain.ReleaseAsset {
259	t.Helper()
260	rn, err := domain.ParseRepoName("proj")
261	require.NoError(t, err)
262	return domain.ReleaseAsset{ID: 7, ReleaseID: 4, Repo: rn, Name: "archive.tar.gz", ContentType: "application/gzip", Size: 10, Digest: "sha256:abcdef", UpdatedAt: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC)}
263}
264func TestReleaseDownload(t *testing.T) {
265	cases := []struct {
266		name, method, rang, etag string
267		status                   int
268		offset, length           int64
269		body, contentRange       string
270	}{
271		{"whole", "GET", "", "", 200, 0, 10, "0123456789", ""},
272		{"bounded", "GET", "bytes=2-5", "", 206, 2, 4, "2345", "bytes 2-5/10"},
273		{"open ended", "GET", "bytes=7-", "", 206, 7, 3, "789", "bytes 7-9/10"},
274		{"suffix", "GET", "bytes=-3", "", 206, 7, 3, "789", "bytes 7-9/10"},
275		{"large suffix", "GET", "bytes=-30", "", 206, 0, 10, "0123456789", "bytes 0-9/10"},
276		{"clamp", "GET", "bytes=7-100", "", 206, 7, 3, "789", "bytes 7-9/10"},
277		{"multiple ignored", "GET", "bytes=0-1,3-4", "", 200, 0, 10, "0123456789", ""},
278		{"unknown unit ignored", "GET", "items=1-2", "", 200, 0, 10, "0123456789", ""},
279		{"out of bounds", "GET", "bytes=10-", "", 416, 0, 0, "", "bytes */10"},
280		{"reverse", "GET", "bytes=4-2", "", 416, 0, 0, "", "bytes */10"},
281		{"invalid", "GET", "bytes=garbage", "", 416, 0, 0, "", "bytes */10"},
282		{"zero suffix", "GET", "bytes=-0", "", 416, 0, 0, "", "bytes */10"},
283		{"conditional", "GET", "", `"sha256-abcdef"`, 304, 0, 0, "", ""},
284		{"head", "HEAD", "", "", 200, 0, 10, "", ""},
285		{"head ignores range", "HEAD", "bytes=2-5", "", 200, 0, 10, "", ""},
286	}
287	for _, tc := range cases {
288		t.Run(tc.name, func(t *testing.T) {
289			api := mocks.NewGitHubReleaseAPI(t)
290			dl := mocks.NewReleaseService(t)
291			a := downloadAsset(t)
292			api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
293			if tc.method == "GET" && (tc.status == 200 || tc.status == 206) {
294				dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
295					require.NoError(t, send(ports.AssetChunk{Asset: &a}))
296					return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
297				}).Once()
298			}
299			w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), tc.method, releaseAssetPath, "", map[string]string{"Accept": "application/vnd.github+json, application/octet-stream", "Range": tc.rang, "If-None-Match": tc.etag})
300			require.Equal(t, tc.status, w.Code)
301			require.Equal(t, tc.body, w.Body.String())
302			require.Equal(t, tc.contentRange, w.Header().Get("Content-Range"))
303			require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
304			require.Equal(t, `attachment; filename="archive.tar.gz"`, w.Header().Get("Content-Disposition"))
305			require.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
306			require.Equal(t, "sandbox", w.Header().Get("Content-Security-Policy"))
307			require.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
308			require.Equal(t, `"sha256-abcdef"`, w.Header().Get("ETag"))
309			require.Equal(t, "Wed, 21 Oct 2026 12:00:00 GMT", w.Header().Get("Last-Modified"))
310			if tc.status == 200 || tc.status == 206 {
311				require.Equal(t, fmt.Sprint(tc.length), w.Header().Get("Content-Length"))
312			}
313		})
314	}
315}
316func TestReleaseDownloadSafety(t *testing.T) {
317	for _, ct := range []string{"text/html", "text/html; charset=utf-8", "APPLICATION/JAVASCRIPT", "image/svg+xml"} {
318		a := downloadAsset(t)
319		a.ContentType = ct
320		a.Name = "é space.txt"
321		w := httptest.NewRecorder()
322		setReleaseDownloadHeaders(w, a)
323		require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type"))
324		require.Equal(t, `attachment; filename="_ space.txt"; filename*=UTF-8''%C3%A9%20space.txt`, w.Header().Get("Content-Disposition"))
325	}
326	require.Equal(t, `attachment; filename="evil.txt"`, releaseContentDisposition("\r\n/\\\"evil.txt"))
327}
328func TestReleaseDownloadFailures(t *testing.T) {
329	for _, started := range []bool{false, true} {
330		t.Run(fmt.Sprint(started), func(t *testing.T) {
331			api := mocks.NewGitHubReleaseAPI(t)
332			dl := mocks.NewReleaseService(t)
333			a := downloadAsset(t)
334			api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
335			dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), int64(10), mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, _, _ int64, send func(ports.AssetChunk) error) error {
336				if started {
337					require.NoError(t, send(ports.AssetChunk{Data: []byte("x")}))
338				}
339				return domain.ErrNotFound
340			}).Once()
341			h := New(Config{Releases: api, ReleaseDownload: dl})
342			run := func() {
343				w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
344				require.Equal(t, 404, w.Code)
345				require.Equal(t, notFoundBody, w.Body.String())
346				require.Empty(t, w.Header().Get("Content-Length"))
347				require.Empty(t, w.Header().Get("Content-Range"))
348			}
349			if started {
350				require.PanicsWithValue(t, http.ErrAbortHandler, run)
351			} else {
352				run()
353			}
354			require.Empty(t, h.archives)
355			require.Empty(t, h.clients)
356		})
357	}
358}
359func TestReleaseDownloadSlots(t *testing.T) {
360	api := mocks.NewGitHubReleaseAPI(t)
361	a := downloadAsset(t)
362	api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Times(2)
363	h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1, MaxArchivesPerClient: 1})
364	release, ok := h.acquire(httptest.NewRecorder(), "192.0.2.1")
365	require.True(t, ok)
366	defer release()
367	w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
368	require.Equal(t, 429, w.Code)
369	require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
370	require.Empty(t, w.Header().Get("Content-Length"))
371	require.Contains(t, w.Body.String(), "Too many archives")
372	// A HEAD uses metadata, even when every download slot is taken.
373	w = releaseRequest(t, h, "HEAD", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
374	require.Equal(t, 200, w.Code)
375}
376
377func TestReleaseEmptyAndMissingDownload(t *testing.T) {
378	for _, tc := range []struct {
379		name string
380		size int64
381		err  error
382		want int
383	}{
384		{"empty", 0, nil, 200},
385		{"missing empty", 0, domain.ErrNotFound, 404},
386		{"store range", 10, domain.ErrOutOfRange, 416},
387	} {
388		t.Run(tc.name, func(t *testing.T) {
389			api := mocks.NewGitHubReleaseAPI(t)
390			dl := mocks.NewReleaseService(t)
391			a := downloadAsset(t)
392			a.Size = tc.size
393			api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
394			dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, int64(0), tc.size, mock.Anything).Return(tc.err).Once()
395			w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
396			require.Equal(t, tc.want, w.Code)
397			switch tc.want {
398			case 200:
399				require.Equal(t, "0", w.Header().Get("Content-Length"))
400				require.Empty(t, w.Body.String())
401			case 416:
402				require.Equal(t, "bytes */10", w.Header().Get("Content-Range"))
403			case 404:
404				require.Equal(t, notFoundBody, w.Body.String())
405			}
406		})
407	}
408	// Missing the stream service leaves JSON available but bytes disabled.
409	api := mocks.NewGitHubReleaseAPI(t)
410	w := releaseRequest(t, New(Config{Releases: api}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
411	require.Equal(t, 404, w.Code)
412	require.Equal(t, notFoundBody, w.Body.String())
413}
414
415func TestReleaseDownloadGlobalSlot(t *testing.T) {
416	api := mocks.NewGitHubReleaseAPI(t)
417	a := downloadAsset(t)
418	api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
419	h := New(Config{Releases: api, ReleaseDownload: mocks.NewReleaseService(t), MaxArchives: 1})
420	release, ok := h.acquire(httptest.NewRecorder(), "another-client")
421	require.True(t, ok)
422	defer release()
423	w := releaseRequest(t, h, "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream"})
424	require.Equal(t, 503, w.Code)
425	require.Equal(t, retryAfter, w.Header().Get("Retry-After"))
426	require.Empty(t, w.Header().Get("Content-Length"))
427	require.Contains(t, w.Body.String(), "Server busy")
428	require.NotContains(t, h.clients, "192.0.2.1")
429}
430
431func TestReleaseDownloadIfRange(t *testing.T) {
432	for _, tc := range []struct {
433		name, ifRange string
434		status        int
435		offset        int64
436		length        int64
437		body, cr      string
438	}{
439		{"matching", `"sha256-abcdef"`, 206, 2, 4, "2345", "bytes 2-5/10"},
440		{"stale", `"sha256-other"`, 200, 0, 10, "0123456789", ""},
441		{"date", "Wed, 21 Oct 2026 12:00:00 GMT", 200, 0, 10, "0123456789", ""},
442	} {
443		t.Run(tc.name, func(t *testing.T) {
444			api := mocks.NewGitHubReleaseAPI(t)
445			dl := mocks.NewReleaseService(t)
446			a := downloadAsset(t)
447			api.EXPECT().AssetByID(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7)).Return(a, nil).Once()
448			dl.EXPECT().DownloadAsset(mock.Anything, domain.Anonymous(), a.Repo, ports.AssetRef{ID: 7}, tc.offset, tc.length, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
449				require.NoError(t, send(ports.AssetChunk{Asset: &a}))
450				return send(ports.AssetChunk{Data: []byte("0123456789")[offset : offset+length]})
451			}).Once()
452			w := releaseRequest(t, New(Config{Releases: api, ReleaseDownload: dl}), "GET", releaseAssetPath, "", map[string]string{"Accept": "application/octet-stream", "Range": "bytes=2-5", "If-Range": tc.ifRange})
453			require.Equal(t, tc.status, w.Code)
454			require.Equal(t, tc.body, w.Body.String())
455			require.Equal(t, tc.cr, w.Header().Get("Content-Range"))
456		})
457	}
458}
459
460func TestReleaseAssetValidationResource(t *testing.T) {
461	api := mocks.NewGitHubReleaseAPI(t)
462	api.EXPECT().UpdateAsset(mock.Anything, domain.Anonymous(), "acme", "proj", int64(7), mock.Anything).Return(nil, fmt.Errorf("%w: asset name is empty", domain.ErrInvalid)).Once()
463	w := releaseRequest(t, New(Config{Releases: api}), "PATCH", releaseAssetPath, `{"name":""}`, nil)
464	require.Equal(t, 422, w.Code)
465	require.Equal(t, `{"message":"Validation Failed","errors":[{"resource":"ReleaseAsset","code":"invalid","field":"name"}]}`, w.Body.String())
466}

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