1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "encoding/json"
8 "io"
9 "net/http"
10 "strconv"
11 "strings"
12 "testing"
13
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19 "bnema.dev/ermite/internal/usecase"
20)
21
22// Releases and web use the production forge wiring.
23func TestWebRelease(t *testing.T) {
24 h := newHarness(t, harnessOpts{})
25 h.webRepo("demo")
26 blobs, releases := h.forge.Blobs, h.forge.Releases
27 repo := h.name("demo")
28 rel, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "v0.2.0", Title: "Demo release", Notes: "## Install\n\n**Download** the binary. <script>alert(1)</script>"})
29 require.NoError(t, err)
30 payload := "binary release data\x00\xff\n"
31 asset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: rel.ID, Name: "demo.bin", ContentType: "application/octet-stream", Size: int64(len(payload))}, strings.NewReader(payload))
32 require.NoError(t, err)
33 draft, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "draft", Title: "Hidden draft", Draft: true})
34 require.NoError(t, err)
35 draftAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: draft.ID, Name: "hidden.bin", Size: 1}, strings.NewReader("x"))
36 require.NoError(t, err)
37 ts := h.srv
38 get := func(method, path, byteRange string) (int, http.Header, string) {
39 t.Helper()
40 req, err := http.NewRequestWithContext(t.Context(), method, ts.URL+path, nil)
41 require.NoError(t, err)
42 if byteRange != "" {
43 req.Header.Set("Range", byteRange)
44 }
45 res, err := ts.Client().Do(req)
46 require.NoError(t, err)
47 defer res.Body.Close()
48 body, err := io.ReadAll(res.Body)
49 require.NoError(t, err)
50 return res.StatusCode, res.Header, string(body)
51 }
52 status, _, body := get("GET", "/demo/releases", "")
53 require.Equal(t, 200, status)
54 assert.Contains(t, body, "Demo release")
55 assert.NotContains(t, body, "Hidden draft")
56 status, _, body = get("GET", "/demo/releases/tag/v0.2.0", "")
57 require.Equal(t, 200, status)
58 assert.Contains(t, body, "<strong>Download</strong>")
59 assert.NotContains(t, body, "<script>alert")
60 assert.Contains(t, body, "demo.bin")
61 assert.Contains(t, body, "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10))
62 for _, path := range []string{"/demo/releases/download/v0.2.0/demo.bin", "/demo/releases/latest/download/demo.bin", "/demo/releases/assets/" + strconv.FormatInt(asset.ID, 10)} {
63 status, headers, body := get("GET", path, "")
64 require.Equal(t, 200, status)
65 assert.Equal(t, payload, body)
66 assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length"))
67 assert.Equal(t, "bytes", headers.Get("Accept-Ranges"))
68 assert.Contains(t, headers.Get("Content-Disposition"), "attachment")
69 status, headers, body = get("GET", path, "bytes=2-6")
70 require.Equal(t, 206, status)
71 assert.Equal(t, payload[2:7], body)
72 assert.Equal(t, "bytes 2-6/"+strconv.Itoa(len(payload)), headers.Get("Content-Range"))
73 assert.Equal(t, "5", headers.Get("Content-Length"))
74 status, headers, body = get("HEAD", path, "")
75 require.Equal(t, 200, status)
76 assert.Empty(t, body)
77 assert.Equal(t, strconv.Itoa(len(payload)), headers.Get("Content-Length"))
78 }
79 for _, path := range []string{"/demo/releases/tag/draft", "/demo/releases/download/draft/hidden.bin", "/demo/releases/assets/" + strconv.FormatInt(draftAsset.ID, 10)} {
80 status, _, _ := get("GET", path, "")
81 assert.Equal(t, 404, status)
82 }
83 // Slash-tag page and asset-id download remain unambiguous.
84 slash, err := releases.CreateRelease(h.ctx, domain.Root(), repo, ports.CreateReleaseParams{Tag: "series/v1", TargetCommitish: "main", Title: "Slash tag"})
85 require.NoError(t, err)
86 status, _, body = get("GET", "/demo/releases/tag/series/v1", "")
87 assert.Equal(t, 200, status)
88 assert.Contains(t, body, slash.Title)
89 // The browser_download_url of the REST API for a slash tag is served by
90 // the web route.
91 slashAsset, err := releases.UploadAsset(h.ctx, domain.Root(), repo, ports.UploadAssetParams{ReleaseID: slash.ID, Name: "s.bin", Size: 5}, strings.NewReader("slash"))
92 require.NoError(t, err)
93 gh := usecase.NewReleasesGitHub(releases, "owner", ts.URL)
94 raw, err := gh.GetAsset(h.ctx, domain.Root(), "owner", "demo", slashAsset.ID)
95 require.NoError(t, err)
96 var obj struct {
97 URL string `json:"browser_download_url"`
98 }
99 require.NoError(t, json.Unmarshal(raw, &obj))
100 require.Equal(t, ts.URL+"/demo/releases/download/series%2Fv1/s.bin", obj.URL)
101 status, _, body = get("GET", strings.TrimPrefix(obj.URL, ts.URL), "")
102 assert.Equal(t, 200, status)
103 assert.Equal(t, "slash", body)
104 // A missing blob is a 404 page before any binary headers are written.
105 require.NoError(t, blobs.Delete(context.Background(), asset.BlobKey()))
106 status, headers, body := get("GET", "/demo/releases/assets/"+strconv.FormatInt(asset.ID, 10), "")
107 assert.Equal(t, 404, status)
108 assert.Contains(t, headers.Get("Content-Type"), "text/html")
109 assert.Contains(t, body, "no such page")
110 assert.Empty(t, headers.Get("Content-Disposition"))
111}