v0.33.0 cf26365

releases_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"mime"
9	"net/http"
10	"net/http/httptest"
11	"net/url"
12	"strings"
13	"testing"
14	"time"
15
16	"github.com/stretchr/testify/assert"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19
20	"bnema.dev/ermite/internal/domain"
21	"bnema.dev/ermite/internal/ports"
22	"bnema.dev/ermite/internal/ports/mocks"
23)
24
25type releaseFixture struct {
26	srv               *Server
27	svc               *mocks.ReleaseService
28	published, draft  domain.Release
29	asset             domain.ReleaseAsset
30	data              string
31	downloadErr       error
32	failAfterMetadata bool
33	downloads         int
34	tokens            []string
35}
36
37func newReleaseFixture(t *testing.T) *releaseFixture {
38	t.Helper()
39	repo := repoName(t, "proj")
40	f := &releaseFixture{svc: mocks.NewReleaseService(t), data: "0123456789"}
41	f.published = domain.Release{ID: 1, Repo: repo, Tag: "stable/v1", Title: "First release", Notes: "# Notes\n\n**Ready** [bad](javascript:alert(1)) <script>alert(1)</script>\n\n[guide](guide.md)", TargetCommitish: strings.Repeat("a", 40), PublishedAt: fixedNow, Prerelease: true}
42	f.draft = domain.Release{ID: 2, Repo: repo, Tag: "unpublished", Title: "Secret draft", Draft: true, MakeLatest: true}
43	f.asset = domain.ReleaseAsset{ID: 7, ReleaseID: 1, Repo: repo, Name: "build.zip", Size: 10, ContentType: "application/zip", Digest: "sha256:" + strings.Repeat("b", 64)}
44	f.svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, ref ports.ReleaseRef) (domain.Release, error) {
45		if ref.Latest || ref.Tag == f.published.Tag {
46			return f.published, nil
47		}
48		if ref.Tag == f.draft.Tag && p.IsOwner() {
49			return f.draft, nil
50		}
51		return domain.Release{}, domain.ErrNotFound
52	}).Maybe()
53	f.svc.EXPECT().ListReleases(mock.Anything, mock.Anything, repo, 30, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, _ int, token string) (ports.ReleasePage, error) {
54		f.tokens = append(f.tokens, token)
55		rows := []domain.Release{f.published}
56		if p.IsOwner() {
57			rows = append([]domain.Release{f.draft}, rows...)
58		}
59		return ports.ReleasePage{Releases: rows, NextPageToken: "next+/="}, nil
60	}).Maybe()
61	f.svc.EXPECT().ListAssets(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, p domain.Principal, _ domain.RepoName, id int64) ([]domain.ReleaseAsset, error) {
62		if id == f.published.ID {
63			return []domain.ReleaseAsset{f.asset}, nil
64		}
65		if id == f.draft.ID && p.IsOwner() {
66			return nil, nil
67		}
68		return nil, domain.ErrNotFound
69	}).Maybe()
70	f.svc.EXPECT().GetAsset(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.Principal, _ domain.RepoName, ref ports.AssetRef) (domain.ReleaseAsset, error) {
71		if ref.ID == f.asset.ID || (ref.Name == f.asset.Name && (ref.Latest || ref.Tag == f.published.Tag)) {
72			return f.asset, nil
73		}
74		return domain.ReleaseAsset{}, domain.ErrNotFound
75	}).Maybe()
76	f.svc.EXPECT().DownloadAsset(mock.Anything, mock.Anything, repo, ports.AssetRef{ID: 7}, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName, _ ports.AssetRef, offset, length int64, send func(ports.AssetChunk) error) error {
77		f.downloads++
78		deadline, ok := ctx.Deadline()
79		require.True(t, ok)
80		assert.InDelta(t, 3600, time.Until(deadline).Seconds(), 2)
81		if f.downloadErr != nil && !f.failAfterMetadata {
82			return f.downloadErr
83		}
84		if err := send(ports.AssetChunk{Asset: &f.asset}); err != nil {
85			return err
86		}
87		if f.downloadErr != nil {
88			return f.downloadErr
89		}
90		return send(ports.AssetChunk{Data: []byte(f.data[offset : offset+length])})
91	}).Maybe()
92	browse := mocks.NewBrowseService(t)
93	browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
94	f.srv = newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Releases: f.svc, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{}, Now: func() time.Time { return fixedNow }})
95	return f
96}
97func (f *releaseFixture) request(method, path, cookie, byteRange string) *httptest.ResponseRecorder {
98	r := httptest.NewRequest(method, path, nil)
99	if cookie != "" {
100		r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
101	}
102	if byteRange != "" {
103		r.Header.Set("Range", byteRange)
104	}
105	w := httptest.NewRecorder()
106	f.srv.ServeHTTP(w, r)
107	return w
108}
109func TestReleasePages(t *testing.T) {
110	f := newReleaseFixture(t)
111	for _, path := range []string{"/proj/releases", "/proj/releases/"} {
112		w := f.request("GET", path, "", "")
113		require.Equal(t, 200, w.Code)
114		body := w.Body.String()
115		assert.Contains(t, body, "First release")
116		assert.NotContains(t, body, "Secret draft")
117		assert.Contains(t, body, ">Latest</span>")
118		assert.Contains(t, body, ">Pre-release</span>")
119		assert.Contains(t, body, `href="/proj/releases/tag/stable/v1"`)
120		assert.Contains(t, body, "2026-10-12")
121		assert.Contains(t, body, `<td>1</td>`)
122		assert.Contains(t, body, `?page=next%2B%2F%3D`)
123		assert.Contains(t, body, `aria-keyshortcuts="l"`)
124		assert.Contains(t, body, "view=releases")
125	}
126	owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
127	require.Equal(t, 200, owner.Code)
128	assert.Contains(t, owner.Body.String(), "Secret draft")
129	assert.Contains(t, owner.Body.String(), ">Draft</span>")
130	assert.Contains(t, owner.Header().Get("Cache-Control"), "no-store")
131	assert.Equal(t, []string{"", "", "cursor+/="}, f.tokens)
132	// The pin on the draft must not become a computed latest badge.
133	assert.Equal(t, 1, strings.Count(owner.Body.String(), ">Latest</span>"))
134	w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
135	require.Equal(t, 200, w.Code)
136	body := w.Body.String()
137	for _, text := range []string{"<strong>Ready</strong>", "build.zip", humanSize(10), "application/zip", `href="/proj/releases/assets/7"`, `href="/proj/tree/stable/v1"`, `href="/proj/commit/` + strings.Repeat("a", 40) + `"`, "view=release"} {
138		assert.Contains(t, body, text)
139	}
140	assert.NotContains(t, body, "<script>alert")
141	assert.NotContains(t, body, `href="javascript:`)
142	assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
143	for _, path := range []string{"/proj/releases/tag/unknown", "/proj/releases/tag/unpublished"} {
144		assert.Equal(t, 404, f.request("GET", path, "", "").Code)
145	}
146	assert.Equal(t, 200, f.request("GET", "/proj/releases/tag/unpublished", "tok", "").Code)
147	latest := f.request("GET", "/proj/releases/latest", "", "")
148	assert.Equal(t, 303, latest.Code)
149	assert.Equal(t, "/proj/releases/tag/stable/v1", latest.Header().Get("Location"))
150}
151func TestReleaseDownloads(t *testing.T) {
152	f := newReleaseFixture(t)
153	// A simple tag uses the stable URL; slash tags use the page's asset-id link.
154	f.published.Tag = "v1"
155	for _, path := range []string{"/proj/releases/download/v1/build.zip", "/proj/releases/latest/download/build.zip", "/proj/releases/assets/7"} {
156		for _, tc := range []struct {
157			rangeValue, body, cr string
158			status               int
159		}{
160			{"", "0123456789", "", 200}, {"bytes=2-5", "2345", "bytes 2-5/10", 206}, {"bytes=7-", "789", "bytes 7-9/10", 206}, {"bytes=-3", "789", "bytes 7-9/10", 206}, {"bytes=8-99", "89", "bytes 8-9/10", 206}, {"bytes=0-0,2-3", "0123456789", "", 200}, {"bytes=10-", "", "bytes */10", 416},
161		} {
162			w := f.request("GET", path, "", tc.rangeValue)
163			require.Equal(t, tc.status, w.Code, path+" "+tc.rangeValue)
164			assert.Equal(t, tc.body, w.Body.String())
165			assert.Equal(t, tc.cr, w.Header().Get("Content-Range"))
166			assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
167			assert.Equal(t, `"sha256-`+strings.Repeat("b", 64)+`"`, w.Header().Get("ETag"))
168			assert.Equal(t, "application/zip", w.Header().Get("Content-Type"))
169			assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
170			assert.Equal(t, rawCSP, w.Header().Get("Content-Security-Policy"))
171			_, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
172			require.NoError(t, err)
173			assert.Equal(t, "build.zip", params["filename"])
174			assert.Equal(t, itoa(len(tc.body)), w.Header().Get("Content-Length"))
175		}
176		before := f.downloads
177		w := f.request("HEAD", path, "", "bytes=1-2")
178		assert.Equal(t, 200, w.Code)
179		assert.Empty(t, w.Header().Get("Content-Range"))
180		assert.Empty(t, w.Body.String())
181		assert.Equal(t, "10", w.Header().Get("Content-Length"))
182		assert.Equal(t, before, f.downloads)
183	}
184	for _, path := range []string{"/proj/releases/assets/0", "/proj/releases/assets/nope", "/proj/releases/assets/99", "/proj/releases/download/missing/build.zip", "/proj/releases/latest/download/nope"} {
185		assert.Equal(t, 404, f.request("GET", path, "", "").Code)
186	}
187	r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
188	r.Header.Set("Range", "bytes=1-2")
189	r.Header.Set("If-Range", `"old"`)
190	w := httptest.NewRecorder()
191	f.srv.ServeHTTP(w, r)
192	assert.Equal(t, 200, w.Code)
193	assert.Equal(t, f.data, w.Body.String())
194}
195func TestReleaseDownloadIfRange(t *testing.T) {
196	f := newReleaseFixture(t)
197	etag := `"sha256-` + strings.Repeat("b", 64) + `"`
198	for _, tc := range []struct {
199		name, ifRange, body, cr string
200		status                  int
201	}{
202		{"matching", etag, "2345", "bytes 2-5/10", 206},
203		{"stale", `"old"`, "0123456789", "", 200},
204		{"date", "Wed, 21 Oct 2026 12:00:00 GMT", "0123456789", "", 200},
205	} {
206		r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
207		r.Header.Set("Range", "bytes=2-5")
208		r.Header.Set("If-Range", tc.ifRange)
209		w := httptest.NewRecorder()
210		f.srv.ServeHTTP(w, r)
211		assert.Equal(t, tc.status, w.Code, tc.name)
212		assert.Equal(t, tc.body, w.Body.String(), tc.name)
213		assert.Equal(t, tc.cr, w.Header().Get("Content-Range"), tc.name)
214	}
215}
216
217// A tag with a slash is one path segment once escaped: the download route must
218// match it and resolve the tag with the slash.
219func TestReleaseDownloadSlashTag(t *testing.T) {
220	f := newReleaseFixture(t) // the published tag is "stable/v1"
221	w := f.request("GET", "/proj/releases/download/"+url.PathEscape(f.published.Tag)+"/build.zip", "", "")
222	require.Equal(t, 200, w.Code)
223	assert.Equal(t, f.data, w.Body.String())
224}
225
226func TestReleaseDownloadFailures(t *testing.T) {
227	f := newReleaseFixture(t)
228	f.downloadErr = errors.New("blob unavailable")
229	w := f.request("GET", "/proj/releases/assets/7", "", "bytes=1-2")
230	assert.Equal(t, 404, w.Code)
231	assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
232	for _, key := range []string{"Content-Disposition", "Content-Range", "Accept-Ranges"} {
233		assert.Empty(t, w.Header().Get(key))
234	}
235	f.failAfterMetadata = true
236	assert.PanicsWithValue(t, http.ErrAbortHandler, func() { f.request("GET", "/proj/releases/assets/7", "", "") })
237}
238func TestReleaseAttachmentHeaders(t *testing.T) {
239	for _, name := range []string{"résumé.zip", "build.zip", "evil\r\nX-Injected: true.html", `<img src=x onerror=alert(1)>.html`} {
240		h := http.Header{}
241		releaseHeaders(h, domain.ReleaseAsset{Name: name, ContentType: "text/html", Digest: "sha256:abcd", Size: 12})
242		assert.Equal(t, "application/octet-stream", h.Get("Content-Type"))
243		assert.NotContains(t, h.Get("Content-Disposition"), "\r")
244		assert.NotContains(t, h.Get("Content-Disposition"), "\n")
245		media, params, err := mime.ParseMediaType(h.Get("Content-Disposition"))
246		require.NoError(t, err)
247		assert.Equal(t, "attachment", media)
248		assert.Equal(t, attachmentName(name), params["filename"])
249	}
250}
251func TestReleasesDisabled(t *testing.T) {
252	s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
253	for _, path := range []string{"/proj/releases", "/proj/releases/latest", "/proj/releases/assets/7"} {
254		w := httptest.NewRecorder()
255		s.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
256		assert.Equal(t, 404, w.Code)
257	}
258}
259
260func TestReleaseEmptyDownloads(t *testing.T) {
261	f := newReleaseFixture(t)
262	f.asset.Size = 0
263	f.data = ""
264	w := f.request("GET", "/proj/releases/assets/7", "", "")
265	assert.Equal(t, 200, w.Code)
266	assert.Equal(t, "0", w.Header().Get("Content-Length"))
267	assert.Empty(t, w.Body.String())
268	w = f.request("GET", "/proj/releases/assets/7", "", "bytes=0-")
269	assert.Equal(t, 416, w.Code)
270	assert.Equal(t, "bytes */0", w.Header().Get("Content-Range"))
271}
272
273func TestReleaseLatestMissing(t *testing.T) {
274	svc := mocks.NewReleaseService(t)
275	repo := repoName(t, "proj")
276	svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound)
277	srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
278	w := httptest.NewRecorder()
279	srv.ServeHTTP(w, httptest.NewRequest("GET", "/proj/releases/latest", nil))
280	assert.Equal(t, 404, w.Code)
281	assert.Empty(t, w.Header().Get("Location"))
282}
283
284func TestReleaseAssetNameEscapedInPage(t *testing.T) {
285	f := newReleaseFixture(t)
286	f.asset.Name = `<img src=x onerror=alert(1)>`
287	w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
288	require.Equal(t, 200, w.Code)
289	assert.NotContains(t, w.Body.String(), f.asset.Name)
290	assert.Contains(t, w.Body.String(), "&lt;img src=x onerror=alert(1)&gt;")
291}
292
293func TestReleaseRowFallbacks(t *testing.T) {
294	row := releaseRow("proj", domain.Release{ID: 1, Tag: "v1", Draft: true, PublishedAt: fixedNow}, 0)
295	assert.Equal(t, "v1", row.Title)
296	assert.Equal(t, "draft", row.Published)
297	assert.False(t, row.Latest)
298}

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