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
310func TestPullRefsAreServedHiddenAndNotPushable(t *testing.T) {
311 c := newCIHarness(t, nil)
312 runner := c.registerRunner("alpha", "ubuntu-latest")
313 c.push("main", pullWF("", " permissions:\n contents: write"), "")
314 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
315 feat := c.push("feat", map[string]string{"f.txt": "f\n"}, "")
316 n := c.openMR(c.root, "feat")
317 job := runner.await()
318 defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
319 merge := c.ref(domain.PullMergeRef(n))
320
321 // Fetchable, as checkout does.
322 dir := c.t.TempDir()
323 c.mustGit(dir, "init", "-q")
324 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")
325 assert.Equal(t, merge, strings.TrimSpace(c.mustGit(dir, "rev-parse", "refs/remotes/pull/1/merge")))
326 assert.Equal(t, feat, strings.TrimSpace(c.mustGit(dir, "rev-parse", "refs/remotes/pull/1/head")))
327
328 // Hidden from the branch and tag lists.
329 res, err := c.root.git.ListRefs(c.ctx, connect.NewRequest(&pb.ListRefsRequest{Repo: "proj"}))
330 require.NoError(t, err)
331 for _, r := range res.Msg.GetRefs() {
332 assert.NotContains(t, r.GetName(), "refs/pull/")
333 }
334
335 // Refused on push: the owner, a stored token with repo:write, and the job
336 // token with contents: write (the workflow guard does not reach it first).
337 other, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), nil, "proj")
338 for _, tc := range []struct{ name, url string }{
339 {"owner", c.urlAuth(adminToken, "/proj.git")},
340 {"token", c.urlAuth(other, "/proj.git")},
341 } {
342 for _, ref := range []string{"refs/pull/1/head", "refs/pull/9/head", "refs/pull/1/merge", "refs/pull/x"} {
343 out, err := c.git(c.wt, "push", tc.url, "HEAD:"+ref)
344 require.Error(t, err, "%s pushes %s: %s", tc.name, ref, out)
345 }
346 out, err := c.git(c.wt, "push", "--delete", tc.url, "refs/pull/1/merge")
347 require.Error(t, err, out)
348 }
349 out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/pull/1/head")
350 require.Error(t, err, out)
351 assert.Equal(t, merge, c.ref(domain.PullMergeRef(n)))
352 assert.Equal(t, feat, c.ref(domain.PullHeadRef(n)))
353 assert.Empty(t, c.ref("refs/pull/9/head"))
354}
355
356func (c *ciHarness) apiGet(token, path string) (int, http.Header, string) {
357 c.t.Helper()
358 req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.url("/-/github/api"+path), nil)
359 require.NoError(c.t, err)
360 if token != "" {
361 req.Header.Set("Authorization", "Bearer "+token)
362 }
363 res, err := http.DefaultClient.Do(req)
364 require.NoError(c.t, err)
365 defer func() { _ = res.Body.Close() }()
366 b, err := io.ReadAll(res.Body)
367 require.NoError(c.t, err)
368 return res.StatusCode, res.Header, string(b)
369}
370
371func TestPullFilesEndpoint(t *testing.T) {
372 c := newCIHarness(t, nil)
373 runner := c.registerRunner("alpha", "ubuntu-latest")
374 c.push("main", pullWF("", " permissions:\n contents: read\n pull-requests: read"), "")
375 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
376 files := map[string]string{}
377 for i := range 5 {
378 files[fmt.Sprintf("d/f%d.txt", i)] = fmt.Sprintf("line %d\n", i)
379 }
380 c.writeFile(c.wt, "d/gone.txt", []byte("x\n"))
381 for name, content := range files {
382 c.writeFile(c.wt, name, []byte(content))
383 }
384 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "feat")
385 c.openMR(c.root, "feat")
386 job := runner.await() // the job token lives while the job runs
387 defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
388
389 type entry struct {
390 Filename, Status, Sha, Patch string
391 Additions, Deletions, Changes int
392 }
393 list := func(token, q string) (int, http.Header, []entry) {
394 code, hdr, body := c.apiGet(token, "/repos/owner/proj/pulls/1/files"+q)
395 var es []entry
396 if code == http.StatusOK {
397 require.NoError(t, json.Unmarshal([]byte(body), &es), body)
398 }
399 return code, hdr, es
400 }
401
402 code, hdr, es := list(adminToken, "?per_page=4&page=1")
403 require.Equal(t, http.StatusOK, code)
404 require.Len(t, es, 4)
405 assert.Equal(t, "d/f0.txt", es[0].Filename)
406 assert.Equal(t, "added", es[0].Status)
407 assert.Equal(t, 1, es[0].Additions)
408 assert.Equal(t, 1, es[0].Changes)
409 assert.Equal(t, "@@ -0,0 +1 @@\n+line 0", es[0].Patch)
410 assert.Len(t, es[0].Sha, 40)
411 link := hdr.Get("Link")
412 assert.Contains(t, link, `page=2>; rel="next"`)
413 assert.Contains(t, link, `page=2>; rel="last"`)
414 assert.NotContains(t, link, `rel="prev"`)
415
416 code, hdr, es = list(adminToken, "?per_page=4&page=2")
417 require.Equal(t, http.StatusOK, code)
418 require.Len(t, es, 2, "6 files in all")
419 assert.NotContains(t, hdr.Get("Link"), `rel="next"`)
420 assert.Contains(t, hdr.Get("Link"), `rel="prev"`)
421 assert.Contains(t, hdr.Get("Link"), `rel="first"`)
422
423 code, _, es = list(adminToken, "?page=9")
424 require.Equal(t, http.StatusOK, code)
425 assert.Empty(t, es)
426 for _, q := range []string{"?page=9223372036854775807", "?per_page=100&page=9223372036854775807", "?page=2305843009213693953&per_page=4"} {
427 code, _, es = list(adminToken, q)
428 require.Equal(t, http.StatusOK, code, q)
429 assert.Empty(t, es, q)
430 }
431 code, hdr, es = list(adminToken, "")
432 require.Equal(t, http.StatusOK, code)
433 assert.Len(t, es, 6, "30 per page by default")
434 assert.Empty(t, hdr.Get("Link"))
435 code, _, es = list(adminToken, "?per_page=1000")
436 require.Equal(t, http.StatusOK, code)
437 assert.Len(t, es, 6, "100 at most")
438
439 // The job token with pull-requests: read; the answer is 404 for a request
440 // that does not exist and for anonymous callers on a private repository.
441 code, _, es = list(job.GetGithubToken(), "")
442 require.Equal(t, http.StatusOK, code)
443 assert.Len(t, es, 6)
444 code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/7/files")
445 assert.Equal(t, http.StatusNotFound, code)
446 code, _, _ = c.apiGet("", "/repos/owner/proj/pulls/1/files")
447 assert.Equal(t, http.StatusNotFound, code)
448 code, _, _ = c.apiGet(adminToken, "/repos/owner/proj/pulls/1")
449 assert.Equal(t, http.StatusNotFound, code, "GET /pulls/{n} is not served: paths-filter does not call it")
450}
451
452// A job token with pull-requests: read and contents: none reads the merge
453// requests of its own private repository, and only of that one.
454func TestPullFilesWithPullRequestsPermissionOnly(t *testing.T) {
455 c := newCIHarness(t, nil)
456 runner := c.registerRunner("alpha", "ubuntu-latest")
457 c.push("main", pullWF("", " permissions:\n contents: none\n pull-requests: read"), "")
458 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
459 c.push("feat", map[string]string{"f.txt": "f\n"}, "")
460 c.openMR(c.root, "feat")
461 job := runner.await()
462 defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
463 code, _, body := c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/1/files")
464 assert.Equal(t, http.StatusOK, code, body)
465 assert.Contains(t, body, "f.txt")
466 // Not the repository itself (contents: none) and not another repository.
467 code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/proj")
468 assert.Equal(t, http.StatusNotFound, code)
469 _, err := c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("other"), Visibility: domain.VisibilityPrivate})
470 require.NoError(t, err)
471 code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/other/pulls/1/files")
472 assert.Equal(t, http.StatusNotFound, code)
473}
474
475// The merge requests of a public repository are read by anyone, except by the
476// holder of an archive token, which reads one archive and nothing else.
477func TestPullFilesOfAPublicRepositoryAreNotReadByAnArchiveToken(t *testing.T) {
478 c := newCIHarness(t, nil)
479 c.push("main", pullWF("", ""), "")
480 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
481 c.push("feat", map[string]string{"f.txt": "f\n"}, "")
482 c.openMR(c.root, "feat")
483 public := domain.VisibilityPublic
484 _, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.name("proj"), ports.UpdateRepoParams{Visibility: &public})
485 require.NoError(t, err)
486
487 code, _, body := c.apiGet("", "/repos/owner/proj/pulls/1/files")
488 require.Equal(t, http.StatusOK, code, body)
489 assert.Contains(t, body, "f.txt", "anyone reads a public repository")
490
491 archive := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "a", Repo: c.name("proj"), Archive: true, Sha: strings.Repeat("a", 40)})
492 require.True(t, archive.ViaJobToken(), "the archive token passes ViaJobToken")
493 _, _, err = c.forge.CI.ListPullFiles(c.ctx, archive, "owner", "proj", 1, 1, 30)
494 assert.ErrorIs(t, err, domain.ErrNotFound, "but it reads no merge request")
495}
496
497func TestPullFilesNeedsPullRequestsPermission(t *testing.T) {
498 c := newCIHarness(t, nil)
499 runner := c.registerRunner("alpha", "ubuntu-latest")
500 c.push("main", pullWF("", ""), "") // the default permissions: contents read only
501 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
502 c.push("feat", map[string]string{"f.txt": "f\n"}, "")
503 c.openMR(c.root, "feat")
504 job := runner.await()
505 defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
506 code, _, body := c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/1/files")
507 assert.Equal(t, http.StatusForbidden, code, body)
508 assert.Contains(t, body, "Resource not accessible by integration")
509}