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}