detached 59d5a0b

ci_schedule_trust_test.go RawHistoryFolder

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}

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