v0.22.0 4c072b1

ci_schedule_trust_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close