1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "strings"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10
11 "bnema.dev/ermite/internal/usecase"
12)
13
14// Which secrets a job refers to: a name by dot or bracket, anything else is all.
15func TestSecretsUsedBy(t *testing.T) {
16 t.Parallel()
17 steps := func(run string) string {
18 return "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: " + run + "\n"
19 }
20 cases := []struct {
21 name string
22 yaml string
23 want usecase.SecretUse
24 }{
25 {"none", steps("echo hi"), usecase.SecretUse{}},
26 {"dot", steps("echo ${{ secrets.Token }}"), usecase.SecretUse{Names: []string{"TOKEN"}}},
27 {"bracket", steps(`echo "${{ secrets['my_Token'] }}"`), usecase.SecretUse{Names: []string{"MY_TOKEN"}}},
28 {"bracket with double quotes is not an expression string", steps(`echo '${{ secrets["x"] }}'`), usecase.SecretUse{All: true}},
29 {"several, sorted, deduplicated", steps("echo ${{ secrets.B }} ${{ secrets.a }} ${{ secrets.B }}"), usecase.SecretUse{Names: []string{"A", "B"}}},
30 {"in a condition", steps("echo\n if: ${{ secrets.FLAG != '' }}"), usecase.SecretUse{Names: []string{"FLAG"}}},
31 {"in a function", steps("echo ${{ format('{0}', secrets.X) }}"), usecase.SecretUse{Names: []string{"X"}}},
32 {"the context name is case-insensitive", steps("echo ${{ SECRETS.x }}"), usecase.SecretUse{Names: []string{"X"}}},
33 {"bare context", steps("echo ${{ secrets }}"), usecase.SecretUse{All: true}},
34 {"toJSON(secrets)", steps("echo ${{ toJSON(secrets) }}"), usecase.SecretUse{All: true}},
35 {"computed index", steps("echo ${{ secrets[matrix.name] }}"), usecase.SecretUse{All: true}},
36 {"star", steps("echo ${{ secrets.* }}"), usecase.SecretUse{All: true}},
37 {"a named secret next to a bare use", steps("echo ${{ secrets.A }} ${{ join(secrets) }}"), usecase.SecretUse{All: true, Names: []string{"A"}}},
38 {"a string literal that says secrets is not a use", steps("echo ${{ 'secrets.NOT' }}"), usecase.SecretUse{}},
39 {"text outside the braces is not a use", steps("echo secrets.NOT"), usecase.SecretUse{}},
40 {"an expression with a quoted brace", steps("echo ${{ format('}}', secrets.Q) }}"), usecase.SecretUse{Names: []string{"Q"}}},
41 {"does not parse but mentions secrets", steps("echo ${{ secrets.A == }}"), usecase.SecretUse{All: true}},
42 {"does not parse and does not mention them", steps("echo ${{ == }}"), usecase.SecretUse{}},
43 {"not YAML", "on: [", usecase.SecretUse{All: true}},
44 {"not a mapping", "- a\n- b\n", usecase.SecretUse{All: true}},
45 {"empty", "", usecase.SecretUse{All: true}},
46 }
47 for _, tc := range cases {
48 assert.Equal(t, tc.want, usecase.SecretsUsedBy(tc.yaml, "a"), tc.name)
49 }
50}
51
52func TestSecretsUsedByCoversTheWholeSubtreeAndWorkflowEnvOnly(t *testing.T) {
53 t.Parallel()
54 src := `on: push
55env:
56 WF: ${{ secrets.WORKFLOW_LEVEL }}
57defaults:
58 run:
59 shell: bash
60jobs:
61 a:
62 runs-on: ${{ secrets.IN_RUNS_ON }}
63 name: ${{ secrets.IN_NAME }}
64 env:
65 E: ${{ secrets.JOB_ENV }}
66 container:
67 image: x
68 credentials:
69 password: ${{ secrets.REGISTRY }}
70 services:
71 db:
72 image: y
73 env:
74 P: ${{ secrets.SERVICE }}
75 outputs:
76 o: ${{ secrets.OUTPUT }}
77 strategy:
78 matrix:
79 k: [ "${{ secrets.MATRIX }}" ]
80 steps:
81 - uses: some/action@v1
82 with:
83 token: ${{ secrets.WITH_INPUT }}
84 - run: echo
85 env: { K: "${{ secrets.FLOW_STYLE }}" }
86 b:
87 runs-on: x
88 steps:
89 - run: echo ${{ secrets.OTHER_JOB }}
90`
91 got := usecase.SecretsUsedBy(src, "a")
92 assert.False(t, got.All)
93 assert.Equal(t, []string{
94 "FLOW_STYLE", "IN_NAME", "IN_RUNS_ON", "JOB_ENV", "MATRIX", "OUTPUT", "REGISTRY", "SERVICE", "WITH_INPUT", "WORKFLOW_LEVEL",
95 }, got.Names, "every expression of the job and of the workflow env; not another job's")
96 assert.Equal(t, []string{"OTHER_JOB", "WORKFLOW_LEVEL"}, usecase.SecretsUsedBy(src, "b").Names)
97 assert.Equal(t, []string{"WORKFLOW_LEVEL"}, usecase.SecretsUsedBy(src, "missing").Names)
98}
99
100// An anchor defined in another job (or reached through a chain of aliases) is
101// followed: the job that aliases it refers to its secrets.
102func TestSecretsUsedByFollowsYAMLAliases(t *testing.T) {
103 t.Parallel()
104 src := `on: push
105jobs:
106 b:
107 runs-on: x
108 env: &shared
109 K: ${{ secrets.FROM_OTHER_JOB }}
110 steps:
111 - run: echo
112 a:
113 runs-on: x
114 env: *shared
115 steps:
116 - run: echo
117 env: &chain
118 J: ${{ secrets.CHAIN_ROOT }}
119 - run: echo
120 env: *chain
121 c:
122 runs-on: x
123 steps:
124 - run: echo
125 with: { list: [ *shared, *chain ] }
126`
127 assert.Equal(t, []string{"FROM_OTHER_JOB"}, usecase.SecretsUsedBy(src, "b").Names)
128 assert.Equal(t, []string{"CHAIN_ROOT", "FROM_OTHER_JOB"}, usecase.SecretsUsedBy(src, "a").Names, "anchor from another job, and an anchor used twice")
129 assert.Equal(t, []string{"CHAIN_ROOT", "FROM_OTHER_JOB"}, usecase.SecretsUsedBy(src, "c").Names, "aliases inside a flow sequence")
130}
131
132// Deeply nested YAML is read as "all secrets" rather than followed forever.
133func TestSecretsUsedByDeepNestingIsAll(t *testing.T) {
134 t.Parallel()
135 var b strings.Builder
136 b.WriteString("on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo\n env: ")
137 b.WriteString(strings.Repeat("[", 400) + strings.Repeat("]", 400) + "\n")
138 assert.True(t, usecase.SecretsUsedBy(b.String(), "a").All)
139}
140
141// The analysis is made once per run and job, kept apart from the run it
142// belongs to, and dropped with it. It holds names only.
143func TestSecretUseIsCachedPerRunAndJob(t *testing.T) {
144 src := "on: push\njobs:\n a:\n runs-on: x\n steps:\n - run: echo ${{ secrets.A }}\n b:\n runs-on: x\n steps:\n - run: echo ${{ secrets.B }}\n"
145 c := usecase.NewSecretUseCache(4)
146 before := usecase.SecretWalks()
147 for range 5 {
148 assert.Equal(t, []string{"A"}, c.Use(1, src, "a").Names)
149 }
150 assert.EqualValues(t, 1, usecase.SecretWalks()-before, "five legs, one walk")
151 assert.Equal(t, []string{"B"}, c.Use(1, src, "b").Names, "another job of the run has its own")
152 assert.Equal(t, []string{"A"}, c.Use(2, src, "a").Names, "another run too")
153 assert.EqualValues(t, 3, usecase.SecretWalks()-before)
154 c.Drop(1)
155 c.Use(1, src, "a")
156 assert.EqualValues(t, 4, usecase.SecretWalks()-before, "dropped with the run")
157}