1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "strings"
7 "testing"
8 "time"
9
10 "connectrpc.com/connect"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 pb "bnema.dev/ermite-api/gen/ermite"
15 "bnema.dev/ermite/internal/config"
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/usecase"
19)
20
21// CI-3 D1, S1: a scheduled run is run as whoever moved the default branch to
22// its tip, with their trust.
23
24const scheduleSecretWorkflow = `name: nightly
25on:
26 schedule:
27 - cron: '* * * * *'
28jobs:
29 j:
30 runs-on: ubuntu-latest
31 steps:
32 - run: echo ${{ secrets.NIGHTLY_TOKEN }}
33`
34
35func newScheduleSecretsHarness(t *testing.T) (*ciHarness, *testClock, *fakeRunner) {
36 t.Helper()
37 clk := &testClock{}
38 clk.set("2026-03-04 10:00:30")
39 h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
40 o.ci = func(cfg *usecase.CIConfig) {
41 cfg.Clock = clk
42 cfg.ScheduleTick = time.Hour
43 }
44 })
45 c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
46 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
47 require.NoError(t, err)
48 c.wt = h.workTree("main")
49 c.setSecret(repoScope("proj"), "NIGHTLY_TOKEN", "s3cret")
50 return c, clk, c.registerRunner("alpha", "ubuntu-latest")
51}
52
53// fireAt moves the clock to the minute, ticks the scheduler and returns the job
54// of the scheduled run it started.
55func (c *ciHarness) fireAt(clk *testClock, runner *fakeRunner, at string) (*pb.Run, *pb.Job) {
56 c.t.Helper()
57 before := len(scheduleRuns(c.runs()))
58 clk.set(at)
59 c.forge.CI.ScheduleNow(c.ctx)
60 runs := scheduleRuns(c.runs())
61 require.Len(c.t, runs, before+1, "one scheduled run")
62 job := runner.await()
63 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
64 return runs[0], job // newest first
65}
66
67func (c *ciHarness) repoToken(label string) string {
68 return c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), "proj")
69}
70
71func TestScheduleOnABranchAnAgentMadeTheDefaultIsNotTrusted(t *testing.T) {
72 c, clk, runner := newScheduleSecretsHarness(t)
73 // The repository is empty: the first branch pushed becomes the default
74 // branch, whoever pushes it.
75 secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
76 c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
77 c.writeFile(c.wt, ".github/workflows/nightly.yml", []byte(scheduleSecretWorkflow))
78 c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
79 def, _, err := c.forge.git.DefaultBranch(c.ctx, c.name("proj"))
80 require.NoError(t, err)
81 require.Equal(t, "agent/x", def, "the agent's branch is the default branch")
82
83 run, job := c.fireAt(clk, runner, "2026-03-04 10:01:00")
84 assert.False(t, run.GetTrusted(), "the owner's secrets do not run for an agent's workflow")
85 assert.Equal(t, "claude", run.GetActor(), "the actor is who pushed the tip")
86 assert.False(t, job.GetTrusted())
87 assert.Empty(t, job.GetSecrets())
88}
89
90func TestScheduleActorFollowsTheLastPusherOfTheDefaultBranch(t *testing.T) {
91 c, clk, runner := newScheduleSecretsHarness(t)
92
93 // The owner pushes main: trusted, with the secrets.
94 c.push("main", map[string]string{".github/workflows/nightly.yml": scheduleSecretWorkflow}, "")
95 run, job := c.fireAt(clk, runner, "2026-03-04 10:01:00")
96 assert.True(t, run.GetTrusted())
97 assert.Equal(t, "root", run.GetActor())
98 assert.Equal(t, map[string]string{"NIGHTLY_TOKEN": "s3cret"}, job.GetSecrets())
99
100 // A token scoped to the repository (not the owner) pushes main: untrusted.
101 tok := c.repoToken("scoped")
102 c.writeFile(c.wt, "x.txt", []byte("x"))
103 c.mustGit(c.wt, "push", "-q", c.urlAuth(tok, "/proj.git"), "main")
104 run, job = c.fireAt(clk, runner, "2026-03-04 10:02:00")
105 assert.False(t, run.GetTrusted(), "a repository-scoped token is not the owner")
106 assert.Empty(t, job.GetSecrets())
107
108 // The owner pushes again: trusted again.
109 c.push("main", map[string]string{"y.txt": "y"}, "")
110 run, _ = c.fireAt(clk, runner, "2026-03-04 10:03:00")
111 assert.True(t, run.GetTrusted())
112
113 // The record is for one commit: a tip it does not name is not trusted.
114 require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
115 Repo: c.name("proj"), Ref: "refs/heads/main", Sha: "1111111111111111111111111111111111111111", Actor: "root", Trusted: true,
116 }))
117 run, job = c.fireAt(clk, runner, "2026-03-04 10:04:00")
118 assert.False(t, run.GetTrusted(), "the record names another commit")
119 assert.Empty(t, job.GetSecrets())
120}
121
122func TestScheduleAfterAMergeIsTheMergersToo(t *testing.T) {
123 c, clk, runner := newScheduleSecretsHarness(t)
124 c.push("main", map[string]string{".github/workflows/nightly.yml": scheduleSecretWorkflow}, "")
125 tok := c.repoToken("scoped")
126 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
127 c.writeFile(c.wt, "f.txt", []byte("f"))
128 c.mustGit(c.wt, "push", "-q", c.urlAuth(tok, "/proj.git"), "feat")
129 n := c.openMR(c.root, "feat")
130 c.waitFor("the head ref", func() bool { return c.ref(domain.PullHeadRef(n)) != "" })
131 head := c.ref(domain.PullHeadRef(n))
132
133 // The owner merges: main is theirs from now on.
134 _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head}))
135 require.NoError(t, err)
136 run, job := c.fireAt(clk, runner, "2026-03-04 10:01:00")
137 assert.True(t, run.GetTrusted(), "merged by the owner")
138 assert.Equal(t, map[string]string{"NIGHTLY_TOKEN": "s3cret"}, job.GetSecrets())
139}
140
141func TestRefTipRecordsGoWithTheRepository(t *testing.T) {
142 c, _, _ := newScheduleSecretsHarness(t)
143 c.push("main", map[string]string{"a.txt": "a"}, "")
144 tips := c.forge.store.CI()
145 got, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
146 require.NoError(t, err)
147 assert.True(t, got.Trusted)
148 assert.Equal(t, c.ref("refs/heads/main"), got.Sha)
149
150 _, err = c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
151 require.NoError(t, err)
152 _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
153 assert.ErrorIs(t, err, domain.ErrNotFound, "deleted with the repository")
154}
155
156// GO-004: the record is written before the ref moves, so the sequence owner A,
157// someone else B, someone else A (the same commit as the owner's) never leaves
158// the owner's record on the tip, however late the post-receive of the last push
159// is. A push that is refused after its record was written leaves a record that
160// matches no tip.
161func TestScheduleABAOfThePusherIsNeverTrusted(t *testing.T) {
162 c, clk, runner := newScheduleSecretsHarness(t)
163 tips := c.forge.store.CI()
164 c.push("main", map[string]string{".github/workflows/nightly.yml": scheduleSecretWorkflow}, "")
165 commitA := c.ref("refs/heads/main")
166 run, _ := c.fireAt(clk, runner, "2026-03-04 10:01:00")
167 require.True(t, run.GetTrusted(), "the owner's A is trusted")
168
169 // B and A again, by a repository-scoped token.
170 tok := c.repoToken("scoped")
171 c.writeFile(c.wt, "b.txt", []byte("b"))
172 c.mustGit(c.wt, "push", "-q", c.urlAuth(tok, "/proj.git"), "main")
173 rec, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
174 require.NoError(t, err)
175 assert.False(t, rec.Trusted, "B was recorded as the token's before the ref moved")
176 c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(tok, "/proj.git"), commitA+":refs/heads/main")
177 require.Equal(t, commitA, c.ref("refs/heads/main"), "main is back at the owner's commit")
178
179 // The record of that push is the token's, not the owner's: A is untrusted.
180 rec, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
181 require.NoError(t, err)
182 assert.Equal(t, commitA, rec.Sha)
183 assert.False(t, rec.Trusted)
184 run, job := c.fireAt(clk, runner, "2026-03-04 10:02:00")
185 assert.False(t, run.GetTrusted(), "owner A, token B, token A: not the owner's")
186 assert.Empty(t, job.GetSecrets())
187
188 // A push that moves nothing (the owner pushes A where A already is) is not a
189 // push for git: it endorses nothing.
190 c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), commitA+":refs/heads/main")
191 run, _ = c.fireAt(clk, runner, "2026-03-04 10:03:00")
192 assert.False(t, run.GetTrusted(), "still the token's")
193
194 // The owner moves the tip: trusted again.
195 c.push("main", map[string]string{"owner.txt": "o"}, "")
196 run, _ = c.fireAt(clk, runner, "2026-03-04 10:04:00")
197 assert.True(t, run.GetTrusted())
198 tip := c.ref("refs/heads/main")
199
200 // A push refused after the decision recorded it (here the check alone runs,
201 // then git never moves the ref): the record names a commit the tip does not
202 // have, and the schedule fails closed.
203 scoped, err := c.forge.Auth.Authenticate(c.ctx, tok)
204 require.NoError(t, err)
205 never := strings.Repeat("2", 40)
206 require.NoError(t, c.forge.Pushes.Check(c.ctx, scoped, c.name("proj"),
207 []domain.RefUpdate{{Ref: "refs/heads/main", Old: tip, New: never}}, ports.PushObjects{}))
208 left, err := c.forge.store.CI().GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
209 require.NoError(t, err)
210 assert.Equal(t, never, left.Sha, "the refused push left its record behind")
211 run, _ = c.fireAt(clk, runner, "2026-03-04 10:05:00")
212 assert.False(t, run.GetTrusted(), "a record for a commit that never became the tip: fail closed")
213}
214
215// GO-006: a merge that deletes its source branch drops the record of that
216// branch, and a merge records the target before it moves.
217func TestMergeRecordsTheTargetAndDropsTheSourceRecord(t *testing.T) {
218 c, _, _ := newScheduleSecretsHarness(t)
219 tips := c.forge.store.CI()
220 c.push("main", map[string]string{"a.txt": "a"}, "")
221 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
222 c.push("feat", map[string]string{"f.txt": "f"}, "")
223 _, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
224 require.NoError(t, err, "the pushed branch has a record")
225 n := c.openMR(c.root, "feat")
226 c.waitFor("the head ref", func() bool { return c.ref(domain.PullHeadRef(n)) != "" })
227
228 _, err = c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
229 Repo: "proj", Number: n, HeadId: c.ref(domain.PullHeadRef(n)), DeleteSourceBranch: true,
230 }))
231 require.NoError(t, err)
232
233 _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/feat")
234 assert.ErrorIs(t, err, domain.ErrNotFound, "the deleted source branch has no record")
235 main, err := tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/main")
236 require.NoError(t, err)
237 assert.Equal(t, c.ref("refs/heads/main"), main.Sha, "the merge recorded the new tip")
238 assert.True(t, main.Trusted)
239
240 // A pushed branch that is deleted by a push loses its record too.
241 c.mustGit(c.wt, "checkout", "-q", "-b", "gone")
242 c.push("gone", map[string]string{"g.txt": "g"}, "")
243 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":refs/heads/gone")
244 _, err = tips.GetRefTip(c.ctx, c.name("proj"), "refs/heads/gone")
245 assert.ErrorIs(t, err, domain.ErrNotFound)
246}
247
248// GO-005: a dispatched run is trusted only when the caller is and the ref was
249// moved to its commit by a trusted principal, for every caller.
250func TestDispatchTrustFollowsTheRefsPusher(t *testing.T) {
251 c := newCIHarness(t, nil)
252 runner := c.registerRunner("alpha", "ubuntu-latest")
253 c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow, ".github/workflows/d.yml": jobTokenDispatcher}, "")
254 jobs := runner.jobsByKey(4)
255 in := map[string]string{"version": "1"}
256
257 // An agent's branch.
258 secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
259 c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
260 c.writeFile(c.wt, "x.txt", []byte("x"))
261 c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
262 c.waitFor("the agent's push run", func() bool { return len(c.runsOf("dispatcher")) >= 2 })
263
264 run, err := c.dispatch(c.root, "deploy.yml", "main", in)
265 require.NoError(t, err)
266 assert.True(t, run.GetTrusted(), "the owner dispatching what the owner pushed")
267
268 run, err = c.dispatch(c.root, "deploy.yml", "agent/x", in)
269 require.NoError(t, err)
270 assert.False(t, run.GetTrusted(), "the owner dispatching an agent's branch")
271
272 run, err = c.dispatch(c.api(jobs["with"].GetGithubToken()), "deploy.yml", "agent/x", in)
273 require.NoError(t, err)
274 assert.False(t, run.GetTrusted(), "a trusted job token dispatching an agent's branch")
275 run, err = c.dispatch(c.api(jobs["with"].GetGithubToken()), "deploy.yml", "main", in)
276 require.NoError(t, err)
277 assert.True(t, run.GetTrusted(), "a trusted job token dispatching the owner's branch")
278
279 // What the jobs of those runs received: the untrusted ones got no secrets.
280 got := map[int64]*pb.Job{}
281 for range 4 {
282 j := runner.await()
283 got[j.GetRunId()] = j
284 }
285 for _, r := range c.runs() {
286 if r.GetEvent() != "workflow_dispatch" {
287 continue
288 }
289 if j := got[r.GetId()]; j != nil {
290 assert.Equal(t, r.GetTrusted(), j.GetTrusted(), "the job follows its run")
291 if !r.GetTrusted() {
292 assert.Empty(t, j.GetSecrets())
293 }
294 }
295 }
296}
297
298// GO-007: the trust of a dispatch is judged on the object and the commit it
299// resolved, not on the tag as it is read afterwards. The tag is a lightweight
300// one at commit A when the dispatch resolves it; before the trust is judged it
301// is retargeted by the owner to an annotated tag on commit B, whose record is
302// trusted: A runs, and must not borrow that record.
303func TestDispatchOnATagRetargetedMeanwhileIsNotTrusted(t *testing.T) {
304 var retarget func()
305 c := newCIHarness(t, func(cfg *usecase.CIConfig) {
306 cfg.DispatchResolved = func() {
307 if retarget != nil {
308 retarget()
309 }
310 }
311 })
312 c.registerRunner("alpha", "ubuntu-latest")
313 c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
314 c.mustGit(c.wt, "tag", "v1")
315 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
316 commitA := c.ref("refs/heads/main")
317 in := map[string]string{"version": "1"}
318
319 run, err := c.dispatch(c.root, "deploy.yml", "v1", in)
320 require.NoError(t, err)
321 require.True(t, run.GetTrusted(), "without a retarget the owner's own tag is trusted")
322
323 retarget = func() {
324 retarget = nil // once
325 c.writeFile(c.wt, "b.txt", []byte("b"))
326 c.mustGit(c.wt, "tag", "-a", "-f", "-m", "annotated", "v1")
327 c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), "v1")
328 }
329 run, err = c.dispatch(c.root, "deploy.yml", "v1", in)
330 require.NoError(t, err)
331 assert.Equal(t, commitA, run.GetSha(), "the dispatch runs the commit it resolved")
332 assert.False(t, run.GetTrusted(), "the tag moved to another object meanwhile: the record of the new one is not A's")
333
334 // The tag really moved: dispatching it now is trusted again, on the new commit.
335 run, err = c.dispatch(c.root, "deploy.yml", "v1", in)
336 require.NoError(t, err)
337 assert.NotEqual(t, commitA, run.GetSha())
338 assert.True(t, run.GetTrusted())
339}
340
341// A tag is judged by its own record, like a branch.
342func TestDispatchOnATagFollowsItsPusher(t *testing.T) {
343 c := newCIHarness(t, nil)
344 c.registerRunner("alpha", "ubuntu-latest")
345 c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
346 c.mustGit(c.wt, "tag", "v1")
347 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
348 c.mustGit(c.wt, "tag", "-a", "-m", "annotated", "v2")
349 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v2")
350 in := map[string]string{"version": "1"}
351
352 run, err := c.dispatch(c.root, "deploy.yml", "v1", in)
353 require.NoError(t, err)
354 assert.True(t, run.GetTrusted(), "a lightweight tag the owner pushed")
355 run, err = c.dispatch(c.root, "deploy.yml", "v2", in)
356 require.NoError(t, err)
357 assert.True(t, run.GetTrusted(), "an annotated tag the owner pushed (the record names the tag object)")
358
359 tok := c.repoToken("scoped")
360 c.writeFile(c.wt, "t.txt", []byte("t"))
361 c.mustGit(c.wt, "tag", "v3")
362 c.mustGit(c.wt, "push", "-q", c.urlAuth(tok, "/proj.git"), "v3")
363 run, err = c.dispatch(c.root, "deploy.yml", "v3", in)
364 require.NoError(t, err)
365 assert.False(t, run.GetTrusted(), "a tag a scoped token pushed")
366}