1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "strings"
11 "testing"
12
13 "connectrpc.com/connect"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 pb "bnema.dev/ermite-api/gen/ermite"
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// CI-3 D5/D6, AC5: the pull_request event of merge requests, the refs
23// refs/pull/<n>/{head,merge} and the REST files endpoint paths-filter reads.
24
25const pullWorkflow = `name: pr
26on:
27 pull_request:
28%s
29jobs:
30 j:
31 runs-on: ubuntu-latest
32%s
33 steps:
34 - run: echo hi
35`
36
37func pullWF(on, jobExtra string) map[string]string {
38 return map[string]string{".github/workflows/pr.yml": fmt.Sprintf(pullWorkflow, on, jobExtra)}
39}
40
41// openMR opens a merge request from source into main as client.
42func (c *ciHarness) openMR(cl *clients, source string) int64 {
43 c.t.Helper()
44 res, err := cl.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
45 Repo: c.repo, Title: "Title of " + source, Description: "About " + source, SourceBranch: source, TargetBranch: "main",
46 }))
47 require.NoError(c.t, err)
48 return res.Msg.GetMergeRequest().GetNumber()
49}
50
51func (c *ciHarness) ghContext(j *pb.Job) map[string]any {
52 c.t.Helper()
53 var ctxs map[string]map[string]any
54 require.NoError(c.t, json.Unmarshal([]byte(j.GetContexts()), &ctxs))
55 return ctxs["github"]
56}
57
58func (c *ciHarness) ref(name string) string {
59 c.t.Helper()
60 out, err := c.git(c.repoDirOf(), "rev-parse", "--verify", "-q", name)
61 if err != nil {
62 return ""
63 }
64 return strings.TrimSpace(out)
65}
66
67func (c *ciHarness) repoDirOf() string { return c.repoDir("proj") }
68
69// finish runs the job of the next run to success.
70func (c *ciHarness) finish(r *fakeRunner) *pb.Job {
71 c.t.Helper()
72 j := r.await()
73 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
74 return j
75}
76
77func TestPullRequestOpenedSynchronizeRuns(t *testing.T) {
78 c := newCIHarness(t, nil)
79 runner := c.registerRunner("alpha", "ubuntu-latest")
80 c.push("main", pullWF("", ""), "")
81 c.push("main", map[string]string{"a.txt": "a\n"}, "")
82 main := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
83 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
84 feat1 := c.push("feat", map[string]string{"b.txt": "b\n"}, "")
85
86 n := c.openMR(c.root, "feat")
87 job := c.finish(runner)
88
89 // The refs: head is the branch, merge a test merge commit of main and feat.
90 assert.Equal(t, feat1, c.ref(domain.PullHeadRef(n)))
91 merge := c.ref(domain.PullMergeRef(n))
92 require.NotEmpty(t, merge)
93 assert.Equal(t, main+" "+feat1, strings.TrimSpace(c.git2("log", "-1", "--format=%P", merge)), "parents: base then head")
94 assert.Equal(t, "Merge "+feat1+" into "+main, strings.TrimSpace(c.git2("log", "-1", "--format=%s", merge)))
95 assert.Contains(t, c.git2("ls-tree", "--name-only", merge), "a.txt", "the tree has main's file")
96 assert.Contains(t, c.git2("ls-tree", "--name-only", merge), "b.txt", "and feat's")
97
98 runs := c.runs()
99 require.Len(t, runs, 1)
100 assert.Equal(t, "pull_request", runs[0].GetEvent())
101 assert.Equal(t, "refs/pull/1/merge", runs[0].GetRef())
102 assert.Equal(t, merge, runs[0].GetSha())
103 assert.True(t, runs[0].GetTrusted())
104 assert.Equal(t, "Title of feat", runs[0].GetTitle())
105
106 // Checks attach to the head, not to the merge commit.
107 require.Len(t, c.checks(feat1), 1)
108 assert.Equal(t, "j", c.checks(feat1)[0].GetName())
109 assert.Empty(t, c.checks(merge))
110
111 gh := c.ghContext(job)
112 assert.Equal(t, "pull_request", gh["event_name"])
113 assert.Equal(t, "refs/pull/1/merge", gh["ref"])
114 assert.Equal(t, "1/merge", gh["ref_name"])
115 assert.Equal(t, merge, gh["sha"])
116 assert.Equal(t, "feat", gh["head_ref"])
117 assert.Equal(t, "main", gh["base_ref"])
118 assert.Equal(t, merge, job.GetSha())
119 ev := gh["event"].(map[string]any)
120 assert.Equal(t, "opened", ev["action"])
121 assert.EqualValues(t, 1, ev["number"])
122 pr := ev["pull_request"].(map[string]any)
123 assert.EqualValues(t, 1, pr["number"])
124 assert.Equal(t, "open", pr["state"])
125 assert.Equal(t, "Title of feat", pr["title"])
126 assert.Equal(t, "About feat", pr["body"])
127 assert.Equal(t, false, pr["merged"])
128 assert.Equal(t, merge, pr["merge_commit_sha"])
129 assert.Equal(t, publicOrigin+"/owner/proj/merge-requests/1", pr["html_url"])
130 head, base := pr["head"].(map[string]any), pr["base"].(map[string]any)
131 assert.Equal(t, "feat", head["ref"])
132 assert.Equal(t, feat1, head["sha"])
133 assert.Equal(t, "main", base["ref"])
134 assert.Equal(t, main, base["sha"])
135 assert.Equal(t, "owner/proj", head["repo"].(map[string]any)["full_name"])
136 assert.Equal(t, "owner/proj", ev["repository"].(map[string]any)["full_name"])
137 assert.NotEmpty(t, ev["sender"].(map[string]any)["login"])
138
139 // A push to the source branch is a synchronize run, with the new merge.
140 feat2 := c.push("feat", map[string]string{"c.txt": "c\n"}, "")
141 job = c.finish(runner)
142 c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
143 assert.Equal(t, feat2, c.ref(domain.PullHeadRef(n)))
144 merge2 := c.ref(domain.PullMergeRef(n))
145 assert.NotEqual(t, merge, merge2)
146 gh = c.ghContext(job)
147 assert.Equal(t, merge2, gh["sha"])
148 ev = gh["event"].(map[string]any)
149 assert.Equal(t, "synchronize", ev["action"])
150 assert.Equal(t, feat1, ev["before"])
151 assert.Equal(t, feat2, ev["after"])
152 assert.Len(t, c.checks(feat2), 1)
153}
154
155// git2 runs git in the bare repository.
156func (c *ciHarness) git2(args ...string) string { return c.bareGit("proj", args...) }
157
158func TestPullRequestConflictingMergeRunsNothing(t *testing.T) {
159 c := newCIHarness(t, nil)
160 runner := c.registerRunner("alpha", "ubuntu-latest")
161 files := pullWF("", "")
162 files["a.txt"] = "base\n"
163 c.push("main", files, "")
164 c.mustGit(c.wt, "checkout", "-q", "-b", "conflict")
165 c.push("conflict", map[string]string{"a.txt": "feature\n"}, "")
166 c.mustGit(c.wt, "checkout", "-q", "main")
167 c.push("main", map[string]string{"a.txt": "main\n"}, "")
168 c.mustGit(c.wt, "checkout", "-q", "-b", "clean", "main")
169 c.push("clean", map[string]string{"z.txt": "z\n"}, "")
170
171 n1 := c.openMR(c.root, "conflict")
172 n2 := c.openMR(c.root, "clean")
173 job := c.finish(runner) // the one run is the clean request's
174 assert.EqualValues(t, 2, c.ghContext(job)["event"].(map[string]any)["number"])
175 assert.NotEmpty(t, c.ref(domain.PullHeadRef(n1)), "the head ref is kept")
176 assert.Empty(t, c.ref(domain.PullMergeRef(n1)), "no merge ref when the merge conflicts")
177 assert.NotEmpty(t, c.ref(domain.PullMergeRef(n2)))
178 require.Len(t, c.runs(), 1)
179
180 // A push that does not fix the conflict makes no run either.
181 c.mustGit(c.wt, "checkout", "-q", "conflict")
182 c.push("conflict", map[string]string{"other.txt": "o\n"}, "")
183 c.mustGit(c.wt, "checkout", "-q", "-b", "later", "main")
184 c.push("later", map[string]string{"l.txt": "l\n"}, "")
185 c.openMR(c.root, "later")
186 job = c.finish(runner)
187 assert.EqualValues(t, 3, c.ghContext(job)["event"].(map[string]any)["number"])
188 assert.Len(t, c.runs(), 2)
189}
190
191func TestPullRequestTypesAndClosed(t *testing.T) {
192 c := newCIHarness(t, nil)
193 runner := c.registerRunner("alpha", "ubuntu-latest")
194 // closed only: opened and synchronize start nothing.
195 c.push("main", pullWF(" types: [closed]", ""), "")
196 c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
197 c.push("f1", map[string]string{"1.txt": "1\n"}, "")
198 n1 := c.openMR(c.root, "f1")
199 f1 := c.push("f1", map[string]string{"1b.txt": "1\n"}, "")
200 c.mustGit(c.wt, "checkout", "-q", "main")
201 c.mustGit(c.wt, "checkout", "-q", "-b", "f2")
202 f2 := c.push("f2", map[string]string{"2.txt": "2\n"}, "")
203 n2 := c.openMR(c.root, "f2")
204 // The refs follow the MR creation and the push asynchronously: wait for
205 // both of each request, at the latest source sha.
206 c.waitFor("the refs of both requests", func() bool {
207 mergesHead := func(n int64, head string) bool {
208 m := c.ref(domain.PullMergeRef(n))
209 parents, err := c.git(c.repoDirOf(), "log", "-1", "--format=%P", m) // no require: runs in the poller
210 return m != "" && err == nil && strings.HasSuffix(strings.TrimSpace(parents), " "+head)
211 }
212 return c.ref(domain.PullHeadRef(n1)) == f1 && mergesHead(n1, f1) &&
213 c.ref(domain.PullHeadRef(n2)) == f2 && mergesHead(n2, f2)
214 })
215 require.Empty(t, c.runs(), "no run for opened and synchronize")
216 head1 := c.ref(domain.PullHeadRef(n1))
217 merge1 := c.ref(domain.PullMergeRef(n1))
218 require.Equal(t, f1, head1)
219
220 // Closed without merging: merged false, the last computed merge commit.
221 _, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n1}))
222 require.NoError(t, err)
223 job := c.finish(runner)
224 gh := c.ghContext(job)
225 ev := gh["event"].(map[string]any)
226 assert.Equal(t, "closed", ev["action"])
227 pr := ev["pull_request"].(map[string]any)
228 assert.Equal(t, false, pr["merged"])
229 assert.Equal(t, "closed", pr["state"])
230 assert.Equal(t, merge1, gh["sha"], "the last computed merge commit")
231 assert.Equal(t, merge1, pr["merge_commit_sha"])
232 assert.Equal(t, "refs/pull/1/merge", gh["ref"])
233
234 // Merged: merged true, merge_commit_sha is the commit the merge made; the
235 // source branch is deleted and the refs are not computed again.
236 head2 := c.ref(domain.PullHeadRef(n2))
237 merge2 := c.ref(domain.PullMergeRef(n2))
238 _, err = c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
239 Repo: "proj", Number: n2, HeadId: head2, DeleteSourceBranch: true,
240 }))
241 require.NoError(t, err)
242 job = c.finish(runner)
243 gh = c.ghContext(job)
244 ev = gh["event"].(map[string]any)
245 pr = ev["pull_request"].(map[string]any)
246 assert.Equal(t, "closed", ev["action"])
247 assert.Equal(t, true, pr["merged"])
248 assert.Equal(t, head2, pr["merge_commit_sha"], "fast-forward: the merged commit is the head")
249 assert.Equal(t, merge2, gh["sha"], "github.sha stays the last test merge")
250 assert.Equal(t, head2, pr["head"].(map[string]any)["sha"])
251 assert.Equal(t, head2, c.ref(domain.PullHeadRef(n2)))
252 assert.Equal(t, merge2, c.ref(domain.PullMergeRef(n2)))
253 assert.Len(t, c.checks(head2), 1)
254}
255
256func TestPullRequestPathsAndBranchesFilters(t *testing.T) {
257 c := newCIHarness(t, nil)
258 runner := c.registerRunner("alpha", "ubuntu-latest")
259 c.push("main", pullWF(" branches: [main]\n paths: ['docs/**']", ""), "")
260 c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
261 c.push("f1", map[string]string{"src/x.go": "x\n"}, "")
262 n := c.openMR(c.root, "f1")
263 // Another request whose changes match: the only run is its.
264 c.mustGit(c.wt, "checkout", "-q", "-b", "f2", "main")
265 c.push("f2", map[string]string{"docs/readme.md": "d\n"}, "")
266 n2 := c.openMR(c.root, "f2")
267 job := c.finish(runner)
268 assert.EqualValues(t, n2, c.ghContext(job)["event"].(map[string]any)["number"])
269 require.Len(t, c.runs(), 1)
270
271 // A later push that adds a docs file to the first request: the files are
272 // those between the merge base and the head, so it runs now.
273 c.mustGit(c.wt, "checkout", "-q", "f1")
274 c.push("f1", map[string]string{"docs/new.md": "n\n"}, "")
275 job = c.finish(runner)
276 assert.EqualValues(t, n, c.ghContext(job)["event"].(map[string]any)["number"])
277 assert.Equal(t, "synchronize", c.ghContext(job)["event"].(map[string]any)["action"])
278}
279
280func TestPullRequestTrustFollowsTheActor(t *testing.T) {
281 c := newCIHarness(t, nil)
282 runner := c.registerRunner("alpha", "ubuntu-latest")
283 c.push("main", pullWF("", ""), "")
284 c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
285 c.push("agent/x", map[string]string{"x.txt": "x\n"}, "")
286 secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
287
288 // Opened by a branch-limited token: untrusted, no secrets, read-only token.
289 c.openMR(c.api(secret), "agent/x")
290 job := c.finish(runner)
291 assert.False(t, job.GetTrusted())
292 runs := c.runs()
293 require.Len(t, runs, 1)
294 assert.False(t, runs[0].GetTrusted())
295 assert.Equal(t, "claude", runs[0].GetActor())
296
297 // A synchronize pushed by the owner (root token) is trusted.
298 c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
299 job = c.finish(runner)
300 assert.True(t, job.GetTrusted())
301 assert.Equal(t, "root", c.runs()[0].GetActor())
302
303 // A synchronize pushed by the limited token is not.
304 c.writeFile(c.wt, "z.txt", []byte("z\n"))
305 c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
306 job = c.finish(runner)
307 assert.False(t, job.GetTrusted())
308}
309
310// CI-4 D1: the workflow of a pull_request run comes from the head, so the run
311// is trusted only if the head was pushed by a trusted principal too.
312func TestPullRequestTrustFollowsTheHeadsPusher(t *testing.T) {
313 c := newCIHarness(t, nil)
314 runner := c.registerRunner("alpha", "ubuntu-latest")
315 c.push("main", pullWF(" types: [opened, synchronize, closed]", ""), "")
316 secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
317 agentPush := func(branch, file string) string {
318 c.writeFile(c.wt, file, []byte(file+"\n"))
319 c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), branch)
320 return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
321 }
322
323 // The owner opens the request of a branch the agent pushed: not trusted.
324 c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
325 agentPush("agent/x", "x.txt")
326 n := c.openMR(c.root, "agent/x")
327 job := c.finish(runner)
328 assert.False(t, job.GetTrusted(), "the agent's workflow does not get the owner's secrets")
329 run := c.runs()[0]
330 assert.False(t, run.GetTrusted())
331 assert.Equal(t, "root", run.GetActor(), "the actor is still who opened it")
332
333 // The owner pushes on top: the synchronize is trusted.
334 c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
335 assert.True(t, c.finish(runner).GetTrusted())
336
337 // The agent pushes again: not trusted.
338 agentPush("agent/x", "z.txt")
339 assert.False(t, c.finish(runner).GetTrusted())
340
341 // The owner closes it, the head being the agent's: not trusted.
342 _, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
343 require.NoError(t, err)
344 job = c.finish(runner)
345 assert.Equal(t, "closed", c.ghContext(job)["event"].(map[string]any)["action"])
346 assert.False(t, job.GetTrusted())
347
348 // The owner's own branch: trusted.
349 c.mustGit(c.wt, "checkout", "-q", "-b", "own", "main")
350 c.push("own", map[string]string{"o.txt": "o\n"}, "")
351 c.openMR(c.root, "own")
352 assert.True(t, c.finish(runner).GetTrusted())
353
354 // A head whose record names another commit (the branch moved since, or the
355 // owner's push of it was refused), or that has none: not trusted.
356 c.mustGit(c.wt, "checkout", "-q", "-b", "other", "main")
357 c.push("other", map[string]string{"p.txt": "p\n"}, "")
358 require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
359 Repo: c.name("proj"), Ref: "refs/heads/other", Sha: "1111111111111111111111111111111111111111", Actor: "root", Trusted: true,
360 }))
361 c.openMR(c.root, "other")
362 assert.False(t, c.finish(runner).GetTrusted(), "the record names another commit")
363
364 c.mustGit(c.wt, "checkout", "-q", "-b", "none", "main")
365 c.push("none", map[string]string{"q.txt": "q\n"}, "")
366 require.NoError(t, c.forge.store.CI().DeleteRefTip(c.ctx, c.name("proj"), "refs/heads/none"))
367 c.openMR(c.root, "none")
368 assert.False(t, c.finish(runner).GetTrusted(), "no record")
369}
370
371// Merging takes the head into the target: the closing of a merged request has
372// the merger's trust, even with the source branch (and its record) deleted.
373func TestPullRequestMergedByTheOwnerIsTrusted(t *testing.T) {
374 c := newCIHarness(t, nil)
375 runner := c.registerRunner("alpha", "ubuntu-latest")
376 c.push("main", pullWF(" types: [closed]", ""), "")
377 secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
378 c.mustGit(c.wt, "checkout", "-q", "-b", "agent/m")
379 c.writeFile(c.wt, "m.txt", []byte("m\n"))
380 c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/m")
381 n := c.openMR(c.root, "agent/m")
382 c.waitFor("the head ref", func() bool { return c.ref(domain.PullHeadRef(n)) != "" })
383 _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
384 Repo: "proj", Number: n, HeadId: c.ref(domain.PullHeadRef(n)), DeleteSourceBranch: true,
385 }))
386 require.NoError(t, err)
387 job := c.finish(runner)
388 assert.Equal(t, true, c.ghContext(job)["event"].(map[string]any)["pull_request"].(map[string]any)["merged"])
389 assert.True(t, job.GetTrusted())
390}
391
392// openMRInto opens a merge request from source into target as the owner.
393func (c *ciHarness) openMRInto(source, target string) int64 {
394 c.t.Helper()
395 res, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
396 Repo: c.repo, Title: "Title of " + source, SourceBranch: source, TargetBranch: target,
397 }))
398 require.NoError(c.t, err)
399 return res.Msg.GetMergeRequest().GetNumber()
400}
401
402// The merge commit carries the target's files too: the base of the event must
403// have been pushed by a trusted principal as well, for every activity.
404func TestPullRequestTrustFollowsTheBasesPusher(t *testing.T) {
405 c, hook := newHookedHarness(t)
406 runner := c.registerRunner("alpha", "ubuntu-latest")
407 c.push("main", pullWF(" types: [opened, synchronize, closed]", ""), "")
408 secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
409 c.mustGit(c.wt, "checkout", "-q", "-b", "agent/base")
410 c.writeFile(c.wt, "a.txt", []byte("a\n"))
411 c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/base")
412 c.mustGit(c.wt, "checkout", "-q", "-b", "feat", "main")
413 c.push("feat", map[string]string{"f.txt": "f\n"}, "")
414
415 // The owner's branch into the agent's: not trusted, opened or synchronized.
416 n := c.openMRInto("feat", "agent/base")
417 assert.False(t, c.finish(runner).GetTrusted(), "opened on the agent's target")
418 c.push("feat", map[string]string{"g.txt": "g\n"}, "")
419 job := c.finish(runner)
420 assert.Equal(t, "synchronize", c.ghContext(job)["event"].(map[string]any)["action"])
421 assert.False(t, job.GetTrusted(), "a synchronize by the owner on the agent's target")
422
423 // The owner pushes the target: its tip is the owner's, so are the next runs.
424 c.mustGit(c.wt, "checkout", "-q", "agent/base")
425 c.push("agent/base", map[string]string{"b.txt": "b\n"}, "")
426 c.mustGit(c.wt, "checkout", "-q", "feat")
427 c.push("feat", map[string]string{"h.txt": "h\n"}, "")
428 assert.True(t, c.finish(runner).GetTrusted(), "owner's source, owner's target")
429
430 // The agent pushes the target again; the owner closes: the run is the last
431 // merge (refreshed on the agent's tip), not trusted.
432 c.mustGit(c.wt, "checkout", "-q", "agent/base")
433 c.writeFile(c.wt, "c.txt", []byte("c\n"))
434 c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/base")
435 agentTip := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
436 c.waitFor("the merge on the agent's tip", func() bool {
437 m := c.ref(domain.PullMergeRef(n))
438 return m != "" && strings.HasPrefix(c.parents(m), agentTip+" ")
439 })
440 _, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
441 require.NoError(t, err)
442 job = c.finish(runner)
443 assert.Equal(t, "closed", c.ghContext(job)["event"].(map[string]any)["action"])
444 assert.False(t, job.GetTrusted(), "closed on a merge of the agent's target")
445
446 // The trust is that of the snapshot: the owner moving the agent's target
447 // after the opening does not make the opening trusted.
448 c.mustGit(c.wt, "checkout", "-q", "-b", "feat2", "main")
449 c.push("feat2", map[string]string{"k.txt": "k\n"}, "")
450 reached, release := hook.block(t, "event")
451 c.openMRInto("feat2", "agent/base")
452 await(t, reached, "the opening event")
453 c.mustGit(c.wt, "checkout", "-q", "agent/base")
454 c.push("agent/base", map[string]string{"d.txt": "d\n"}, "")
455 release()
456 job = c.finish(runner)
457 assert.Equal(t, agentTip+" ", c.parents(c.ghContext(job)["sha"].(string))[:41], "the run is for the snapshot's base")
458 assert.False(t, job.GetTrusted())
459
460 // And the other way: the agent moving the owner's target after the opening
461 // does not make it untrusted.
462 c.mustGit(c.wt, "checkout", "-q", "-b", "feat3", "main")
463 c.push("feat3", map[string]string{"m.txt": "m\n"}, "")
464 ownerTip := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "agent/base"))
465 reached, release = hook.block(t, "event")
466 c.openMRInto("feat3", "agent/base")
467 await(t, reached, "the opening event")
468 c.mustGit(c.wt, "checkout", "-q", "agent/base")
469 c.writeFile(c.wt, "e.txt", []byte("e\n"))
470 c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/base")
471 release()
472 job = c.finish(runner)
473 assert.Equal(t, ownerTip+" ", c.parents(c.ghContext(job)["sha"].(string))[:41])
474 assert.True(t, job.GetTrusted())
475}
476
477// A closed request runs its last merge commit, which can be of an older head
478// than the one its closing is for: the trust of that head says nothing of it.
479func TestPullRequestClosedOnAMergeOfAnOlderHeadIsNotTrusted(t *testing.T) {
480 c := newCIHarness(t, nil)
481 runner := c.registerRunner("alpha", "ubuntu-latest")
482 files := pullWF(" types: [closed]", "")
483 files["c.txt"] = "base\n"
484 c.push("main", files, "")
485 secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
486 c.mustGit(c.wt, "checkout", "-q", "-b", "agent/o")
487 c.writeFile(c.wt, "x.txt", []byte("x\n"))
488 c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/o")
489 agentHead := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
490 n := c.openMR(c.root, "agent/o")
491
492 // The target moves, the agent's head still merges; then the owner pushes a
493 // head that conflicts: the merge ref keeps the merge of the agent's head.
494 c.mustGit(c.wt, "checkout", "-q", "main")
495 main2 := c.push("main", map[string]string{"c.txt": "main\n"}, "")
496 c.waitFor("the merge of the agent's head on the new target", func() bool {
497 m := c.ref(domain.PullMergeRef(n))
498 return m != "" && c.parents(m) == main2+" "+agentHead
499 })
500 c.mustGit(c.wt, "checkout", "-q", "agent/o")
501 ownerHead := c.push("agent/o", map[string]string{"c.txt": "owner\n"}, "")
502 c.waitFor("the head ref", func() bool { return c.ref(domain.PullHeadRef(n)) == ownerHead })
503 require.Equal(t, main2+" "+agentHead, c.parents(c.ref(domain.PullMergeRef(n))))
504
505 _, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
506 require.NoError(t, err)
507 job := c.finish(runner)
508 assert.Equal(t, c.ref(domain.PullMergeRef(n)), c.ghContext(job)["sha"], "the run is the merge of the agent's head")
509 assert.False(t, job.GetTrusted())
510}
511
512func TestPullRefsAreServedHiddenAndNotPushable(t *testing.T) {
513 c := newCIHarness(t, nil)
514 runner := c.registerRunner("alpha", "ubuntu-latest")
515 c.push("main", pullWF("", " permissions:\n contents: write"), "")
516 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
517 feat := c.push("feat", map[string]string{"f.txt": "f\n"}, "")
518 n := c.openMR(c.root, "feat")
519 job := runner.await()
520 defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
521 merge := c.ref(domain.PullMergeRef(n))
522
523 // Fetchable, as checkout does.
524 dir := c.t.TempDir()
525 c.mustGit(dir, "init", "-q")
526 c.mustGit(dir, "fetch", "-q", c.urlAuth(adminToken, "/proj.git"), "refs/pull/1/merge:refs/remotes/pull/1/merge", "refs/pull/1/head:refs/remotes/pull/1/head")
527 assert.Equal(t, merge, strings.TrimSpace(c.mustGit(dir, "rev-parse", "refs/remotes/pull/1/merge")))
528 assert.Equal(t, feat, strings.TrimSpace(c.mustGit(dir, "rev-parse", "refs/remotes/pull/1/head")))
529
530 // Hidden from the branch and tag lists.
531 res, err := c.root.git.ListRefs(c.ctx, connect.NewRequest(&pb.ListRefsRequest{Repo: "proj"}))
532 require.NoError(t, err)
533 for _, r := range res.Msg.GetRefs() {
534 assert.NotContains(t, r.GetName(), "refs/pull/")
535 }
536
537 // Refused on push: the owner, a stored token with repo:write, and the job
538 // token with contents: write (the workflow guard does not reach it first).
539 other, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), nil, "proj")
540 for _, tc := range []struct{ name, url string }{
541 {"owner", c.urlAuth(adminToken, "/proj.git")},
542 {"token", c.urlAuth(other, "/proj.git")},
543 } {
544 for _, ref := range []string{"refs/pull/1/head", "refs/pull/9/head", "refs/pull/1/merge", "refs/pull/x"} {
545 out, err := c.git(c.wt, "push", tc.url, "HEAD:"+ref)
546 require.Error(t, err, "%s pushes %s: %s", tc.name, ref, out)
547 }
548 out, err := c.git(c.wt, "push", "--delete", tc.url, "refs/pull/1/merge")
549 require.Error(t, err, out)
550 }
551 out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/pull/1/head")
552 require.Error(t, err, out)
553 assert.Equal(t, merge, c.ref(domain.PullMergeRef(n)))
554 assert.Equal(t, feat, c.ref(domain.PullHeadRef(n)))
555 assert.Empty(t, c.ref("refs/pull/9/head"))
556}
557
558func (c *ciHarness) apiGet(token, path string) (int, http.Header, string) {
559 c.t.Helper()
560 req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.url("/-/github/api"+path), nil)
561 require.NoError(c.t, err)
562 if token != "" {
563 req.Header.Set("Authorization", "Bearer "+token)
564 }
565 res, err := http.DefaultClient.Do(req)
566 require.NoError(c.t, err)
567 defer func() { _ = res.Body.Close() }()
568 b, err := io.ReadAll(res.Body)
569 require.NoError(c.t, err)
570 return res.StatusCode, res.Header, string(b)
571}
572
573func TestPullFilesEndpoint(t *testing.T) {
574 c := newCIHarness(t, nil)
575 runner := c.registerRunner("alpha", "ubuntu-latest")
576 c.push("main", pullWF("", " permissions:\n contents: read\n pull-requests: read"), "")
577 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
578 files := map[string]string{}
579 for i := range 5 {
580 files[fmt.Sprintf("d/f%d.txt", i)] = fmt.Sprintf("line %d\n", i)
581 }
582 c.writeFile(c.wt, "d/gone.txt", []byte("x\n"))
583 for name, content := range files {
584 c.writeFile(c.wt, name, []byte(content))
585 }
586 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "feat")
587 c.openMR(c.root, "feat")
588 job := runner.await() // the job token lives while the job runs
589 defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
590
591 type entry struct {
592 Filename, Status, Sha, Patch string
593 Additions, Deletions, Changes int
594 }
595 list := func(token, q string) (int, http.Header, []entry) {
596 code, hdr, body := c.apiGet(token, "/repos/owner/proj/pulls/1/files"+q)
597 var es []entry
598 if code == http.StatusOK {
599 require.NoError(t, json.Unmarshal([]byte(body), &es), body)
600 }
601 return code, hdr, es
602 }
603
604 code, hdr, es := list(adminToken, "?per_page=4&page=1")
605 require.Equal(t, http.StatusOK, code)
606 require.Len(t, es, 4)
607 assert.Equal(t, "d/f0.txt", es[0].Filename)
608 assert.Equal(t, "added", es[0].Status)
609 assert.Equal(t, 1, es[0].Additions)
610 assert.Equal(t, 1, es[0].Changes)
611 assert.Equal(t, "@@ -0,0 +1 @@\n+line 0", es[0].Patch)
612 assert.Len(t, es[0].Sha, 40)
613 link := hdr.Get("Link")
614 assert.Contains(t, link, `page=2>; rel="next"`)
615 assert.Contains(t, link, `page=2>; rel="last"`)
616 assert.NotContains(t, link, `rel="prev"`)
617
618 code, hdr, es = list(adminToken, "?per_page=4&page=2")
619 require.Equal(t, http.StatusOK, code)
620 require.Len(t, es, 2, "6 files in all")
621 assert.NotContains(t, hdr.Get("Link"), `rel="next"`)
622 assert.Contains(t, hdr.Get("Link"), `rel="prev"`)
623 assert.Contains(t, hdr.Get("Link"), `rel="first"`)
624
625 code, _, es = list(adminToken, "?page=9")
626 require.Equal(t, http.StatusOK, code)
627 assert.Empty(t, es)
628 for _, q := range []string{"?page=9223372036854775807", "?per_page=100&page=9223372036854775807", "?page=2305843009213693953&per_page=4"} {
629 code, _, es = list(adminToken, q)
630 require.Equal(t, http.StatusOK, code, q)
631 assert.Empty(t, es, q)
632 }
633 code, hdr, es = list(adminToken, "")
634 require.Equal(t, http.StatusOK, code)
635 assert.Len(t, es, 6, "30 per page by default")
636 assert.Empty(t, hdr.Get("Link"))
637 code, _, es = list(adminToken, "?per_page=1000")
638 require.Equal(t, http.StatusOK, code)
639 assert.Len(t, es, 6, "100 at most")
640
641 // The job token with pull-requests: read; the answer is 404 for a request
642 // that does not exist and for anonymous callers on a private repository.
643 code, _, es = list(job.GetGithubToken(), "")
644 require.Equal(t, http.StatusOK, code)
645 assert.Len(t, es, 6)
646 code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/7/files")
647 assert.Equal(t, http.StatusNotFound, code)
648 code, _, _ = c.apiGet("", "/repos/owner/proj/pulls/1/files")
649 assert.Equal(t, http.StatusNotFound, code)
650 code, _, _ = c.apiGet(adminToken, "/repos/owner/proj/pulls/1")
651 assert.Equal(t, http.StatusNotFound, code, "GET /pulls/{n} is not served: paths-filter does not call it")
652}
653
654// A job token with pull-requests: read and contents: none reads the merge
655// requests of its own private repository, and only of that one.
656func TestPullFilesWithPullRequestsPermissionOnly(t *testing.T) {
657 c := newCIHarness(t, nil)
658 runner := c.registerRunner("alpha", "ubuntu-latest")
659 c.push("main", pullWF("", " permissions:\n contents: none\n pull-requests: read"), "")
660 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
661 c.push("feat", map[string]string{"f.txt": "f\n"}, "")
662 c.openMR(c.root, "feat")
663 job := runner.await()
664 defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
665 code, _, body := c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/1/files")
666 assert.Equal(t, http.StatusOK, code, body)
667 assert.Contains(t, body, "f.txt")
668 // Not the repository itself (contents: none) and not another repository.
669 code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/proj")
670 assert.Equal(t, http.StatusNotFound, code)
671 _, err := c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("other"), Visibility: domain.VisibilityPrivate})
672 require.NoError(t, err)
673 code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/other/pulls/1/files")
674 assert.Equal(t, http.StatusNotFound, code)
675}
676
677// The merge requests of a public repository are read by anyone, except by the
678// holder of an archive token, which reads one archive and nothing else.
679func TestPullFilesOfAPublicRepositoryAreNotReadByAnArchiveToken(t *testing.T) {
680 c := newCIHarness(t, nil)
681 c.push("main", pullWF("", ""), "")
682 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
683 c.push("feat", map[string]string{"f.txt": "f\n"}, "")
684 c.openMR(c.root, "feat")
685 public := domain.VisibilityPublic
686 _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.name("proj"), ports.UpdateRepoParams{Visibility: &public})
687 require.NoError(t, err)
688
689 code, _, body := c.apiGet("", "/repos/owner/proj/pulls/1/files")
690 require.Equal(t, http.StatusOK, code, body)
691 assert.Contains(t, body, "f.txt", "anyone reads a public repository")
692
693 archive := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "a", Repo: c.name("proj"), Archive: true, Sha: strings.Repeat("a", 40)})
694 require.True(t, archive.ViaJobToken(), "the archive token passes ViaJobToken")
695 _, _, err = c.forge.CI.ListPullFiles(c.ctx, archive, "owner", "proj", 1, 1, 30)
696 assert.ErrorIs(t, err, domain.ErrNotFound, "but it reads no merge request")
697}
698
699func TestPullFilesNeedsPullRequestsPermission(t *testing.T) {
700 c := newCIHarness(t, nil)
701 runner := c.registerRunner("alpha", "ubuntu-latest")
702 c.push("main", pullWF("", ""), "") // the default permissions: contents read only
703 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
704 c.push("feat", map[string]string{"f.txt": "f\n"}, "")
705 c.openMR(c.root, "feat")
706 job := runner.await()
707 defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
708 code, _, body := c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/1/files")
709 assert.Equal(t, http.StatusForbidden, code, body)
710 assert.Contains(t, body, "Resource not accessible by integration")
711}