1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "fmt"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 pb "bnema.dev/ermite-api/gen/ermite"
13 "bnema.dev/ermite/internal/domain"
14)
15
16// CI-4 D6: the create and delete events.
17
18const refEventWorkflow = `name: %s
19on: %s
20jobs:
21 j:
22 runs-on: ubuntu-latest
23 steps:
24 - run: echo ${{ secrets.API_TOKEN }} %s
25`
26
27func refEventWF(event, mark string) map[string]string {
28 return map[string]string{".github/workflows/" + event + ".yml": fmt.Sprintf(refEventWorkflow, event, event, mark)}
29}
30
31// newRefEventHarness is a harness with a repository secret, the workflow of
32// event on main and the create run of main itself finished when event is create.
33func newRefEventHarness(t *testing.T, event string) (*ciHarness, *fakeRunner, string) {
34 t.Helper()
35 c := newSecretsHarness(t, secretsKeyA)
36 runner := c.registerRunner("alpha", "ubuntu-latest")
37 c.setSecret(repoScope("proj"), "API_TOKEN", "repo-token")
38 main := c.push("main", refEventWF(event, "from-main"), "")
39 if event == "create" {
40 job := c.finish(runner)
41 assert.Equal(t, "refs/heads/main", job.GetRef(), "creating main is a create event")
42 }
43 return c, runner, main
44}
45
46func eventOf(c *ciHarness, j *pb.Job) map[string]any {
47 return c.ghContext(j)["event"].(map[string]any)
48}
49
50func TestCreateRunsOnTheCreatedRef(t *testing.T) {
51 c, runner, _ := newRefEventHarness(t, "create")
52
53 // A new branch: its own workflow file runs, on its commit.
54 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
55 feat := c.push("feat", refEventWF("create", "from-feat"), "")
56 job := c.finish(runner)
57 assert.Contains(t, job.GetWorkflowYaml(), "from-feat")
58 assert.Equal(t, "refs/heads/feat", job.GetRef())
59 assert.Equal(t, feat, job.GetSha())
60 assert.True(t, job.GetTrusted())
61 assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
62 gh := c.ghContext(job)
63 assert.Equal(t, "create", gh["event_name"])
64 assert.Equal(t, "refs/heads/feat", gh["ref"])
65 assert.Equal(t, "feat", gh["ref_name"])
66 assert.Equal(t, "branch", gh["ref_type"])
67 ev := eventOf(c, job)
68 assert.Equal(t, "feat", ev["ref"])
69 assert.Equal(t, "branch", ev["ref_type"])
70 assert.Equal(t, "main", ev["master_branch"])
71 assert.Equal(t, "user", ev["pusher_type"])
72 assert.Contains(t, ev, "description")
73 assert.Nil(t, ev["description"])
74 assert.NotNil(t, ev["repository"])
75 assert.NotNil(t, ev["sender"])
76 assert.NotContains(t, ev, "commits", "not a push payload")
77 assert.Equal(t, "refs/heads/feat", job.GetCache().GetWriteRef(), "a trusted run saves under its ref")
78
79 // A tag.
80 c.mustGit(c.wt, "tag", "v1")
81 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
82 job = c.finish(runner)
83 assert.Equal(t, "refs/tags/v1", job.GetRef())
84 ev = eventOf(c, job)
85 assert.Equal(t, "v1", ev["ref"])
86 assert.Equal(t, "tag", ev["ref_type"])
87
88 // Moving an existing branch creates nothing.
89 c.push("feat", map[string]string{"b.txt": "b\n"}, "")
90 c.mustGit(c.wt, "checkout", "-q", "main")
91 c.push("main", map[string]string{"sentinel.txt": "s\n"}, "")
92 c.mustGit(c.wt, "checkout", "-q", "-b", "sentinel")
93 c.push("sentinel", map[string]string{"s.txt": "s\n"}, "")
94 job = c.finish(runner)
95 assert.Equal(t, "refs/heads/sentinel", job.GetRef(), "the update of feat and main started no run")
96 for _, r := range c.runs() {
97 assert.Equal(t, "create", r.GetEvent())
98 }
99 assert.Len(t, c.runs(), 4, "main, feat, v1, sentinel")
100}
101
102// More than three tags at once create no event, as on GitHub.
103func TestCreateSkipsMoreThanThreeTags(t *testing.T) {
104 c, runner, _ := newRefEventHarness(t, "create")
105 for _, tag := range []string{"t1", "t2", "t3", "t4"} {
106 c.mustGit(c.wt, "tag", tag)
107 }
108 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "t1", "t2", "t3", "t4")
109 c.mustGit(c.wt, "checkout", "-q", "-b", "sentinel")
110 c.push("sentinel", map[string]string{"s.txt": "s\n"}, "")
111 job := c.finish(runner)
112 assert.Equal(t, "refs/heads/sentinel", job.GetRef())
113 assert.Len(t, c.runs(), 2, "main and sentinel")
114}
115
116func TestDeleteRunsTheDefaultBranchWorkflow(t *testing.T) {
117 c, runner, main := newRefEventHarness(t, "delete")
118 // The deleted branch carries another version of the workflow: it is not
119 // the one that runs.
120 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
121 c.push("feat", refEventWF("delete", "from-feat"), "")
122 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":feat")
123
124 job := c.finish(runner)
125 assert.Contains(t, job.GetWorkflowYaml(), "from-main")
126 assert.Equal(t, "refs/heads/main", job.GetRef())
127 assert.Equal(t, main, job.GetSha())
128 assert.True(t, job.GetTrusted())
129 assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
130 gh := c.ghContext(job)
131 assert.Equal(t, "delete", gh["event_name"])
132 assert.Equal(t, "refs/heads/main", gh["ref"])
133 assert.Equal(t, main, gh["sha"])
134 ev := eventOf(c, job)
135 assert.Equal(t, "feat", ev["ref"])
136 assert.Equal(t, "branch", ev["ref_type"])
137 assert.Equal(t, "user", ev["pusher_type"])
138 assert.NotContains(t, ev, "master_branch")
139 assert.NotNil(t, ev["repository"])
140 assert.NotNil(t, ev["sender"])
141 assert.Equal(t, "refs/heads/main", job.GetCache().GetWriteRef())
142
143 runs := c.runs()
144 require.Len(t, runs, 1)
145 assert.Equal(t, "delete", runs[0].GetEvent())
146 assert.Equal(t, "refs/heads/main", runs[0].GetRef())
147
148 // A tag.
149 c.mustGit(c.wt, "tag", "v1", "main")
150 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
151 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":refs/tags/v1")
152 ev = eventOf(c, c.finish(runner))
153 assert.Equal(t, "v1", ev["ref"])
154 assert.Equal(t, "tag", ev["ref_type"])
155}
156
157// An agent creating or deleting runs untrusted; an owner's delete runs
158// untrusted when the default tip was not pushed by a trusted principal.
159func TestCreateAndDeleteTrust(t *testing.T) {
160 c := newSecretsHarness(t, secretsKeyA)
161 runner := c.registerRunner("alpha", "ubuntu-latest")
162 c.setSecret(repoScope("proj"), "API_TOKEN", "repo-token")
163 files := refEventWF("create", "c")
164 for k, v := range refEventWF("delete", "d") {
165 files[k] = v
166 }
167 main := c.push("main", files, "")
168 require.True(t, c.finish(runner).GetTrusted(), "the owner created main")
169 secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
170 agentURL := c.urlAuth(secret, "/proj.git")
171
172 // The agent creates a branch: untrusted.
173 c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
174 c.writeFile(c.wt, "x.txt", []byte("x\n"))
175 c.mustGit(c.wt, "push", "-q", agentURL, "agent/x")
176 job := c.finish(runner)
177 assert.Equal(t, "create", c.ghContext(job)["event_name"])
178 assert.False(t, job.GetTrusted())
179 assert.Empty(t, job.GetSecrets())
180 assert.Equal(t, "refs/untrusted/heads/agent/x", job.GetCache().GetWriteRef())
181
182 // The agent deletes it: untrusted, and the default branch is not written.
183 c.mustGit(c.wt, "push", "-q", agentURL, ":agent/x")
184 job = c.finish(runner)
185 assert.Equal(t, "delete", c.ghContext(job)["event_name"])
186 assert.Equal(t, main, job.GetSha())
187 assert.False(t, job.GetTrusted())
188 assert.Empty(t, job.GetSecrets())
189 assert.Empty(t, job.GetCache().GetWriteRef(), "an untrusted run never saves under the default branch")
190
191 // The owner deletes a branch the agent pushed: trusted, the deleted ref's
192 // record does not matter (it is gone with the ref).
193 c.mustGit(c.wt, "push", "-q", agentURL, "agent/x")
194 c.finish(runner) // create
195 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":agent/x")
196 job = c.finish(runner)
197 assert.Equal(t, "delete", c.ghContext(job)["event_name"])
198 assert.True(t, job.GetTrusted())
199 assert.Equal(t, "repo-token", job.GetSecrets()["API_TOKEN"])
200
201 // The owner deletes while main's record says an untrusted principal set
202 // it: the default tip's code runs, so untrusted.
203 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "agent/x:refs/heads/other")
204 c.finish(runner) // create
205 require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
206 Repo: c.name("proj"), Ref: "refs/heads/main", Sha: main, Actor: "claude", Trusted: false,
207 }))
208 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":other")
209 job = c.finish(runner)
210 assert.Equal(t, "delete", c.ghContext(job)["event_name"])
211 assert.False(t, job.GetTrusted())
212 assert.Empty(t, job.GetSecrets())
213}