1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "testing"
7 "time"
8
9 "connectrpc.com/connect"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 pb "bnema.dev/ermite-api/gen/ermite"
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/usecase"
16)
17
18// `ermite workflow disable|enable|list` (P8, D5): a disabled workflow starts no
19// run for any event, and enabling it restores it.
20
21const releaseWF = ".github/workflows/release.yml"
22
23// wfOn is a workflow file with the given "on:" block (indented under on:).
24func wfOn(name, on string) string {
25 return "name: " + name + "\non:\n" + on + "jobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
26}
27
28func (c *ciHarness) setWorkflow(api *clients, disable bool, file string) (*pb.Workflow, error) {
29 c.t.Helper()
30 if disable {
31 res, err := api.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: c.repo, Workflow: file}))
32 if err != nil {
33 return nil, err
34 }
35 return res.Msg.GetWorkflow(), nil
36 }
37 res, err := api.run.EnableWorkflow(c.ctx, connect.NewRequest(&pb.EnableWorkflowRequest{Repo: c.repo, Workflow: file}))
38 if err != nil {
39 return nil, err
40 }
41 return res.Msg.GetWorkflow(), nil
42}
43
44func (c *ciHarness) listWorkflows(api *clients) map[string]*pb.Workflow {
45 c.t.Helper()
46 res, err := api.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo}))
47 require.NoError(c.t, err)
48 out := map[string]*pb.Workflow{}
49 for _, w := range res.Msg.GetWorkflows() {
50 out[w.GetPath()] = w
51 }
52 return out
53}
54
55func runsOfWorkflow(runs []*pb.Run, path string) []*pb.Run {
56 var out []*pb.Run
57 for _, r := range runs {
58 if r.GetWorkflowPath() == path {
59 out = append(out, r)
60 }
61 }
62 return out
63}
64
65// Disabled before the first push, on an empty repository where the file does
66// not exist yet: the push of many refs starts nothing of it, the other
67// workflows still run, and enabling restores it.
68func TestWorkflowDisabledBeforeFirstPushStartsNoRun(t *testing.T) {
69 c := newCIHarness(t, nil)
70 runner := c.registerRunner("alpha", "ubuntu-latest")
71
72 w, err := c.setWorkflow(c.root, true, "release.yml")
73 require.NoError(t, err, "the repository is empty and has no such file")
74 assert.Equal(t, releaseWF, w.GetPath(), "stored by its normalized path")
75 assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, w.GetState())
76 _, err = c.setWorkflow(c.root, true, releaseWF)
77 require.NoError(t, err, "idempotent, by path too")
78
79 // The list shows it though no branch holds the file.
80 listed := c.listWorkflows(c.root)
81 require.Len(t, listed, 1)
82 assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState())
83 assert.Empty(t, listed[releaseWF].GetName(), "no file, no name")
84
85 push := wfOn("release", " push:\n tags: ['v*']\n branches: [main]\n")
86 c.push("main", map[string]string{
87 releaseWF: push,
88 ".github/workflows/z-ctrl.yml": wfOn("control", " push:\n branches: [main]\n"),
89 }, "")
90 c.waitFor("the control run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 1 })
91 assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF), "a disabled workflow starts no push run")
92 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ctrl")
93
94 // Tags: many at once.
95 for _, tag := range []string{"v1", "v2", "v3"} {
96 c.mustGit(c.wt, "tag", tag)
97 }
98 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), "v1", "v2", "v3")
99 c.push("main", map[string]string{"x.txt": "x"}, "")
100 c.waitFor("the control run of the next push", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/z-ctrl.yml")) == 2 })
101 assert.Empty(t, runsOfWorkflow(c.runs(), releaseWF))
102
103 listed = c.listWorkflows(c.root)
104 assert.Equal(t, "release", listed[releaseWF].GetName(), "the file now exists and has a name")
105 assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, listed[releaseWF].GetState())
106 assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, listed[".github/workflows/z-ctrl.yml"].GetState())
107
108 // Enabling restores it.
109 w, err = c.setWorkflow(c.root, false, "release.yml")
110 require.NoError(t, err)
111 assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, w.GetState())
112 _, err = c.setWorkflow(c.root, false, "release.yml")
113 require.NoError(t, err, "idempotent")
114 assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_ENABLED, c.listWorkflows(c.root)[releaseWF].GetState())
115 c.push("main", map[string]string{"y.txt": "y"}, "")
116 c.waitFor("the release run", func() bool { return len(runsOfWorkflow(c.runs(), releaseWF)) == 1 })
117}
118
119// A disabled workflow does not fire on its schedule, and fires again once
120// enabled, with no reload of the schedules.
121func TestWorkflowDisabledStartsNoScheduledRun(t *testing.T) {
122 c, clk, _ := newScheduleHarness(t, "2026-03-04 10:00:30")
123 c.push("main", scheduleWorkflow("*/5 * * * *"), "")
124 ci := c.forge.CI
125 tick := func(at string) {
126 clk.set(at)
127 ci.ScheduleNow(c.ctx)
128 }
129 tick("2026-03-04 10:05:00")
130 require.Len(t, c.scheduled(), 1, "enabled: fires")
131 assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
132
133 _, err := c.setWorkflow(c.root, true, "nightly.yml")
134 require.NoError(t, err)
135 assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")), "the cache holds every workflow: the state is read when it fires")
136 tick("2026-03-04 10:10:00")
137 tick("2026-03-04 10:15:00")
138 assert.Len(t, c.scheduled(), 1, "disabled: no run for the minutes 10:10 and 10:15")
139
140 // A push of the file while disabled changes nothing.
141 c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "again")
142 tick("2026-03-04 10:20:00")
143 assert.Len(t, c.scheduled(), 1)
144
145 _, err = c.setWorkflow(c.root, false, "nightly.yml")
146 require.NoError(t, err)
147 tick("2026-03-04 10:25:00")
148 assert.Len(t, c.scheduled(), 2, "enabled again: fires, without catching up the missed minutes")
149}
150
151// workflow_dispatch and pull_request are refused/skipped too, per file.
152func TestWorkflowDisabledStartsNoDispatchOrPullRequestRun(t *testing.T) {
153 c := newCIHarness(t, nil)
154 runner := c.registerRunner("alpha", "ubuntu-latest")
155 c.push("main", map[string]string{
156 ".github/workflows/deploy.yml": dispatchWorkflow,
157 ".github/workflows/pr.yml": wfOn("pr", " pull_request:\n"),
158 ".github/workflows/other.yml": wfOn("other", " pull_request:\n"),
159 }, "")
160 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
161 c.push("feat", map[string]string{"f.txt": "f"}, "")
162
163 _, err := c.setWorkflow(c.root, true, "deploy.yml")
164 require.NoError(t, err)
165 _, err = c.setWorkflow(c.root, true, ".github/workflows/pr.yml")
166 require.NoError(t, err)
167
168 _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
169 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a disabled workflow is not dispatched")
170 assert.Contains(t, err.Error(), "disabled")
171
172 c.openMR(c.root, "feat")
173 c.waitFor("the other workflow's run", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/other.yml")) == 1 })
174 assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/pr.yml"), "no pull_request run")
175 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "other")
176
177 _, err = c.setWorkflow(c.root, false, "deploy.yml")
178 require.NoError(t, err)
179 run, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
180 require.NoError(t, err)
181 assert.Equal(t, "workflow_dispatch", run.GetEvent())
182}
183
184// A disabled workflow does not start a workflow_run workflow.
185func TestWorkflowDisabledStartsNoWorkflowRunRun(t *testing.T) {
186 c := newCIHarness(t, nil)
187 runner := c.registerRunner("alpha", "ubuntu-latest")
188 c.push("main", map[string]string{
189 ".github/workflows/ci.yml": wfOn("ci", " push:\n"),
190 ".github/workflows/after.yml": wfOn("after", " workflow_run:\n workflows: [ci]\n types: [completed]\n"),
191 ".github/workflows/after2.yml": wfOn("after2", " workflow_run:\n workflows: [ci]\n types: [completed]\n"),
192 }, "")
193 _, err := c.setWorkflow(c.root, true, "after.yml")
194 require.NoError(t, err)
195 // Pushed before the disable took effect? The first push ran ci: finish it.
196 job := runner.await()
197 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ci")
198 c.waitFor("after2 runs", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/after2.yml")) == 1 })
199 assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/after.yml"))
200}
201
202// Scopes: repo:admin changes the state, repo:read lists, ci:write and
203// repo:write do not change it; the shape is checked, not the existence.
204func TestWorkflowStateAccessAndValidation(t *testing.T) {
205 c := newCIHarness(t, nil)
206 admin := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), "proj"))
207 writer := c.api(c.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), "proj"))
208 reader := c.api(c.token(scopes(domain.ScopeRepoRead), "proj"))
209 stranger := c.api(c.token(scopes(domain.ScopeRepoAdmin), "other"))
210 limited, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/"}, "proj")
211
212 _, err := c.setWorkflow(admin, true, "release.yml")
213 require.NoError(t, err)
214 _, err = c.setWorkflow(writer, true, "aur.yml")
215 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "repo:write and ci:write are not enough")
216 _, err = c.setWorkflow(writer, false, "release.yml")
217 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
218 _, err = c.setWorkflow(reader, true, "aur.yml")
219 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
220 _, err = c.setWorkflow(c.api(limited), true, "aur.yml")
221 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "a token limited to branches may not")
222 _, err = c.setWorkflow(stranger, true, "aur.yml")
223 assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "a repository the token cannot read")
224
225 for _, bad := range []string{"", "release", "release.txt", "sub/release.yml", ".github/workflows/sub/release.yml", "../release.yml", ".github/other/release.yml"} {
226 _, err = c.setWorkflow(admin, true, bad)
227 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "%q", bad)
228 }
229
230 assert.Equal(t, pb.WorkflowState_WORKFLOW_STATE_DISABLED, c.listWorkflows(reader)[releaseWF].GetState(), "repo:read lists")
231 assert.Len(t, c.listWorkflows(reader), 1, "the refused changes left nothing")
232 _, err = stranger.run.ListWorkflows(c.ctx, connect.NewRequest(&pb.ListWorkflowsRequest{Repo: c.repo}))
233 assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
234 _, err = c.setWorkflow(admin, true, ".github/workflows/Odd Name.YAML")
235 require.NoError(t, err, "only the shape is checked")
236
237 _, err = admin.run.DisableWorkflow(c.ctx, connect.NewRequest(&pb.DisableWorkflowRequest{Repo: "nope", Workflow: "a.yml"}))
238 assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
239}
240
241// GO-001: the state is enforced where the run is stored. A dispatch that passed
242// the check and is paused after resolving its ref (DispatchResolved) while the
243// workflow is disabled creates no run.
244func TestWorkflowDisabledBetweenDispatchCheckAndCreation(t *testing.T) {
245 var c *ciHarness
246 var disable func()
247 c = newCIHarness(t, func(cfg *usecase.CIConfig) {
248 cfg.DispatchResolved = func() {
249 if disable != nil {
250 disable()
251 }
252 }
253 })
254 c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
255 before := len(c.runs())
256
257 disable = func() {
258 disable = nil
259 _, err := c.setWorkflow(c.root, true, "deploy.yml")
260 require.NoError(t, err)
261 }
262 _, err := c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
263 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "the disable landed after the check")
264 assert.Contains(t, err.Error(), "disabled")
265 assert.Len(t, c.runs(), before, "no run was created")
266
267 _, err = c.setWorkflow(c.root, false, "deploy.yml")
268 require.NoError(t, err)
269 _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1"})
270 require.NoError(t, err, "enabled again")
271}
272
273// GO-001: an invalid workflow file gets no failed run either while it is
274// disabled, on a push and on the failed-run path of a disable that races.
275func TestWorkflowDisabledInvalidFileStartsNoFailedRun(t *testing.T) {
276 c := newCIHarness(t, nil)
277 c.registerRunner("alpha", "ubuntu-latest")
278 _, err := c.setWorkflow(c.root, true, "broken.yml")
279 require.NoError(t, err)
280 c.push("main", map[string]string{
281 ".github/workflows/broken.yml": "on: push\njobs: [this is not a workflow\n",
282 ".github/workflows/bad2.yml": "on: push\njobs: [this is not a workflow\n",
283 }, "")
284 c.waitFor("the invalid enabled file fails", func() bool { return len(runsOfWorkflow(c.runs(), ".github/workflows/bad2.yml")) == 1 })
285 assert.Empty(t, runsOfWorkflow(c.runs(), ".github/workflows/broken.yml"), "a disabled invalid file reports nothing")
286}
287
288// GO-002: a schedule load that is overtaken by an enable (or a disable) neither
289// loses the workflow nor fires a disabled one: the cache holds every workflow
290// and the state is read when the schedule fires.
291func TestScheduleLoadRacingWithEnableAndDisable(t *testing.T) {
292 clk := &testClock{}
293 clk.set("2026-03-04 10:00:30")
294 var c *ciHarness
295 var inLoad func()
296 c = newCIHarness(t, func(cfg *usecase.CIConfig) {
297 cfg.Clock = clk
298 cfg.ScheduleTick = time.Hour
299 cfg.ScheduleLoaded = func(domain.RepoName) {
300 if f := inLoad; f != nil {
301 inLoad = nil
302 f()
303 }
304 }
305 })
306 c.registerRunner("alpha", "ubuntu-latest")
307 ci := c.forge.CI
308 tick := func(at string) {
309 clk.set(at)
310 ci.ScheduleNow(c.ctx)
311 }
312 // Disabled before the first push; the load of the schedules sees the file,
313 // and an enable lands while it is being cached.
314 _, err := c.setWorkflow(c.root, true, "nightly.yml")
315 require.NoError(t, err)
316 c.push("main", scheduleWorkflow("*/5 * * * *"), "")
317 inLoad = func() {
318 _, err := c.setWorkflow(c.root, false, "nightly.yml")
319 require.NoError(t, err)
320 }
321 tick("2026-03-04 10:05:00")
322 require.Len(t, c.scheduled(), 1, "enabled while the load was running: the workflow is there")
323 assert.Equal(t, 1, ci.CachedSchedules(c.name("proj")))
324
325 // And the other way: disabled while a load is running.
326 require.NoError(t, func() error { _, err := c.setWorkflow(c.root, false, "nightly.yml"); return err }())
327 c.push("main", scheduleWorkflow("*/5 * * * *", "0 0 1 1 *"), "second") // the tip moves: a new load
328 inLoad = func() {
329 _, err := c.setWorkflow(c.root, true, "nightly.yml")
330 require.NoError(t, err)
331 }
332 tick("2026-03-04 10:10:00")
333 assert.Len(t, c.scheduled(), 1, "disabled while the load was running: it does not fire")
334 _, err = c.setWorkflow(c.root, false, "nightly.yml")
335 require.NoError(t, err)
336 tick("2026-03-04 10:15:00")
337 assert.Len(t, c.scheduled(), 2, "enabled again: fires with the cached entry, nothing missing")
338}
339
340// GO-003: one file has one key. Non-canonical spellings are refused by the API,
341// for disable, enable and dispatch alike.
342func TestWorkflowNonCanonicalPathsAreRefused(t *testing.T) {
343 c := newCIHarness(t, nil)
344 c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
345 for _, bad := range []string{
346 ".github/workflows//release.yml", "./.github/workflows/release.yml", ".github/workflows/sub/../release.yml",
347 ".github/workflows/./release.yml",
348 } {
349 _, err := c.setWorkflow(c.root, true, bad)
350 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "disable %q", bad)
351 _, err = c.setWorkflow(c.root, false, bad)
352 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "enable %q", bad)
353 _, err = c.dispatch(c.root, bad, "", nil)
354 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "dispatch %q", bad)
355 }
356 assert.Empty(t, c.listWorkflows(c.root)[".github/workflows/release.yml"], "nothing was stored")
357 _, err := c.dispatch(c.root, ".github/workflows/deploy.yml", "", map[string]string{"version": "1"})
358 require.NoError(t, err, "the canonical path still dispatches")
359}