detached 59d5a0b

actions_artifacts_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close