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