v0.18.0 0cea275

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().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}

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