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().GetStepSummaries(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything, mock.Anything).Return(ports.SummaryList{}, nil).Maybe()
112 browse := mocks.NewBrowseService(t)
113 browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
114 cfg := Config{
115 Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs,
116 PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Limiter: &blockingLimiter{},
117 Now: func() time.Time { return fixedNow },
118 }
119 if arts != nil {
120 cfg.Artifacts = arts.m
121 }
122 return &passkeyFixture{srv: newTestServer(t, cfg)}
123}
124
125func TestRunPageArtifactsForTheOwner(t *testing.T) {
126 t.Parallel()
127 arts := newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK"))
128 f := artifactsServer(t, arts)
129 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1", cookie: "tok"})
130 require.Equal(t, http.StatusOK, rec.Code)
131 body := rec.Body.String()
132 assert.Contains(t, body, `id="run-artifacts"`)
133 assert.Contains(t, body, `href="/proj/actions/runs/1/artifacts/7"`)
134 assert.Contains(t, body, ">dist</a>")
135 assert.Contains(t, body, "2 B", "the size")
136 assert.Contains(t, body, `datetime="2027-01-10T12:00:00Z"`, "the expiry")
137 assert.Contains(t, body, `href="/proj/actions/runs/1/job/1-0-0">build</a>`, "the job that uploaded it")
138 assert.Contains(t, body, "3.0 MiB of 10.0 GiB used")
139 assert.Contains(t, body, `action="/proj/actions/runs/1/artifacts/7/delete"`)
140}
141
142func TestRunPageArtifactsHidden(t *testing.T) {
143 t.Parallel()
144 // Anonymous: no section, and the use case is not even asked.
145 arts := newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK"))
146 f := artifactsServer(t, arts)
147 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1"})
148 require.Equal(t, http.StatusOK, rec.Code)
149 assert.NotContains(t, rec.Body.String(), "run-artifacts")
150 assert.Zero(t, arts.lists)
151
152 // A refusal or a failure of the list leaves the page whole, without it.
153 for _, err := range []error{domain.ErrForbidden, domain.ErrNotFound, assert.AnError} {
154 arts := newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK"))
155 arts.listErr = err
156 rec := artifactsServer(t, arts).do(t, req{method: "GET", target: "/proj/actions/runs/1", cookie: "tok"})
157 require.Equal(t, http.StatusOK, rec.Code, err)
158 assert.NotContains(t, rec.Body.String(), "run-artifacts", err)
159 }
160
161 // No artifacts on the server: no section and no route.
162 f = artifactsServer(t, nil)
163 rec = f.do(t, req{method: "GET", target: "/proj/actions/runs/1", cookie: "tok"})
164 require.Equal(t, http.StatusOK, rec.Code)
165 assert.NotContains(t, rec.Body.String(), "run-artifacts")
166 assert.Equal(t, http.StatusNotFound, f.do(t, req{method: "GET", target: "/proj/actions/runs/1/artifacts/7", cookie: "tok"}).Code)
167}
168
169func TestArtifactDownloadZip(t *testing.T) {
170 t.Parallel()
171 data := []byte("PK\x03\x04 the archive")
172 f := artifactsServer(t, newFakeArtifacts(t, "dist", domain.CIArtifactZip, data))
173 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1/artifacts/7", cookie: "tok"})
174 require.Equal(t, http.StatusOK, rec.Code)
175 h := rec.Header()
176 assert.Equal(t, "application/zip", h.Get("Content-Type"))
177 assert.Equal(t, `attachment; filename=dist.zip`, h.Get("Content-Disposition"))
178 assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options"))
179 assert.Contains(t, h.Get("Content-Security-Policy"), "sandbox")
180 assert.Equal(t, itoa(len(data)), h.Get("Content-Length"))
181 assert.Equal(t, sessionCache, h.Get("Cache-Control"))
182 assert.Equal(t, data, rec.Body.Bytes())
183
184 // HEAD: the headers only.
185 rec = f.do(t, req{method: "HEAD", target: "/proj/actions/runs/1/artifacts/7", cookie: "tok"})
186 require.Equal(t, http.StatusOK, rec.Code)
187 assert.Equal(t, itoa(len(data)), rec.Header().Get("Content-Length"))
188 assert.Empty(t, rec.Body.Bytes())
189}
190
191// The media type an uploader chose is never served: it could be text/html.
192func TestArtifactDownloadOtherType(t *testing.T) {
193 t.Parallel()
194 data := []byte("<script>alert(1)</script>")
195 f := artifactsServer(t, newFakeArtifacts(t, "report é.html", "text/html", data))
196 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1/artifacts/7", cookie: "tok"})
197 require.Equal(t, http.StatusOK, rec.Code)
198 h := rec.Header()
199 assert.Equal(t, "application/octet-stream", h.Get("Content-Type"))
200 assert.Equal(t, `attachment; filename="report _.html"; filename*=UTF-8''report%20%C3%A9.html`, h.Get("Content-Disposition"))
201 assert.Equal(t, "nosniff", h.Get("X-Content-Type-Options"))
202 assert.Contains(t, h.Get("Content-Security-Policy"), "sandbox")
203 assert.Equal(t, data, rec.Body.Bytes())
204}
205
206func TestArtifactDownloadRefused(t *testing.T) {
207 t.Parallel()
208 f := artifactsServer(t, newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK")))
209 // Anonymous, even on a public repository.
210 rec := f.do(t, req{method: "GET", target: "/proj/actions/runs/1/artifacts/7"})
211 assert.Equal(t, http.StatusForbidden, rec.Code)
212 assert.Empty(t, rec.Header().Get("Content-Disposition"))
213 // Not a number, unknown, or an artifact of another run.
214 for _, target := range []string{
215 "/proj/actions/runs/1/artifacts/x", "/proj/actions/runs/1/artifacts/0", "/proj/actions/runs/1/artifacts/-7",
216 "/proj/actions/runs/1/artifacts/8", "/proj/actions/runs/2/artifacts/7", "/proj/actions/runs/x/artifacts/7",
217 } {
218 rec := f.do(t, req{method: "GET", target: target, cookie: "tok"})
219 assert.Equal(t, http.StatusNotFound, rec.Code, target)
220 assert.Empty(t, rec.Header().Get("Content-Disposition"), target)
221 }
222}
223
224func TestArtifactDelete(t *testing.T) {
225 t.Parallel()
226 arts := newFakeArtifacts(t, "dist", domain.CIArtifactZip, []byte("PK"))
227 f := artifactsServer(t, arts)
228 target := "/proj/actions/runs/1/artifacts/7/delete"
229 form := "application/x-www-form-urlencoded"
230 // The gate: a session and the Origin of the forge.
231 assert.Equal(t, http.StatusForbidden, f.do(t, req{method: "POST", target: target, origin: forgeOrigin, contentType: form}).Code)
232 assert.Equal(t, http.StatusForbidden, f.do(t, req{method: "POST", target: target, origin: "https://evil.example", cookie: "tok", contentType: form}).Code)
233 assert.Empty(t, arts.deleted)
234
235 rec := f.do(t, req{method: "POST", target: target, origin: forgeOrigin, cookie: "tok", contentType: form})
236 assert.Equal(t, http.StatusSeeOther, rec.Code)
237 assert.Equal(t, "/proj/actions/runs/1", rec.Header().Get("Location"))
238 assert.Equal(t, []int64{7}, arts.deleted)
239}
240
241func TestContentDisposition(t *testing.T) {
242 t.Parallel()
243 for in, want := range map[string]string{
244 "dist.zip": `attachment; filename=dist.zip`,
245 "a b.txt": `attachment; filename="a b.txt"`,
246 "日本.zip": `attachment; filename=__.zip; filename*=UTF-8''%E6%97%A5%E6%9C%AC.zip`,
247 attachmentName(""): `attachment; filename=artifact`,
248 } {
249 assert.Equal(t, want, contentDisposition(in), in)
250 }
251 assert.Equal(t, "ab", attachmentName("a\"\x00\r\n\\b"))
252 assert.Equal(t, "artifact", attachmentName(".."))
253}