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)\n\n```sh\ncurl https://example.com\n```", 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, `data-key="g r"`)
124 assert.NotContains(t, body, `aria-keyshortcuts="g r"`)
125 assert.Contains(t, body, "view=releases")
126 }
127 owner := f.request("GET", "/proj/releases?page=cursor%2B%2F%3D", "tok", "")
128 require.Equal(t, 200, owner.Code)
129 assert.Contains(t, owner.Body.String(), "Secret draft")
130 assert.Contains(t, owner.Body.String(), ">Draft</span>")
131 assert.Contains(t, owner.Header().Get("Cache-Control"), "no-store")
132 assert.Equal(t, []string{"", "", "cursor+/="}, f.tokens)
133 // The pin on the draft must not become a computed latest badge.
134 assert.Equal(t, 1, strings.Count(owner.Body.String(), ">Latest</span>"))
135 w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
136 require.Equal(t, 200, w.Code)
137 body := w.Body.String()
138 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"} {
139 assert.Contains(t, body, text)
140 }
141 notes := strings.SplitN(body, `<article class="mr-desc prose">`, 2)
142 require.Len(t, notes, 2)
143 notesBody := strings.SplitN(notes[1], "</article>", 2)[0]
144 assert.Contains(t, notesBody, "<pre")
145 assert.Contains(t, notesBody, "<code")
146 assert.Contains(t, notesBody, "curl")
147 assert.Contains(t, notesBody, "</code></pre>")
148 assert.Contains(t, notesBody, `<h1 id="md-notes">Notes</h1>`)
149 assert.Contains(t, body, `<section class="mr-card">`)
150 assert.NotContains(t, body, "<script>alert")
151 assert.NotContains(t, body, `href="javascript:`)
152 assert.Contains(t, body, `href="/proj/blob/stable/v1/guide.md"`)
153 for _, path := range []string{"/proj/releases/tag/unknown", "/proj/releases/tag/unpublished"} {
154 assert.Equal(t, 404, f.request("GET", path, "", "").Code)
155 }
156 assert.Equal(t, 200, f.request("GET", "/proj/releases/tag/unpublished", "tok", "").Code)
157 latest := f.request("GET", "/proj/releases/latest", "", "")
158 assert.Equal(t, 303, latest.Code)
159 assert.Equal(t, "/proj/releases/tag/stable/v1", latest.Header().Get("Location"))
160}
161func TestReleaseDownloads(t *testing.T) {
162 f := newReleaseFixture(t)
163 // A simple tag uses the stable URL; slash tags use the page's asset-id link.
164 f.published.Tag = "v1"
165 for _, path := range []string{"/proj/releases/download/v1/build.zip", "/proj/releases/latest/download/build.zip", "/proj/releases/assets/7"} {
166 for _, tc := range []struct {
167 rangeValue, body, cr string
168 status int
169 }{
170 {"", "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},
171 } {
172 w := f.request("GET", path, "", tc.rangeValue)
173 require.Equal(t, tc.status, w.Code, path+" "+tc.rangeValue)
174 assert.Equal(t, tc.body, w.Body.String())
175 assert.Equal(t, tc.cr, w.Header().Get("Content-Range"))
176 assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges"))
177 assert.Equal(t, `"sha256-`+strings.Repeat("b", 64)+`"`, w.Header().Get("ETag"))
178 assert.Equal(t, "application/zip", w.Header().Get("Content-Type"))
179 assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
180 assert.Equal(t, rawCSP, w.Header().Get("Content-Security-Policy"))
181 _, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
182 require.NoError(t, err)
183 assert.Equal(t, "build.zip", params["filename"])
184 assert.Equal(t, itoa(len(tc.body)), w.Header().Get("Content-Length"))
185 }
186 before := f.downloads
187 w := f.request("HEAD", path, "", "bytes=1-2")
188 assert.Equal(t, 200, w.Code)
189 assert.Empty(t, w.Header().Get("Content-Range"))
190 assert.Empty(t, w.Body.String())
191 assert.Equal(t, "10", w.Header().Get("Content-Length"))
192 assert.Equal(t, before, f.downloads)
193 }
194 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"} {
195 assert.Equal(t, 404, f.request("GET", path, "", "").Code)
196 }
197 r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
198 r.Header.Set("Range", "bytes=1-2")
199 r.Header.Set("If-Range", `"old"`)
200 w := httptest.NewRecorder()
201 f.srv.ServeHTTP(w, r)
202 assert.Equal(t, 200, w.Code)
203 assert.Equal(t, f.data, w.Body.String())
204}
205func TestReleaseDownloadIfRange(t *testing.T) {
206 f := newReleaseFixture(t)
207 etag := `"sha256-` + strings.Repeat("b", 64) + `"`
208 for _, tc := range []struct {
209 name, ifRange, body, cr string
210 status int
211 }{
212 {"matching", etag, "2345", "bytes 2-5/10", 206},
213 {"stale", `"old"`, "0123456789", "", 200},
214 {"date", "Wed, 21 Oct 2026 12:00:00 GMT", "0123456789", "", 200},
215 } {
216 r := httptest.NewRequest("GET", "/proj/releases/assets/7", nil)
217 r.Header.Set("Range", "bytes=2-5")
218 r.Header.Set("If-Range", tc.ifRange)
219 w := httptest.NewRecorder()
220 f.srv.ServeHTTP(w, r)
221 assert.Equal(t, tc.status, w.Code, tc.name)
222 assert.Equal(t, tc.body, w.Body.String(), tc.name)
223 assert.Equal(t, tc.cr, w.Header().Get("Content-Range"), tc.name)
224 }
225}
226
227// A tag with a slash is one path segment once escaped: the download route must
228// match it and resolve the tag with the slash.
229func TestReleaseDownloadSlashTag(t *testing.T) {
230 f := newReleaseFixture(t) // the published tag is "stable/v1"
231 w := f.request("GET", "/proj/releases/download/"+url.PathEscape(f.published.Tag)+"/build.zip", "", "")
232 require.Equal(t, 200, w.Code)
233 assert.Equal(t, f.data, w.Body.String())
234}
235
236func TestReleaseDownloadFailures(t *testing.T) {
237 f := newReleaseFixture(t)
238 f.downloadErr = errors.New("blob unavailable")
239 w := f.request("GET", "/proj/releases/assets/7", "", "bytes=1-2")
240 assert.Equal(t, 404, w.Code)
241 assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
242 for _, key := range []string{"Content-Disposition", "Content-Range", "Accept-Ranges"} {
243 assert.Empty(t, w.Header().Get(key))
244 }
245 f.failAfterMetadata = true
246 assert.PanicsWithValue(t, http.ErrAbortHandler, func() { f.request("GET", "/proj/releases/assets/7", "", "") })
247}
248func TestReleaseAttachmentHeaders(t *testing.T) {
249 for _, name := range []string{"résumé.zip", "build.zip", "evil\r\nX-Injected: true.html", `<img src=x onerror=alert(1)>.html`} {
250 h := http.Header{}
251 releaseHeaders(h, domain.ReleaseAsset{Name: name, ContentType: "text/html", Digest: "sha256:abcd", Size: 12})
252 assert.Equal(t, "application/octet-stream", h.Get("Content-Type"))
253 assert.NotContains(t, h.Get("Content-Disposition"), "\r")
254 assert.NotContains(t, h.Get("Content-Disposition"), "\n")
255 media, params, err := mime.ParseMediaType(h.Get("Content-Disposition"))
256 require.NoError(t, err)
257 assert.Equal(t, "attachment", media)
258 assert.Equal(t, attachmentName(name), params["filename"])
259 }
260}
261func TestReleasesDisabled(t *testing.T) {
262 s := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
263 for _, path := range []string{"/proj/releases", "/proj/releases/latest", "/proj/releases/assets/7"} {
264 w := httptest.NewRecorder()
265 s.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
266 assert.Equal(t, 404, w.Code)
267 }
268}
269
270func TestReleaseEmptyDownloads(t *testing.T) {
271 f := newReleaseFixture(t)
272 f.asset.Size = 0
273 f.data = ""
274 w := f.request("GET", "/proj/releases/assets/7", "", "")
275 assert.Equal(t, 200, w.Code)
276 assert.Equal(t, "0", w.Header().Get("Content-Length"))
277 assert.Empty(t, w.Body.String())
278 w = f.request("GET", "/proj/releases/assets/7", "", "bytes=0-")
279 assert.Equal(t, 416, w.Code)
280 assert.Equal(t, "bytes */0", w.Header().Get("Content-Range"))
281}
282
283func TestReleaseLatestMissing(t *testing.T) {
284 svc := mocks.NewReleaseService(t)
285 repo := repoName(t, "proj")
286 svc.EXPECT().GetRelease(mock.Anything, mock.Anything, repo, ports.ReleaseRef{Latest: true}).Return(domain.Release{}, domain.ErrNotFound)
287 srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Releases: svc})
288 w := httptest.NewRecorder()
289 srv.ServeHTTP(w, httptest.NewRequest("GET", "/proj/releases/latest", nil))
290 assert.Equal(t, 404, w.Code)
291 assert.Empty(t, w.Header().Get("Location"))
292}
293
294func TestReleaseAssetNameEscapedInPage(t *testing.T) {
295 f := newReleaseFixture(t)
296 f.asset.Name = `<img src=x onerror=alert(1)>`
297 w := f.request("GET", "/proj/releases/tag/stable/v1", "", "")
298 require.Equal(t, 200, w.Code)
299 assert.NotContains(t, w.Body.String(), f.asset.Name)
300 assert.Contains(t, w.Body.String(), "<img src=x onerror=alert(1)>")
301}
302
303func TestReleaseRowFallbacks(t *testing.T) {
304 row := releaseRow("proj", domain.Release{ID: 1, Tag: "v1", Draft: true, PublishedAt: fixedNow}, 0)
305 assert.Equal(t, "v1", row.Title)
306 assert.Equal(t, "draft", row.Published)
307 assert.False(t, row.Latest)
308}