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(), "<img src=x onerror=alert(1)>")
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}