1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "encoding/json"
7 "testing"
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)
16
17// CI-3 D5, AC5: workflow_dispatch.
18
19const dispatchWorkflow = `name: deploy
20on:
21 workflow_dispatch:
22 inputs:
23 version:
24 type: string
25 required: true
26 dry:
27 type: boolean
28 default: false
29 count:
30 type: number
31 default: 3
32 level:
33 type: choice
34 options: [low, high]
35 target:
36 type: environment
37 required: false
38run-name: deploy ${{ inputs.version }} dry=${{ inputs.dry }}
39jobs:
40 j:
41 runs-on: ubuntu-latest
42 steps:
43 - run: echo hi
44`
45
46const pushOnlyWorkflow = "name: ci\non: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
47
48const jobTokenDispatcher = `name: dispatcher
49on: push
50permissions:
51 actions: write
52 contents: read
53jobs:
54 with:
55 runs-on: ubuntu-latest
56 steps:
57 - run: echo hi
58 without:
59 runs-on: ubuntu-latest
60 permissions:
61 contents: read
62 steps:
63 - run: echo hi
64 actionsonly:
65 runs-on: ubuntu-latest
66 permissions:
67 actions: write
68 steps:
69 - run: echo hi
70 readonly:
71 runs-on: ubuntu-latest
72 permissions: {}
73 steps:
74 - run: echo hi
75`
76
77func (c *ciHarness) dispatch(api *clients, path, ref string, inputs map[string]string) (*pb.Run, error) {
78 c.t.Helper()
79 res, err := api.run.DispatchWorkflow(c.ctx, connect.NewRequest(&pb.DispatchWorkflowRequest{
80 Repo: c.repo, WorkflowPath: path, Ref: ref, Inputs: inputs,
81 }))
82 if err != nil {
83 return nil, err
84 }
85 return res.Msg.GetRun(), nil
86}
87
88func TestDispatchInputsAreValidatedAndTyped(t *testing.T) {
89 c := newCIHarness(t, nil)
90 runner := c.registerRunner("alpha", "ubuntu-latest")
91 c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow, ".github/workflows/ci.yml": pushOnlyWorkflow}, "")
92 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "push run")
93
94 _, err := c.dispatch(c.root, "deploy.yml", "", nil)
95 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a required input is missing")
96 assert.Contains(t, err.Error(), "Required input 'version'")
97 _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "nope": "x"})
98 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "an undeclared input")
99 _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "dry": "maybe"})
100 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a boolean")
101 _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "count": "0x10"})
102 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a number")
103 _, err = c.dispatch(c.root, "deploy.yml", "", map[string]string{"version": "1", "level": "medium"})
104 assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(err), "a choice outside its options")
105 _, err = c.dispatch(c.root, "ci.yml", "", nil)
106 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "no workflow_dispatch trigger")
107 _, err = c.dispatch(c.root, "missing.yml", "", nil)
108 assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
109 _, err = c.dispatch(c.root, "deploy.yml", "nobranch", map[string]string{"version": "1"})
110 assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "a ref that does not exist")
111 assert.Len(t, c.runs(), 1, "no refused dispatch made a run")
112
113 run, err := c.dispatch(c.root, ".github/workflows/deploy.yml", "main", map[string]string{"version": "1.2", "dry": "true"})
114 require.NoError(t, err)
115 assert.Equal(t, "workflow_dispatch", run.GetEvent())
116 assert.Equal(t, "refs/heads/main", run.GetRef())
117 assert.True(t, run.GetTrusted())
118 assert.Equal(t, "root", run.GetActor(), "the caller, as for a push")
119 assert.Equal(t, "deploy 1.2 dry=true", run.GetName(), "run-name sees the typed inputs")
120 assert.Equal(t, pb.CiStatus_CI_STATUS_QUEUED, run.GetStatus())
121
122 job := runner.await()
123 gh := c.ghContext(job)
124 assert.Equal(t, "workflow_dispatch", gh["event_name"])
125 assert.Equal(t, "refs/heads/main", gh["ref"])
126 ev := gh["event"].(map[string]any)
127 assert.Equal(t, map[string]any{"version": "1.2", "dry": "true", "count": "3", "level": "low"}, ev["inputs"], "github.event.inputs are strings, defaults applied")
128 assert.Equal(t, ".github/workflows/deploy.yml", ev["workflow"])
129 assert.Equal(t, "refs/heads/main", ev["ref"])
130
131 var ctxs map[string]json.RawMessage
132 require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
133 var inputs map[string]any
134 require.NoError(t, json.Unmarshal(ctxs["inputs"], &inputs))
135 assert.Equal(t, map[string]any{"version": "1.2", "dry": true, "count": float64(3), "level": "low", "target": ""}, inputs, "the inputs context is typed")
136 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
137}
138
139func TestDispatchReadsTheWorkflowAtTheRef(t *testing.T) {
140 c := newCIHarness(t, nil)
141 runner := c.registerRunner("alpha", "ubuntu-latest")
142 c.push("main", map[string]string{".github/workflows/ci.yml": pushOnlyWorkflow}, "")
143 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "push run")
144 c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
145 c.push("feat", map[string]string{".github/workflows/ci.yml": "name: ci\non: [push, workflow_dispatch]\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
146 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "push run")
147
148 _, err := c.dispatch(c.root, "ci.yml", "", nil)
149 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "main has no workflow_dispatch")
150 run, err := c.dispatch(c.root, "ci.yml", "feat", nil)
151 require.NoError(t, err)
152 assert.Equal(t, "refs/heads/feat", run.GetRef())
153 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
154}
155
156func TestDispatchAccess(t *testing.T) {
157 c := newCIHarness(t, nil)
158 runner := c.registerRunner("alpha", "ubuntu-latest")
159 c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
160 in := map[string]string{"version": "1"}
161
162 readOnly := c.api(c.token(scopes(domain.ScopeRepoRead), "proj"))
163 _, err := c.dispatch(readOnly, "deploy.yml", "", in)
164 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "ci:write is needed")
165
166 // A token with ci:write but limited to branch prefixes: its run is not trusted,
167 // and it may only dispatch on a branch under its prefixes.
168 secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeCIWrite), []string{"agent/claude/"}, "proj")
169 limited := c.api(secret)
170 _, err = c.dispatch(limited, "deploy.yml", "main", in)
171 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "main is outside the prefixes")
172 c.mustGit(c.wt, "checkout", "-q", "-b", "agent/claude/x")
173 c.push("agent/claude/x", nil, "")
174 run, err := c.dispatch(limited, "deploy.yml", "agent/claude/x", in)
175 require.NoError(t, err)
176 assert.False(t, run.GetTrusted(), "a branch-limited caller gets an untrusted run")
177 job := runner.await()
178 assert.False(t, job.GetTrusted())
179 assert.Empty(t, job.GetSecrets())
180}
181
182func TestDispatchWithAJobToken(t *testing.T) {
183 c := newCIHarness(t, nil)
184 runner := c.registerRunner("alpha", "ubuntu-latest")
185 c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow, ".github/workflows/d.yml": jobTokenDispatcher}, "")
186 jobs := runner.jobsByKey(4)
187 with, without := jobs["with"], jobs["without"]
188 require.NotNil(t, with)
189 require.NotNil(t, without)
190 onlyActions, none := jobs["actionsonly"], jobs["readonly"]
191
192 in := map[string]string{"version": "9"}
193 _, err := c.dispatch(c.api(without.GetGithubToken()), "deploy.yml", "", in)
194 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "no actions: write")
195 run, err := c.dispatch(c.api(with.GetGithubToken()), "deploy.yml", "", in)
196 require.NoError(t, err, "actions: write lets a job token dispatch (GitHub's exception)")
197 assert.Equal(t, "workflow_dispatch", run.GetEvent())
198 assert.Equal(t, "github-actions[bot]", run.GetActor())
199 assert.True(t, run.GetTrusted(), "the dispatching run was trusted")
200
201 // The repository is private and this token has no contents permission at all:
202 // actions: write alone is enough, and a token without it is told nothing.
203 _, err = c.dispatch(c.api(none.GetGithubToken()), "deploy.yml", "", in)
204 assert.Equal(t, connect.CodeNotFound, ciErrCode(err), "no permission at all on a private repository: concealed")
205 run, err = c.dispatch(c.api(onlyActions.GetGithubToken()), "deploy.yml", "", in)
206 require.NoError(t, err, "actions: write without contents: read")
207 assert.Equal(t, "workflow_dispatch", run.GetEvent())
208
209 for _, j := range []*pb.Job{with, without, onlyActions, none} {
210 runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
211 }
212 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "dispatched")
213 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "dispatched")
214}
215
216func TestWorkflowRunCommand(t *testing.T) {
217 c := newCIHarness(t, nil)
218 h := &cliHarness{harness: c.harness, url: c.srv.URL}
219 runner := c.registerRunner("alpha", "ubuntu-latest")
220 c.push("main", map[string]string{".github/workflows/deploy.yml": dispatchWorkflow}, "")
221
222 bad := h.cli(t, adminToken, "workflow", "run", "deploy.yml", "--repo", "proj", "-f", "oops")
223 require.Error(t, bad.err)
224 missing := h.cli(t, adminToken, "workflow", "run", "deploy.yml", "--repo", "proj")
225 require.Error(t, missing.err)
226 assert.Contains(t, missing.err.Error(), "Required input 'version'")
227
228 ok := h.cli(t, adminToken, "workflow", "run", "deploy.yml", "--repo", "proj", "--ref", "main", "-f", "version=a=b", "-f", "dry=true")
229 require.NoError(t, ok.err, ok.stderr)
230 assert.Contains(t, ok.stderr, "started on main")
231 job := runner.await()
232 ev := c.ghContext(job)["event"].(map[string]any)
233 assert.Equal(t, "a=b", ev["inputs"].(map[string]any)["version"])
234
235 list := h.cli(t, adminToken, "run", "list", "proj")
236 require.NoError(t, list.err)
237 assert.Contains(t, list.stdout, "EVENT")
238 assert.Contains(t, list.stdout, "workflow_dispatch")
239}