1package app
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "testing"
8
9 "connectrpc.com/connect"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 pb "bnema.dev/ermite-api/gen/ermite"
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports/mocks"
17 "bnema.dev/ermite/internal/usecase"
18)
19
20func TestCIReusableSecretOutputCannotForgeAReference(t *testing.T) {
21 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
22 c.setSecret(repoScope("proj"), "UNPASSED", "private-value")
23 caller := `on: push
24jobs:
25 prep:
26 uses: ./.github/workflows/producer.yml
27 call:
28 needs: prep
29 uses: ./.github/workflows/callee.yml
30 secrets:
31 TOKEN: ${{ needs.prep.outputs.payload }}
32`
33 producer := `on:
34 workflow_call:
35 outputs:
36 payload:
37 value: ${{ jobs.j.outputs.payload }}
38jobs:
39 j:
40 runs-on: ubuntu-latest
41 outputs:
42 payload: ${{ steps.s.outputs.payload }}
43 steps:
44 - id: s
45 run: echo
46`
47 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/producer.yml": producer, ".github/workflows/callee.yml": secretsCallee}, "")
48 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
49 prep := awaitRecord(c, r, run.GetId(), "prep/j")
50 assert.Empty(t, prep.GetSecrets())
51 r.runJob(prep, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"payload": "\x01UNPASSED\x02"})
52 j := awaitRecord(c, r, run.GetId(), "call/j")
53 assert.NotEqual(t, "private-value", j.GetSecrets()["TOKEN"], "callee output must not mint a secret reference")
54 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
55}
56
57func TestCIReusableSecretExpressions(t *testing.T) {
58 for _, tt := range []struct{ name, value, want string }{
59 {"literal", "literal-value", "literal-value"},
60 {"dynamic", "${{ secrets[vars.KEY] }}", "private-value"},
61 {"conditional", "${{ secrets.SOURCE == 'private-value' && 'yes' || 'no' }}", "yes"},
62 {"function", "${{ format('{0}-x', secrets.SOURCE) }}", "private-value-x"},
63 {"text around", "pre-${{ secrets.SOURCE }}-post", "pre-private-value-post"},
64 } {
65 t.Run(tt.name, func(t *testing.T) {
66 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
67 c.setSecret(repoScope("proj"), "SOURCE", "private-value")
68 c.setVar(repoScope("proj"), "KEY", "SOURCE")
69 caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: " + tt.value + "\n"
70 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}, "")
71 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
72 j := awaitRecord(c, r, run.GetId(), "call/j")
73 assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"])
74 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
75 })
76 }
77}
78
79func TestCIReusableCallMaxParallel(t *testing.T) {
80 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
81 caller := `on: push
82jobs:
83 c:
84 strategy:
85 max-parallel: 1
86 matrix:
87 v: [a,b]
88 uses: ./.github/workflows/callee.yml
89`
90 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": ghCallee}, "")
91 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
92 j := awaitRecord(c, r, run.GetId(), "c/test")
93 second, err := r.fetch("")
94 require.NoError(t, err)
95 assert.Nil(t, second, "max-parallel: 1 must not assign the second call while the first is running")
96 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
97 next := awaitRecord(c, r, run.GetId(), "c/test")
98 assert.NotEqual(t, j.GetJobId(), next.GetJobId(), "the second call starts once the first has ended")
99 r.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
100 assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.waitRunDone(run.GetId()).GetConclusion())
101}
102
103func TestCIReusableOneResolutionPerAttempt(t *testing.T) {
104 remote := newFakeRemote(t)
105 remote.set("acme/shared", ptrRef("refs/heads/main", shaMain))
106 wf := &fakeWorkflows{m: mocks.NewWorkflowFetcher(t)}
107 wf.m.EXPECT().FetchWorkflow(mock.Anything, "acme", "shared", mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _, _, sha, path string, _ int64) ([]byte, error) {
108 remote.set("acme/shared", ptrRef("refs/heads/main", shaOther))
109 return []byte(ghCallee), nil
110 }).Maybe()
111 c, r := newReusableHarness(t, remote, wf)
112 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 b:\n uses: acme/shared/.github/workflows/b.yml@main\n"}, "")
113 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
114 for range 2 {
115 j := r.await()
116 assert.Contains(t, j.GetContexts(), `"workflow_sha":"`+shaMain+`"`, "the first resolution must pin all references of the attempt")
117 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
118 }
119 assert.Equal(t, 1, remote.count())
120 c.waitRunDone(run.GetId())
121}
122
123func TestCIReusableFailedRerunKeepsTheCallee(t *testing.T) {
124 remote, wf := newFakeRemote(t), newFakeWorkflows(t)
125 remote.set("acme/shared", ptrRef("refs/heads/main", shaMain))
126 wf.set("acme/shared@"+shaMain+":.github/workflows/a.yml", ghCallee)
127 wf.set("acme/shared@"+shaOther+":.github/workflows/a.yml", ghCallee)
128 c, r := newReusableHarness(t, remote, wf)
129 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"}, "")
130 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
131 j := r.await()
132 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil)
133 c.waitRunDone(run.GetId())
134 remote.set("acme/shared", ptrRef("refs/heads/main", shaOther))
135 _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: run.GetId(), FailedOnly: true}))
136 require.NoError(t, err)
137 again := r.await()
138 assert.Contains(t, again.GetContexts(), `"workflow_sha":"`+shaMain+`"`, "failed-job reruns must reuse the first attempt's callee commit")
139 r.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
140}
141
142func TestCIReusableJobRecordBudget(t *testing.T) {
143 c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
144 vals := func(n int) string {
145 v := make([]string, n)
146 for i := range v {
147 v[i] = fmt.Sprint(i)
148 }
149 return "[" + strings.Join(v, ",") + "]"
150 }
151 caller := "on: push\njobs:\n c:\n strategy:\n matrix:\n v: " + vals(33) + "\n uses: ./.github/workflows/callee.yml\n"
152 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"
153 c.push("main", map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": callee}, "")
154 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
155 const budgetText = "Workflows may not contain more than 1000 jobs across all referenced files"
156 c.waitFor("the budget refusal", func() bool {
157 for _, j := range c.getRun(run.GetId()).GetJobs() {
158 if strings.Contains(j.GetError(), budgetText) {
159 return true
160 }
161 }
162 return false
163 })
164 assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), usecase.MaxCIChecks, "expanded records obey the job-count limit")
165}
166
167// The ordinary matrices of a workflow share the same budget.
168func TestCIJobRecordBudgetOfMatrices(t *testing.T) {
169 c, _ := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
170 v := make([]string, 200)
171 for i := range v {
172 v[i] = fmt.Sprint(i)
173 }
174 wf := "on: push\njobs:\n"
175 for _, id := range []string{"a", "b", "c", "d", "e", "f"} {
176 wf += " " + id + ":\n strategy:\n matrix:\n v: [" + strings.Join(v, ",") + "]\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"
177 }
178 c.push("main", map[string]string{".github/workflows/ci.yml": wf}, "")
179 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
180 c.waitFor("a job fails on the budget", func() bool {
181 for _, j := range c.getRun(run.GetId()).GetJobs() {
182 if strings.Contains(j.GetError(), "Workflows may not contain more than 1000 jobs across all referenced files") {
183 return true
184 }
185 }
186 return false
187 })
188 assert.LessOrEqual(t, len(c.getRun(run.GetId()).GetJobs()), usecase.MaxCIChecks)
189}
190
191func TestCIReusableCachedGitHubResolutionDoesNotBypassErmite(t *testing.T) {
192 remote, wf := newFakeRemote(t), newFakeWorkflows(t)
193 remote.set("owner/shared", ptrRef("refs/heads/main", shaMain))
194 wf.set("owner/shared@"+shaMain+":.github/workflows/a.yml", ghCallee)
195 c, r := newReusableHarness(t, remote, wf)
196 token, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil, "proj")
197 caller := "on: push\njobs:\n a:\n uses: owner/shared/.github/workflows/a.yml@main\n b:\n needs: a\n uses: owner/shared/.github/workflows/a.yml@main\n"
198 c.pushAs(token, "main", map[string]string{".github/workflows/ci.yml": caller})
199 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
200 first := awaitRecord(c, r, run.GetId(), "a/test")
201 c.seed("shared", domain.VisibilityPrivate)
202 r.runJob(first, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
203 second, err := r.fetch("")
204 require.NoError(t, err)
205 assert.True(t, second == nil, "must not use the cached GitHub source after an unreadable ermite name collision")
206 if second != nil {
207 r.runJob(second, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
208 }
209}
210
211// S2: a literal in "secrets:" arrives although no stored secret resolves; a run
212// that is not trusted (a branch-limited token pushed it) gets nothing.
213func TestCIReusableLiteralSecretNeedsATrustedRun(t *testing.T) {
214 for _, tt := range []struct {
215 name string
216 limited bool
217 want string
218 }{{"owner", false, "literal-value"}, {"limited token", true, ""}} {
219 t.Run(tt.name, func(t *testing.T) {
220 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
221 caller := "on: push\njobs:\n call:\n uses: ./.github/workflows/callee.yml\n secrets:\n TOKEN: literal-value\n"
222 files := map[string]string{".github/workflows/ci.yml": caller, ".github/workflows/callee.yml": secretsCallee}
223 if tt.limited {
224 tok, _ := c.branchToken([]domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, nil)
225 c.pushAs(tok, "main", files)
226 } else {
227 c.push("main", files, "")
228 }
229 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
230 j := awaitRecord(c, r, run.GetId(), "call/j")
231 assert.Equal(t, tt.want, j.GetSecrets()["TOKEN"])
232 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
233 })
234 }
235}
236
237// T3: the name of a nested job carries every caller.
238func TestCIReusableNestedNames(t *testing.T) {
239 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
240 leaf := "on:\n workflow_call:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo\n"
241 mid := "on:\n workflow_call:\njobs:\n inner:\n uses: ./.github/workflows/leaf.yml\n"
242 c.push("main", map[string]string{".github/workflows/leaf.yml": leaf, ".github/workflows/mid.yml": mid, ".github/workflows/ci.yml": "on: push\njobs:\n top:\n uses: ./.github/workflows/mid.yml\n"}, "")
243 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
244 j := awaitRecord(c, r, run.GetId(), "top/inner/j")
245 names := map[string]string{}
246 for _, x := range c.getRun(run.GetId()).GetJobs() {
247 names[x.GetKey()] = x.GetName()
248 }
249 assert.Equal(t, map[string]string{"top": "top", "top/inner": "top / inner", "top/inner/j": "top / inner / j"}, names)
250 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
251}
252
253// S1/S3: secrets pass through two levels of calls, each mapping evaluated at
254// its own level.
255func TestCIReusableSecretsThroughTwoLevels(t *testing.T) {
256 c, r := newReusableHarness(t, newFakeRemote(t), newFakeWorkflows(t))
257 c.setSecret(repoScope("proj"), "TOP", "top-value")
258 leaf := "on:\n workflow_call:\n secrets:\n LEAF:\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo ${{ secrets.LEAF }}\n"
259 mid := "on:\n workflow_call:\n secrets:\n MID:\njobs:\n inner:\n uses: ./.github/workflows/leaf.yml\n secrets:\n LEAF: ${{ secrets.MID }}-leaf\n"
260 c.push("main", map[string]string{".github/workflows/leaf.yml": leaf, ".github/workflows/mid.yml": mid, ".github/workflows/ci.yml": "on: push\njobs:\n top:\n uses: ./.github/workflows/mid.yml\n secrets:\n MID: ${{ secrets.TOP }}\n"}, "")
261 run := c.awaitRunOf(".github/workflows/ci.yml", 0)
262 j := awaitRecord(c, r, run.GetId(), "top/inner/j")
263 assert.Equal(t, map[string]string{"LEAF": "top-value-leaf"}, j.GetSecrets())
264 r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
265}