1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "net/http"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19 "bnema.dev/ermite/internal/ports/mocks"
20)
21
22// fakeArtifacts drives a mock ArtifactService that refuses anonymous callers,
23// like the use case, and serves one artifact. It counts the lists and records
24// the deletions.
25type fakeArtifacts struct {
26 m *mocks.ArtifactService
27 mu sync.Mutex
28 list ports.ArtifactList
29 listErr error
30 art domain.CIArtifact
31 data []byte
32 lists int
33 deleted []int64
34}
35
36func newFakeArtifacts(t *testing.T, name, mediaType string, data []byte) *fakeArtifacts {
37 t.Helper()
38 repo := repoName(t, "proj")
39 a := domain.CIArtifact{
40 ID: 7, Repo: repo, RunID: 1, Attempt: 1, JobID: "1-0-0", Name: name, MediaType: mediaType,
41 Size: int64(len(data)), State: domain.CIArtifactFinal,
42 CreatedAt: fixedNow, ExpiresAt: fixedNow.Add(90 * 24 * time.Hour),
43 }
44 f := &fakeArtifacts{
45 m: mocks.NewArtifactService(t), art: a, data: data,
46 list: ports.ArtifactList{Artifacts: []domain.CIArtifact{a}, Used: 3 << 20, Quota: 10 << 30},
47 }
48 f.m.EXPECT().ListArtifacts(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(
49 func(_ context.Context, p domain.Principal, _ domain.RepoName, runID int64) (ports.ArtifactList, error) {
50 f.mu.Lock()
51 defer f.mu.Unlock()
52 f.lists++
53 switch {
54 case !p.IsOwner():
55 return ports.ArtifactList{}, domain.ErrUnauthenticated
56 case f.listErr != nil:
57 return ports.ArtifactList{}, f.listErr
58 case runID != f.art.RunID:
59 return ports.ArtifactList{}, nil
60 }
61 return f.list, nil
62 }).Maybe()
63 f.m.EXPECT().DownloadArtifact(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
64 func(_ context.Context, p domain.Principal, _ domain.RepoName, id, offset int64, send func(ports.ArtifactChunk) error) error {
65 if !p.IsOwner() {
66 return domain.ErrUnauthenticated
67 }
68 if id != f.art.ID || offset != 0 {
69 return domain.ErrNotFound
70 }
71 a := f.art
72 if err := send(ports.ArtifactChunk{Artifact: &a}); err != nil {
73 return err
74 }
75 half := len(f.data) / 2
76 for _, d := range [][]byte{f.data[:half], f.data[half:]} {
77 if err := send(ports.ArtifactChunk{Data: d}); err != nil {
78 return err
79 }
80 }
81 return nil
82 }).Maybe()
83 f.m.EXPECT().DeleteArtifact(mock.Anything, mock.Anything, repo, mock.Anything).RunAndReturn(
84 func(_ context.Context, p domain.Principal, _ domain.RepoName, id int64) error {
85 if !p.IsOwner() {
86 return domain.ErrUnauthenticated
87 }
88 f.mu.Lock()
89 defer f.mu.Unlock()
90 f.deleted = append(f.deleted, id)
91 return nil
92 }).Maybe()
93 return f
94}
95
96// artifactsServer serves the run 1 of proj, with its job 1-0-0, and the
97// artifacts of arts; the session token "tok" signs the owner in.
98func artifactsServer(t *testing.T, arts *fakeArtifacts) *passkeyFixture {
99 t.Helper()
100 repo := repoName(t, "proj")
101 runs := mocks.NewRunService(t)
102 job := ports.JobView{CIJob: domain.CIJob{
103 ID: "1-0-0", RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "build", Name: "build",
104 Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess,
105 }}
106 runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
107 Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1, Number: 1, WorkflowName: "ci", Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess},
108 Jobs: []ports.JobView{job}, LatestAttempt: 1,
109 }, nil).Maybe()
110 runs.EXPECT().ListAnnotations(mock.Anything, mock.Anything, repo, int64(1), int32(1), "").Return(ports.AnnotationList{}, nil).Maybe()
111 runs.EXPECT().DisabledWorkflows(mock.Anything, mock.Anything, repo).Return(nil, nil).Maybe()
112 runs.EXPECT().GetStepSummaries(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(ports.SummaryList{}, nil).Maybe()
113 browse := mocks.NewBrowseService(t)
114 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
115 cfg := Config{
116 Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs,
117 PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{},
118 Now: func() time.Time { return fixedNow },
119 }
120 if arts != nil {
121 cfg.Artifacts = arts.m
122 }
123 return &passkeyFixture{srv: newTestServer(t, cfg)}
124}
125
126func TestRunPageArtifactsForTheOwner(t *testing.T) {
127 t.Parallel()
128 arts := newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK"))
129 f := artifactsServer(t, arts)
130 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1", cookie: "tok"})
131 require.Equal(t, http.StatusOK, rec.Code)
132 body := rec.Body.String()
133 assert.Contains(t, body, `id="run-artifacts"`)
134 assert.Contains(t, body, `href="/proj/actions/runs/1/artifacts/7"`)
135 assert.Contains(t, body, ">dist</a>")
136 assert.Contains(t, body, "2 B", "the size")
137 assert.Contains(t, body, `datetime="2027-01-10T12:00:00Z"`, "the expiry")
138 assert.Contains(t, body, `href="/proj/actions/runs/1/job/1-0-0">build</a>`, "the job that uploaded it")
139 assert.Contains(t, body, "3.0 MiB of 10.0 GiB used")
140 assert.Contains(t, body, `action="/proj/actions/runs/1/artifacts/7/delete"`)
141}
142
143func TestRunPageArtifactsHidden(t *testing.T) {
144 t.Parallel()
145 // Anonymous: no section, and the use case is not even asked.
146 arts := newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK"))
147 f := artifactsServer(t, arts)
148 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1"})
149 require.Equal(t, http.StatusOK, rec.Code)
150 assert.NotContains(t, rec.Body.String(), "run-artifacts")
151 assert.Zero(t, arts.lists)
152
153 // A refusal or a failure of the list leaves the page whole, without it.
154 for _, err := range []error{domain.ErrForbidden, domain.ErrNotFound, assert.AnError} {
155 arts := newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK"))
156 arts.listErr = err
157 rec := artifactsServer(t, arts).do(t, req{method: "GET", target: "/proj/actions/runs/1", cookie: "tok"})
158 require.Equal(t, http.StatusOK, rec.Code, err)
159 assert.NotContains(t, rec.Body.String(), "run-artifacts", err)
160 }
161
162 // No artifacts on the server: no section and no route.
163 f = artifactsServer(t, nil)
164 rec = f.do(t, req{method: "GET", target: "/proj/actions/runs/1", cookie: "tok"})
165 require.Equal(t, http.StatusOK, rec.Code)
166 assert.NotContains(t, rec.Body.String(), "run-artifacts")
167 assert.Equal(t, http.StatusNotFound, f.do(t, req{method: "GET", target: "/proj/actions/runs/1/artifacts/7", cookie: "tok"}).Code)
168}
169
170func TestArtifactDownloadZip(t *testing.T) {
171 t.Parallel()
172 data := []byte("PK\x03\x04 the archive")
173 f := artifactsServer(t, newFakeArtifacts(t, "dist", domain.CIArtifactZip, data))
174 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1/artifacts/7", cookie: "tok"})
175 require.Equal(t, http.StatusOK, rec.Code)
176 h := rec.Header()
177 assert.Equal(t, "application/zip", h.Get("Content-Type"))
178 assert.Equal(t, `attachment; filename=dist.zip`, h.Get("Content-Disposition"))
179 assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options"))
180 assert.Contains(t, h.Get("Content-Security-Policy"), "sandbox")
181 assert.Equal(t, itoa(len(data)), h.Get("Content-Length"))
182 assert.Equal(t, sessionCache, h.Get("Cache-Control"))
183 assert.Equal(t, data, rec.Body.Bytes())
184
185 // HEAD: the headers only.
186 rec = f.do(t, req{method: "HEAD", target: "/proj/actions/runs/1/artifacts/7", cookie: "tok"})
187 require.Equal(t, http.StatusOK, rec.Code)
188 assert.Equal(t, itoa(len(data)), rec.Header().Get("Content-Length"))
189 assert.Empty(t, rec.Body.Bytes())
190}
191
192// The media type an uploader chose is never served: it could be text/html.
193func TestArtifactDownloadOtherType(t *testing.T) {
194 t.Parallel()
195 data := []byte("<script>alert(1)</script>")
196 f := artifactsServer(t, newFakeArtifacts(t, "report é.html", "text/html", data))
197 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1/artifacts/7", cookie: "tok"})
198 require.Equal(t, http.StatusOK, rec.Code)
199 h := rec.Header()
200 assert.Equal(t, "application/octet-stream", h.Get("Content-Type"))
201 assert.Equal(t, `attachment; filename="report _.html"; filename*=UTF-8''report%20%C3%A9.html`, h.Get("Content-Disposition"))
202 assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options"))
203 assert.Contains(t, h.Get("Content-Security-Policy"), "sandbox")
204 assert.Equal(t, data, rec.Body.Bytes())
205}
206
207func TestArtifactDownloadRefused(t *testing.T) {
208 t.Parallel()
209 f := artifactsServer(t, newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK")))
210 // Anonymous, even on a public repository.
211 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1/artifacts/7"})
212 assert.Equal(t, http.StatusForbidden, rec.Code)
213 assert.Empty(t, rec.Header().Get("Content-Disposition"))
214 // Not a number, unknown, or an artifact of another run.
215 for _, target := range []string{
216 "/proj/actions/runs/1/artifacts/x", "/proj/actions/runs/1/artifacts/0", "/proj/actions/runs/1/artifacts/-7",
217 "/proj/actions/runs/1/artifacts/8", "/proj/actions/runs/2/artifacts/7", "/proj/actions/runs/x/artifacts/7",
218 } {
219 rec := f.do(t, req{method: "GET", target: target, cookie: "tok"})
220 assert.Equal(t, http.StatusNotFound, rec.Code, target)
221 assert.Empty(t, rec.Header().Get("Content-Disposition"), target)
222 }
223}
224
225func TestArtifactDelete(t *testing.T) {
226 t.Parallel()
227 arts := newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK"))
228 f := artifactsServer(t, arts)
229 target := "/proj/actions/runs/1/artifacts/7/delete"
230 form := "application/x-www-form-urlencoded"
231 // The gate: a session and the Origin of the forge.
232 assert.Equal(t, http.StatusForbidden, f.do(t, req{method: "POST", target: target, origin: forgeOrigin, contentType: form}).Code)
233 assert.Equal(t, http.StatusForbidden, f.do(t, req{method: "POST", target: target, origin: "https://evil.example", cookie: "tok", contentType: form}).Code)
234 assert.Empty(t, arts.deleted)
235
236 rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: form})
237 assert.Equal(t, http.StatusSeeOther, rec.Code)
238 assert.Equal(t, "/proj/actions/runs/1", rec.Header().Get("Location"))
239 assert.Equal(t, []int64{7}, arts.deleted)
240}
241
242func TestContentDisposition(t *testing.T) {
243 t.Parallel()
244 for in, want := range map[string]string{
245 "dist.zip": `attachment; filename=dist.zip`,
246 "a b.txt": `attachment; filename="a b.txt"`,
247 "日本.zip": `attachment; filename=__.zip; filename*=UTF-8''%E6%97%A5%E6%9C%AC.zip`,
248 attachmentName(""): `attachment; filename=artifact`,
249 } {
250 assert.Equal(t, want, contentDisposition(in), in)
251 }
252 assert.Equal(t, "ab", attachmentName("a\"\x00\r\n\\b"))
253 assert.Equal(t, "artifact", attachmentName(".."))
254}