1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "fmt"
7 "strings"
8 "testing"
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/domain"
16)
17
18const producerWorkflow = `on:
19 workflow_call:
20 outputs:
21 payload:
22 value: ${{ jobs.j.outputs.payload }}
23jobs:
24 j:
25 runs-on: ubuntu-latest
26 outputs:
27 payload: ${{ steps.s.outputs.payload }}
28 steps:
29 - id: s
30 run: echo
31`
32
33// secretCall runs a push whose caller maps TOKEN with value and returns the job
34// of the callee that was assigned.
35func secretCall(t *testing.T, setup func(c *ciHarness), value string) (*ciHarness, *fakeRunner, *pb.Job) {
36 t.Helper()
37 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
38 if setup != nil {
39 setup(c)
40 }
41 caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: " + value + "\n"
42 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}, "")
43 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
44 return c, r, awaitRecord(c, r, run.GetId(), "call/j")
45}
46
47// GO-009: a string that an expression produces is data. It is not read again as
48// an expression, so it cannot name a secret.
49func TestCIReusableEscapedExpressionStaysALiteral(t *testing.T) {
50 c, r, j := secretCall(t, func(c *ciHarness) {
51 c.setSecret(repoScope("proj"), "UNPASSED", "private-value")
52 }, "${{ '${{ secrets.UNPASSED }}' }}")
53 assert.Equal(t, "${{ secrets.UNPASSED }}", j.GetSecrets()["TOKEN"])
54 assert.NotContains(t, c.logs.String(), "private-value")
55 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
56}
57
58// GO-009: nor does a name taken from a context select a secret.
59func TestCIReusableContextDataCannotSelectASecret(t *testing.T) {
60 for _, tt := range []struct{ name, token, payload, want string }{
61 {"a text that looks like an expression", "${{ needs.prep.outputs.payload }}", "${{ secrets.UNPASSED }}", "${{ secrets.UNPASSED }}"},
62 {"a former marker", "${{ needs.prep.outputs.payload }}", "\x01UNPASSED\x02", "\x01UNPASSED\x02"},
63 {"an escaped expression over json", "${{ '${{ secrets[fromJSON(needs.prep.outputs.payload).name] }}' }}", `{"name":"UNPASSED"}`, "${{ secrets[fromJSON(needs.prep.outputs.payload).name] }}"},
64 } {
65 t.Run(tt.name, func(t *testing.T) {
66 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
67 c.setSecret(repoScope("proj"), "UNPASSED", "private-value")
68 caller := "on: push\njobs:\n prep:\n uses: ./.github/workflows/producer.yml\n call:\n needs: prep\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: " + tt.token + "\n"
69 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/producer.yml": producerWorkflow, ".github/workflows/callee.yml": secretsCallee}, "")
70 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
71 prep := awaitRecord(c, r, run.GetId(), "prep/j")
72 assert.Empty(t, prep.GetSecrets())
73 r.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"payload": tt.payload})
74 j := awaitRecord(c, r, run.GetId(), "call/j")
75 assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"])
76 assert.NotEqual(t, "private-value", j.GetSecrets()["TOKEN"])
77 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
78 })
79 }
80}
81
82// GO-009: an expression that selects a secret by data of the caller (an input of
83// the workflow, here) does use the secret it names.
84func TestCIReusableMappingUsesPlainContexts(t *testing.T) {
85 _, r, j := secretCall(t, func(c *ciHarness) {
86 c.setSecret(repoScope("proj"), "SOURCE", "private-value")
87 c.setVar(repoScope("proj"), "KEY", "SOURCE")
88 }, "${{ secrets[vars.KEY] }}")
89 assert.Equal(t, "private-value", j.GetSecrets()["TOKEN"])
90 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
91}
92
93// GO-010: an expression error never reaches the log: format() puts its
94// arguments, here a secret, in its message.
95func TestCIReusableSecretEvaluationErrorIsNotLogged(t *testing.T) {
96 secret := "private-value{bad-format}"
97 c, r, j := secretCall(t, func(c *ciHarness) {
98 c.setSecret(repoScope("proj"), "SOURCE", secret)
99 }, "${{ format(secrets.SOURCE) }}")
100 assert.Empty(t, j.GetSecrets()["TOKEN"])
101 logs := c.logs.String()
102 assert.NotContains(t, logs, secret)
103 assert.NotContains(t, logs, "private-value")
104 assert.Contains(t, logs, "could not evaluate", "the mapping is named, with a fixed reason")
105 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
106}
107
108// GO-011: planning does not evaluate the mapping, so a value that needs a real
109// secret (here a JSON one) is not refused for lack of it.
110func TestCIReusableMappingNeedsNoSecretAtPlanning(t *testing.T) {
111 c, r, j := secretCall(t, func(c *ciHarness) {
112 c.setSecret(repoScope("proj"), "JSON", `{"token":"private-value"}`)
113 }, "${{ fromJSON(secrets.JSON).token }}")
114 assert.Equal(t, "private-value", j.GetSecrets()["TOKEN"])
115 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
116 _ = c
117}
118
119// GO-012: the mapping of a call reads the vars of the calling scope, not those
120// of the environment of the job that is assigned.
121func TestCIReusableMappingUsesTheCallersVars(t *testing.T) {
122 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
123 c.setSecret(repoScope("proj"), "PUBLIC", "public-value")
124 c.setSecret(repoScope("proj"), "PRIVATE", "private-value")
125 c.setVar(repoScope("proj"), "KEY", "PUBLIC")
126 _, err := c.root.env.CreateEnvironment(c.ctx, connect.NewRequest(&pb.CreateEnvironmentRequest{Repo: "proj", Name: "prod"}))
127 require.NoError(t, err)
128 c.setVar(envScope("proj", "prod"), "KEY", "PRIVATE")
129 caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: ${{ secrets[vars.KEY] }}\n"
130 callee := strings.Replace(secretsCallee, " runs-on: ubuntu-latest", " runs-on: ubuntu-latest\n environment: prod", 1)
131 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "")
132 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
133 j := awaitRecord(c, r, run.GetId(), "call/j")
134 assert.Equal(t, "public-value", j.GetSecrets()["TOKEN"])
135 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
136}
137
138// An input handed down as an escaped expression is data in a callee that
139// inherits secrets, and an untrusted run gets nothing.
140func TestCIReusableInheritScopeTrustAndInputData(t *testing.T) {
141 for _, limited := range []bool{false, true} {
142 t.Run(map[bool]string{false: "trusted", true: "untrusted"}[limited], func(t *testing.T) {
143 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
144 c.setSecret(repoScope("proj"), "TOP", "top-value")
145 c.setSecret(repoScope("proj"), "UNPASSED", "private-value")
146 leaf := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ toJSON(secrets) }}\n"
147 mid := "on:\n workflow_call:\n inputs:\n text:\n type: string\n secrets:\n MID:\njobs:\n inner:\n uses: ./.github/workflows/leaf.yml\n secrets: inherit\n"
148 caller := "on: push\njobs:\n top:\n uses: ./.github/workflows/mid.yml\n with:\n text: '${{ ''${{ secrets.UNPASSED }}'' }}'\n secrets:\n MID: ${{ secrets.TOP }}\n"
149 files := map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/mid.yml": mid, ".github/workflows/leaf.yml": leaf}
150 if limited {
151 tok, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
152 c.pushAs(tok, "main", files)
153 } else {
154 c.push("main", files, "")
155 }
156 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
157 j := awaitRecord(c, r, run.GetId(), "top/inner/j")
158 if limited {
159 assert.Empty(t, j.GetSecrets())
160 } else {
161 assert.Equal(t, map[string]string{"MID": "top-value"}, j.GetSecrets())
162 }
163 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
164 })
165 }
166}
167
168// A secret cannot be handed to the callee as an input.
169func TestCIReusableSecretsAreRefusedInWith(t *testing.T) {
170 c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
171 c.setSecret(repoScope("proj"), "SOURCE", "private-value")
172 caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n with:\n go-version: ${{ secrets.SOURCE }}\n"
173 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": ghCallee}, "")
174 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
175 c.waitRunDone(run.GetId())
176 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.getRun(run.GetId()).GetRun().GetConclusion())
177 assert.NotContains(t, c.logs.String(), "private-value")
178}
179
180// A re-run of everything resolves the reference of a called workflow afresh.
181func TestCIReusableRerunAllRefreshesTheCallee(t *testing.T) {
182 remote, wf := newFakeRemote(t), newFakeWorkflows(t)
183 remote.set("acme/shared", ptrRef("refs/heads/main", shaMain))
184 wf.set("acme/shared@"+shaMain+":.github/workflows/a.yml", ghCallee)
185 wf.set("acme/shared@"+shaOther+":.github/workflows/a.yml", ghCallee)
186 c, r := newReusableHarness(t, remote, wf)
187 c.push("main", map[string]string{".github/workflows/ci.yml": "on: push\njobs:\n a:\n uses: acme/shared/.github/workflows/a.yml@main\n"}, "")
188 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
189 first := r.await()
190 assert.Contains(t, first.GetContexts(), `"workflow_sha":"`+shaMain+`"`)
191 r.runJob(first, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil)
192 c.waitRunDone(run.GetId())
193
194 remote.set("acme/shared", ptrRef("refs/heads/main", shaOther))
195 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId()}))
196 require.NoError(t, err)
197 again := r.await()
198 assert.Contains(t, again.GetContexts(), `"workflow_sha":"`+shaOther+`"`, "the reference is resolved again")
199 r.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
200 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion())
201}
202
203// A matrix of calls with max-parallel and both concurrency groups progresses
204// one call at a time to the end, and the groups are free for the next run.
205func TestCIReusableMaxParallelWithBothGroups(t *testing.T) {
206 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
207 caller := "on: push\njobs:\n c:\n strategy:\n max-parallel: 1\n matrix:\n v: [a, b, c]\n concurrency: caller-group\n uses: ./.github/workflows/callee.yml\n"
208 callee := strings.Replace(ghCallee, "jobs:\n", "concurrency: callee-group\njobs:\n", 1)
209 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "")
210 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
211 for range 3 {
212 j := awaitRecord(c, r, run.GetId(), "c/test")
213 c.forge.CI.ReconcileConcurrency(c.ctx)
214 second, err := r.fetch("")
215 require.NoError(t, err)
216 assert.Nil(t, second, "one call at a time")
217 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
218 }
219 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion())
220 c.push("main", map[string]string{"x": "second"}, "")
221 next := c.awaitRunOf(".github/workflows/ci.yml", run.GetId())
222 for range 3 {
223 j := awaitRecord(c, r, next.GetId(), "c/test")
224 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
225 }
226 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(next.GetId()).GetConclusion())
227}
228
229// The job-record budget holds again after a re-run of the failed jobs.
230func TestCIReusableBudgetHoldsOnRerun(t *testing.T) {
231 c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
232 vals := func(n int) string {
233 v := make([]string, n)
234 for i := range v {
235 v[i] = fmt.Sprint(i)
236 }
237 return "[" + strings.Join(v, ",") + "]"
238 }
239 caller := "on: push\njobs:\n c:\n strategy:\n matrix:\n v: " + vals(33) + "\n uses: ./.github/workflows/callee.yml\n"
240 callee := "on: workflow_call\njobs:\n j:\n strategy:\n matrix:\n v: " + vals(32) + "\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"
241 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "")
242 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
243 refused := func() bool {
244 for _, j := range c.getRun(run.GetId()).GetJobs() {
245 if strings.Contains(j.GetError(), "Workflows may not contain more than 1000 jobs across all referenced files") {
246 return true
247 }
248 }
249 return false
250 }
251 c.waitFor("the budget refusal", refused)
252 assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), 1000)
253 _, _ = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
254 c.waitRunDone(run.GetId())
255 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId(), FailedOnly: true}))
256 require.NoError(t, err)
257 c.waitFor("the refusal again", refused)
258 assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), 1000)
259}