v0.28.1 cc7ba64

ci_pull_target_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"fmt"
7	"strings"
8	"testing"
9
10	"connectrpc.com/connect"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13	"google.golang.org/protobuf/types/known/fieldmaskpb"
14
15	pb "bnema.dev/ermite-api/gen/ermite"
16	"bnema.dev/ermite/internal/domain"
17)
18
19// CI-4 D4: the pull_request_target event runs the workflows of the default
20// branch's tip for the activities of a merge request.
21
22const targetWorkflow = `name: prt
23on:
24  pull_request_target:
25%s
26jobs:
27  j:
28    runs-on: ubuntu-latest
29    permissions:
30      contents: write
31    steps:
32      - run: echo ${{ secrets.API_TOKEN }} %s
33`
34
35func targetWF(on, mark string) map[string]string {
36	return map[string]string{".github/workflows/prt.yml": fmt.Sprintf(targetWorkflow, on, mark)}
37}
38
39// newTargetHarness is a harness with a repository secret and the
40// pull_request_target workflow of on on main.
41func newTargetHarness(t *testing.T, on string) (*ciHarness, *fakeRunner, string) {
42	t.Helper()
43	c := newSecretsHarness(t, secretsKeyA)
44	runner := c.registerRunner("alpha", "ubuntu-latest")
45	c.setSecret(repoScope("proj"), "API_TOKEN", "repo-token")
46	files := targetWF(on, "from-main")
47	files["a.txt"] = "base\n"
48	return c, runner, c.push("main", files, "")
49}
50
51func TestPullRequestTargetRunsTheDefaultBranchWorkflow(t *testing.T) {
52	c, runner, main := newTargetHarness(t, "")
53	// The head changes the workflow: that version is not the one that runs.
54	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
55	files := targetWF("", "from-head")
56	files["b.txt"] = "b\n"
57	feat1 := c.push("feat", files, "")
58
59	n := c.openMR(c.root, "feat")
60	job := c.finish(runner)
61	assert.Contains(t, job.GetWorkflowYaml(), "from-main")
62	assert.NotContains(t, job.GetWorkflowYaml(), "from-head")
63	assert.Equal(t, main, job.GetSha())
64	assert.Equal(t, "refs/heads/main", job.GetRef())
65
66	// Trusted: the owner opened it and pushed main.
67	assert.True(t, job.GetTrusted())
68	assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
69	assert.Equal(t, "write", job.GetPermissions().GetLevels()["contents"])
70
71	runs := c.runs()
72	require.Len(t, runs, 1)
73	assert.Equal(t, "pull_request_target", runs[0].GetEvent())
74	assert.Equal(t, "refs/heads/main", runs[0].GetRef())
75	assert.Equal(t, main, runs[0].GetSha())
76	assert.Equal(t, "Title of feat", runs[0].GetTitle())
77	assert.True(t, runs[0].GetTrusted())
78
79	// The checks attach to the head, as those of pull_request, not to main.
80	require.Len(t, c.checks(feat1), 1)
81	assert.Empty(t, c.checks(main))
82
83	gh := c.ghContext(job)
84	assert.Equal(t, "pull_request_target", gh["event_name"])
85	assert.Equal(t, "refs/heads/main", gh["ref"])
86	assert.Equal(t, "main", gh["ref_name"])
87	assert.Equal(t, main, gh["sha"])
88	assert.Equal(t, "feat", gh["head_ref"])
89	assert.Equal(t, "main", gh["base_ref"])
90	ev := gh["event"].(map[string]any)
91	assert.Equal(t, "opened", ev["action"])
92	assert.EqualValues(t, n, ev["number"])
93	pr := ev["pull_request"].(map[string]any)
94	assert.Equal(t, feat1, pr["head"].(map[string]any)["sha"])
95	assert.Equal(t, main, pr["base"].(map[string]any)["sha"])
96	assert.Equal(t, c.ref(domain.PullMergeRef(n)), pr["merge_commit_sha"], "the test merge, as for pull_request")
97
98	// The cache: read-only, restores main's entries only, even trusted.
99	cache := job.GetCache()
100	require.NotNil(t, cache)
101	assert.Equal(t, "owner/proj", cache.GetRepository())
102	assert.Empty(t, cache.GetWriteRef())
103	assert.Equal(t, []string{"refs/heads/main"}, cache.GetReadRefs())
104
105	// A synchronize runs main's workflow again.
106	feat2 := c.push("feat", map[string]string{"c.txt": "c\n"}, "")
107	job = c.finish(runner)
108	gh = c.ghContext(job)
109	assert.Equal(t, "synchronize", gh["event"].(map[string]any)["action"])
110	assert.Equal(t, main, gh["sha"])
111	assert.Equal(t, feat2, gh["event"].(map[string]any)["after"])
112	assert.True(t, job.GetTrusted())
113}
114
115// GitHub runs pull_request_target even when the request conflicts, while
116// pull_request does not run.
117func TestPullRequestTargetRunsOnAConflict(t *testing.T) {
118	c, runner, _ := newTargetHarness(t, "")
119	c.push("main", pullWF("", ""), "")
120	c.mustGit(c.wt, "checkout", "-q", "-b", "conflict")
121	c.push("conflict", map[string]string{"a.txt": "feature\n"}, "")
122	c.mustGit(c.wt, "checkout", "-q", "main")
123	main := c.push("main", map[string]string{"a.txt": "main\n"}, "")
124
125	n := c.openMR(c.root, "conflict")
126	job := c.finish(runner)
127	gh := c.ghContext(job)
128	assert.Equal(t, "pull_request_target", gh["event_name"])
129	assert.Equal(t, main, gh["sha"])
130	assert.Nil(t, gh["event"].(map[string]any)["pull_request"].(map[string]any)["merge_commit_sha"], "no test merge")
131	assert.Empty(t, c.ref(domain.PullMergeRef(n)))
132	require.Len(t, c.runs(), 1, "the pull_request workflow does not run")
133}
134
135// An agent's activity runs the default branch's workflow without secrets and
136// with a read-only token, unlike GitHub (T1). The head's provenance does not
137// matter: its files do not run.
138func TestPullRequestTargetTrustFollowsTheActor(t *testing.T) {
139	c, runner, _ := newTargetHarness(t, "    types: [opened, synchronize, closed]")
140	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
141	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
142	c.writeFile(c.wt, "x.txt", []byte("x\n"))
143	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
144
145	// Opened by the agent: not trusted.
146	n := c.openMR(c.api(secret), "agent/x")
147	job := c.finish(runner)
148	assert.Equal(t, "pull_request_target", c.ghContext(job)["event_name"])
149	assert.False(t, job.GetTrusted())
150	assert.Empty(t, job.GetSecrets())
151	assert.NotEqual(t, "write", job.GetPermissions().GetLevels()["contents"])
152	assert.Equal(t, "claude", c.runs()[0].GetActor())
153	agentRun := c.runs()[0].GetId()
154
155	// The owner re-running it does not unlock the secrets.
156	_, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: agentRun}))
157	require.NoError(t, err)
158	job = c.finish(runner)
159	assert.False(t, job.GetTrusted())
160	assert.Empty(t, job.GetSecrets())
161
162	// The owner pushes on the agent's branch: trusted, as main's code runs.
163	c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
164	job = c.finish(runner)
165	assert.Equal(t, "synchronize", c.ghContext(job)["event"].(map[string]any)["action"])
166	assert.True(t, job.GetTrusted())
167
168	// The agent pushes: not trusted.
169	c.writeFile(c.wt, "z.txt", []byte("z\n"))
170	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
171	assert.False(t, c.finish(runner).GetTrusted())
172
173	// The owner closes it, the head being the agent's: trusted.
174	_, err = c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
175	require.NoError(t, err)
176	job = c.finish(runner)
177	assert.Equal(t, "closed", c.ghContext(job)["event"].(map[string]any)["action"])
178	assert.True(t, job.GetTrusted())
179
180	// The owner opens a request of a branch the agent pushed: trusted too.
181	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/y", "main")
182	c.writeFile(c.wt, "w.txt", []byte("w\n"))
183	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/y")
184	c.openMR(c.root, "agent/y")
185	job = c.finish(runner)
186	assert.True(t, job.GetTrusted())
187	assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
188}
189
190// The default tip runs: it must have been pushed by a trusted principal, as
191// taken when the activity happens.
192func TestPullRequestTargetTrustFollowsTheDefaultTip(t *testing.T) {
193	c, hook := newHookedHarness(t)
194	runner := c.registerRunner("alpha", "ubuntu-latest")
195	main := c.push("main", targetWF("", "from-main"), "")
196	c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
197	c.push("f1", map[string]string{"1.txt": "1\n"}, "")
198
199	// main's record says an untrusted principal set it there.
200	require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
201		Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main, Actor: "claude", Trusted: false,
202	}))
203	c.openMR(c.root, "f1")
204	job := c.finish(runner)
205	assert.Equal(t, main, job.GetSha())
206	assert.False(t, job.GetTrusted(), "main was not pushed by a trusted principal")
207
208	// The owner pushes main: trusted again.
209	c.mustGit(c.wt, "checkout", "-q", "main")
210	main2 := c.push("main", map[string]string{"m.txt": "m\n"}, "")
211	c.mustGit(c.wt, "checkout", "-q", "-b", "f2", "main")
212	c.push("f2", map[string]string{"2.txt": "2\n"}, "")
213	c.openMR(c.root, "f2")
214	job = c.finish(runner)
215	assert.Equal(t, main2, job.GetSha())
216	assert.True(t, job.GetTrusted())
217
218	// The tip is the one of the activity: main moving before the event is
219	// handled changes neither the code nor the trust.
220	c.mustGit(c.wt, "checkout", "-q", "-b", "f3", "main")
221	c.push("f3", map[string]string{"3.txt": "3\n"}, "")
222	reached, release := hook.block(t, "event")
223	c.openMR(c.root, "f3")
224	await(t, reached, "the opening event")
225	c.mustGit(c.wt, "checkout", "-q", "main")
226	main3 := c.push("main", targetWF("", "moved"), "")
227	require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
228		Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main3, Actor: "claude", Trusted: false,
229	}))
230	release()
231	job = c.finish(runner)
232	assert.Equal(t, main2, job.GetSha())
233	assert.Contains(t, job.GetWorkflowYaml(), "from-main")
234	assert.True(t, job.GetTrusted())
235}
236
237// Types, branches and paths filter as for pull_request: the base branch and the
238// files between the merge base and the head.
239func TestPullRequestTargetTypesAndFilters(t *testing.T) {
240	c, runner, _ := newTargetHarness(t, "    types: [closed]\n    branches: [main]\n    paths: ['docs/**']")
241	c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
242	c.push("f1", map[string]string{"src/x.go": "x\n"}, "")
243	n1 := c.openMR(c.root, "f1")
244	c.mustGit(c.wt, "checkout", "-q", "-b", "f2", "main")
245	f2 := c.push("f2", map[string]string{"docs/a.md": "a\n"}, "")
246	n2 := c.openMR(c.root, "f2")
247	c.waitFor("the head refs", func() bool { return c.ref(domain.PullHeadRef(n1)) != "" && c.ref(domain.PullHeadRef(n2)) == f2 })
248
249	for _, n := range []int64{n1, n2} {
250		_, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
251		require.NoError(t, err)
252	}
253	job := c.finish(runner)
254	ev := c.ghContext(job)["event"].(map[string]any)
255	assert.Equal(t, "closed", ev["action"])
256	assert.EqualValues(t, n2, ev["number"])
257	require.Len(t, c.runs(), 1, "neither opened nor the request without docs")
258	assert.True(t, strings.HasPrefix(c.runs()[0].GetRef(), "refs/heads/"))
259}
260
261// Job.cache of the other events (CI-3 D8): save under the run's ref, restore
262// from it, the base branch of a merge request, then the default branch.
263func TestJobCacheScopes(t *testing.T) {
264	c := newCIHarness(t, nil)
265	runner := c.registerRunner("alpha", "ubuntu-latest")
266	files := pullWF("", "")
267	files[".github/workflows/push.yml"] = "on: push\njobs:\n  p:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
268	c.push("main", files, "")
269	job := c.finish(runner)
270	assert.Equal(t, "refs/heads/main", job.GetCache().GetWriteRef())
271	assert.Equal(t, []string{"refs/heads/main"}, job.GetCache().GetReadRefs())
272
273	c.mustGit(c.wt, "checkout", "-q", "-b", "dev")
274	c.push("dev", map[string]string{"d.txt": "d\n"}, "")
275	job = c.finish(runner)
276	assert.Equal(t, "refs/heads/dev", job.GetCache().GetWriteRef())
277	assert.Equal(t, []string{"refs/heads/dev", "refs/heads/main"}, job.GetCache().GetReadRefs())
278
279	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
280	c.push("feat", map[string]string{"f.txt": "f\n"}, "")
281	c.finish(runner) // its push run
282	c.openMRInto("feat", "dev")
283	job = c.finish(runner)
284	assert.Equal(t, "pull_request", c.ghContext(job)["event_name"])
285	assert.Equal(t, "owner/proj", job.GetCache().GetRepository())
286	assert.Equal(t, "refs/pull/1/merge", job.GetCache().GetWriteRef())
287	assert.Equal(t, []string{"refs/pull/1/merge", "refs/heads/dev", "refs/heads/main"}, job.GetCache().GetReadRefs())
288}
289
290// An untrusted run never saves under the default branch: the cache main's
291// trusted runs restore is written by trusted runs only.
292func TestJobCacheOfUntrustedRuns(t *testing.T) {
293	c := newCIHarness(t, nil)
294	runner := c.registerRunner("alpha", "ubuntu-latest")
295	files := merge(map[string]string{".github/workflows/push.yml": "name: push\non: [push, workflow_dispatch]\njobs:\n  p:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"},
296		followWorkflow("follow", "    workflows: [push]\n    types: [requested]\n    branches: ['agent/**']"))
297	main := c.push("main", files, "")
298	job := c.finish(runner)
299	assert.True(t, job.GetTrusted())
300	assert.Equal(t, "refs/heads/main", job.GetCache().GetWriteRef(), "the owner's push of main")
301
302	// The agent's push of its branch: untrusted, saves under that branch only.
303	agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/"}, "proj")
304	c.pushAs(agent, "agent/x", map[string]string{"x.txt": "x\n"})
305	jobs := map[string]*pb.Job{}
306	for range 2 {
307		j := c.finish(runner)
308		jobs[c.ghContext(j)["event_name"].(string)] = j
309	}
310	job = jobs["push"]
311	require.NotNil(t, job)
312	assert.False(t, job.GetTrusted())
313	assert.Equal(t, "refs/untrusted/heads/agent/x", job.GetCache().GetWriteRef(), "in the namespace of untrusted entries")
314	assert.Equal(t, []string{"refs/untrusted/heads/agent/x", "refs/heads/agent/x", "refs/heads/main"}, job.GetCache().GetReadRefs())
315
316	// Its workflow_run runs on main and inherits the trust: read-only.
317	job = jobs["workflow_run"]
318	require.NotNil(t, job)
319	assert.Equal(t, "refs/heads/main", job.GetRef())
320	assert.False(t, job.GetTrusted())
321	assert.Empty(t, job.GetCache().GetWriteRef())
322	assert.Equal(t, []string{"refs/heads/main"}, job.GetCache().GetReadRefs())
323
324	// A dispatch on main whose tip an untrusted principal set: read-only.
325	require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
326		Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main, Actor: "claude", Trusted: false,
327	}))
328	_, err := c.dispatch(c.root, "push.yml", "main", nil)
329	require.NoError(t, err)
330	job = c.finish(runner)
331	assert.Equal(t, "workflow_dispatch", c.ghContext(job)["event_name"])
332	assert.False(t, job.GetTrusted())
333	assert.Empty(t, job.GetCache().GetWriteRef())
334}
335
336// The run pages show the merge request of a pull_request_target run, whose
337// ref is the default branch.
338func TestWebShowsTheMergeRequestOfAPullRequestTargetRun(t *testing.T) {
339	c := newCIHarness(t, nil)
340	b := browser{c.harness}
341	runner := c.registerRunner("alpha", "ubuntu-latest")
342	_, err := c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
343		Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
344	}))
345	require.NoError(t, err)
346	c.push("main", targetWF("", "x"), "")
347	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
348	c.push("feat", map[string]string{"f.txt": "f\n"}, "")
349	n := c.openMR(c.root, "feat")
350	c.finish(runner)
351	run := c.runs()[0]
352	require.Equal(t, "pull_request_target", run.GetEvent())
353
354	for _, page := range []string{"/proj/actions", fmt.Sprintf("/proj/actions/runs/%d", run.GetId())} {
355		res := b.get(page, nil)
356		require.Equal(t, 200, res.status, res.body)
357		assert.Contains(t, res.body, fmt.Sprintf(`href="/proj/merge-requests/%d"`, n), page)
358		assert.Contains(t, res.body, fmt.Sprintf(">#%d<", n), page)
359	}
360}
361
362// A trusted run never restores what an untrusted run saved: the owner pushing
363// on top of an agent's branch, or on the merge request the agent opened, gets
364// the trusted entries only.
365func TestJobCacheTrustedRunsNeverReadUntrustedEntries(t *testing.T) {
366	c := newCIHarness(t, nil)
367	runner := c.registerRunner("alpha", "ubuntu-latest")
368	files := pullWF("", "")
369	files[".github/workflows/push.yml"] = "on: push\njobs:\n  p:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
370	c.push("main", files, "")
371	c.finish(runner)
372	agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
373	c.pushAs(agent, "agent/x", map[string]string{"x.txt": "x\n"})
374	assert.Equal(t, "refs/untrusted/heads/agent/x", c.finish(runner).GetCache().GetWriteRef())
375
376	// The agent opens the request: an untrusted pull_request run.
377	c.openMR(c.api(agent), "agent/x")
378	job := c.finish(runner)
379	require.Equal(t, "pull_request", c.ghContext(job)["event_name"])
380	assert.False(t, job.GetTrusted())
381	assert.Equal(t, "refs/untrusted/pull/1/merge", job.GetCache().GetWriteRef())
382	assert.Equal(t, []string{"refs/untrusted/pull/1/merge", "refs/pull/1/merge", "refs/heads/main"}, job.GetCache().GetReadRefs())
383
384	// The owner pushes on top: the push run and the synchronize are trusted
385	// and see no untrusted entry, at any level.
386	c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
387	jobs := map[string]*pb.Job{}
388	for range 2 {
389		j := c.finish(runner)
390		jobs[c.ghContext(j)["event_name"].(string)] = j
391	}
392	push, pull := jobs["push"], jobs["pull_request"]
393	require.NotNil(t, push)
394	require.NotNil(t, pull)
395	assert.True(t, push.GetTrusted())
396	assert.Equal(t, "refs/heads/agent/x", push.GetCache().GetWriteRef())
397	assert.Equal(t, []string{"refs/heads/agent/x", "refs/heads/main"}, push.GetCache().GetReadRefs())
398	assert.True(t, pull.GetTrusted())
399	assert.Equal(t, "refs/pull/1/merge", pull.GetCache().GetWriteRef())
400	assert.Equal(t, []string{"refs/pull/1/merge", "refs/heads/main"}, pull.GetCache().GetReadRefs())
401	for _, j := range jobs {
402		for _, r := range j.GetCache().GetReadRefs() {
403			assert.False(t, strings.HasPrefix(r, "refs/untrusted/"), r)
404		}
405	}
406}

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 44

↑↓ moveenter openesc close