Skip to content

Commit 3b4cbcf

Browse files
authored
test(act): add argo, vault and github app token mocks (#502)
1 parent ed8da62 commit 3b4cbcf

5 files changed

Lines changed: 445 additions & 46 deletions

File tree

tests/act/internal/workflow/ci/ci.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,7 @@ func NewWorkflow(opts ...WorkflowOption) (Workflow, error) {
6464
testingWf := Workflow{workflow.NewTestingWorkflow("simple-ci", ciBaseWf)}
6565

6666
// Add the child workflow (ci) now, so further customization can be done on it via opts.
67-
// Use the same UUID as the parent, so they have the same uuid in the file name
68-
// and it is easier to correlate them.
69-
childTestingWf := workflow.NewTestingWorkflow("ci", childBaseWf, workflow.WithUUID(testingWf.UUID()))
67+
childTestingWf := workflow.NewTestingWorkflow("ci", childBaseWf)
7068
testingWf.AddChild("ci", childTestingWf)
7169

7270
// Change the parent workflow so it calls the mocked child workflow

tests/act/internal/workflow/mock.go

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
package workflow
33

44
import (
5+
"encoding/json"
56
"fmt"
67
"path/filepath"
78
"strings"
@@ -10,6 +11,11 @@ import (
1011
const (
1112
GCSLoginAction = "google-github-actions/auth"
1213
GCSUploadAction = "google-github-actions/upload-cloud-storage"
14+
15+
VaultSecretsAction = "grafana/shared-workflows/actions/get-vault-secrets"
16+
ArgoWorkflowAction = "grafana/shared-workflows/actions/trigger-argo-workflow"
17+
18+
GitHubAppTokenAction = "actions/create-github-app-token"
1319
)
1420

1521
// CopyMockFilesStep returns a Step that copies mock files from a source folder to a destination folder.
@@ -19,7 +25,6 @@ const (
1925
// You can use GitHub Actions expressions in destFolder, e.g., "${{ github.workspace }}/plugins/my-plugin/dist".
2026
func CopyMockFilesStep(sourceFolder string, destFolder string) Step {
2127
return Step{
22-
Name: "Copy mock files",
2328
Run: Commands{
2429
"set -x",
2530
"mkdir -p " + destFolder,
@@ -33,10 +38,9 @@ func CopyMockFilesStep(sourceFolder string, destFolder string) Step {
3338

3439
// NoOpStep returns a Step that does nothing (no-op) for testing purposes.
3540
// The step simply echoes a message indicating it is a no-op step.
36-
func NoOpStep(id string) Step {
41+
func NoOpStep(originalStep Step) Step {
3742
return Step{
38-
Name: id + " (no-opp'ed for testing)",
39-
ID: id,
43+
Name: originalStep.nameOrID() + " (no-op'd for testing)",
4044
Run: "echo 'noop-ed step for testing'",
4145
Shell: "bash",
4246
}
@@ -90,3 +94,159 @@ func MockGCSUploadStep(originalStep Step) (Step, error) {
9094
func LocalMockdataPath(parts ...string) string {
9195
return filepath.Join("tests", "act", "mockdata", filepath.Join(parts...))
9296
}
97+
98+
// VaultSecrets allows defining the secret values that the mocked get-vault-secrets step should return.
99+
// The keys in the maps must match the secret reference used in the workflow step inputs
100+
// (the value on the right side of the equals sign).
101+
//
102+
// Example:
103+
//
104+
// If the workflow has:
105+
// common_secrets: |
106+
// MY_SECRET=secret/path:key
107+
//
108+
// Then the VaultSecrets should have:
109+
// CommonSecrets: map[string]string{
110+
// "secret/path:key": "mock-value",
111+
// }
112+
type VaultSecrets struct {
113+
// CommonSecrets contains secrets that are referenced in the 'common_secrets' input.
114+
CommonSecrets map[string]string
115+
// RepoSecrets contains secrets that are referenced in the 'repo_secrets' input.
116+
RepoSecrets map[string]string
117+
118+
// DefaultValue is the default value to use for secrets that are not defined in CommonSecrets or RepoSecrets.
119+
// If nil, the step will fail to be constructed if a secret is not defined.
120+
// If not nil, this value will be used for secrets that are not defined.
121+
DefaultValue *string
122+
}
123+
124+
// MockVaultSecretsStep returns a Step that mocks the grafana/shared-workflows/actions/get-vault-secrets action.
125+
// Instead of actually fetching secrets from Vault, it outputs the provided secrets in the expected format.
126+
// The originalStep parameter is the original Vault secrets step to be mocked.
127+
// The original step must use the `grafana/shared-workflows/actions/get-vault-secrets` action.
128+
// If those conditions are not met, an error is returned.
129+
//
130+
// The mocked step mimics the behavior of the original step, but instead of fetching secrets from Vault,
131+
// it outputs the provided secrets in the expected format.
132+
// If export_env is true, the secrets are exported as environment variables.
133+
// If export_env is false, the secrets are exported as a JSON object.
134+
//
135+
// If a secret is not found in the provided VaultSecrets:
136+
// - If DefaultValue is nil, an error is returned.
137+
// - If DefaultValue is not nil, the DefaultValue is used.
138+
func MockVaultSecretsStep(originalStep Step, secrets VaultSecrets) (Step, error) {
139+
// Make sure the original step is indeed a Vault secrets step
140+
if !strings.HasPrefix(originalStep.Uses, VaultSecretsAction) {
141+
return Step{}, fmt.Errorf("cannot mock vault secrets for a step that uses %q action, must be %q", originalStep.Uses, VaultSecretsAction)
142+
}
143+
144+
// Extract original inputs with safe type assertions and defaults
145+
var commonSecretsInput, repoSecretsInput string
146+
if v, ok := originalStep.With["common_secrets"].(string); ok {
147+
commonSecretsInput = v
148+
}
149+
if v, ok := originalStep.With["repo_secrets"].(string); ok {
150+
repoSecretsInput = v
151+
}
152+
exportEnvInput := true
153+
if v, ok := originalStep.With["export_env"].(bool); ok {
154+
exportEnvInput = v
155+
}
156+
output := map[string]string{}
157+
for _, s := range []struct {
158+
input string
159+
secrets map[string]string
160+
}{
161+
{commonSecretsInput, secrets.CommonSecrets},
162+
{repoSecretsInput, secrets.RepoSecrets},
163+
} {
164+
for i, line := range strings.Split(s.input, "\n") {
165+
line = strings.TrimSpace(line)
166+
if line == "" {
167+
continue
168+
}
169+
parts := strings.Split(line, "=")
170+
if len(parts) != 2 {
171+
return Step{}, fmt.Errorf("invalid input, not enough parts on line %d: %s", i, line)
172+
}
173+
outputName := strings.TrimSpace(parts[0])
174+
secretReference := strings.TrimSpace(parts[1])
175+
secretValue, ok := s.secrets[secretReference]
176+
if !ok && secrets.DefaultValue == nil {
177+
return Step{}, fmt.Errorf("secret reference %q not found in provided fake secrets %+v", secretReference, secrets)
178+
}
179+
if !ok {
180+
secretValue = *secrets.DefaultValue
181+
}
182+
output[outputName] = secretValue
183+
}
184+
}
185+
186+
step := Step{
187+
Env: map[string]string{},
188+
Shell: "bash",
189+
}
190+
var stepCommands Commands
191+
if exportEnvInput {
192+
// Workflow-level env vars output
193+
for k, v := range output {
194+
stepCommands = append(stepCommands, fmt.Sprintf(`echo "%s=%s" >> "$GITHUB_ENV"`, k, v))
195+
}
196+
} else {
197+
// JSON output
198+
secretsJSON, err := json.Marshal(output)
199+
if err != nil {
200+
return Step{}, fmt.Errorf("marshal vault secrets to json: %w", err)
201+
}
202+
stepCommands = append(stepCommands, `echo "secrets=${SECRETS_JSON}" >> "$GITHUB_OUTPUT"`)
203+
step.Env = map[string]string{"SECRETS_JSON": string(secretsJSON)}
204+
}
205+
step.Run = stepCommands.String()
206+
return step, nil
207+
}
208+
209+
// MockArgoWorkflowStep returns a Step that mocks the grafana/shared-workflows/actions/trigger-argo-workflow action.
210+
// Instead of actually triggering an Argo Workflow, it outputs a mock URI for the workflow.
211+
// The originalStep parameter is the original Argo Workflow trigger step to be mocked.
212+
// The original step must use the `grafana/shared-workflows/actions/trigger-argo-workflow` action.
213+
// If those conditions are not met, an error is returned.
214+
//
215+
// The mocked step outputs the `uri` output expected by subsequent steps.
216+
func MockArgoWorkflowStep(originalStep Step) (Step, error) {
217+
// Make sure the original step is indeed an Argo Workflow trigger step
218+
if !strings.HasPrefix(originalStep.Uses, ArgoWorkflowAction) {
219+
return Step{}, fmt.Errorf("cannot mock argo workflow for a step that uses %q action, must be %q", originalStep.Uses, ArgoWorkflowAction)
220+
}
221+
222+
return Step{
223+
Run: Commands{
224+
`echo "Mocking Argo Workflow trigger step"`,
225+
`echo "uri=https://mock-argo-workflows.example.com/workflows/grafana-plugins-cd/mock-workflow-id" >> "$GITHUB_OUTPUT"`,
226+
}.String(),
227+
Shell: "bash",
228+
}, nil
229+
}
230+
231+
// MockGitHubAppTokenStep returns a Step that mocks the actions/create-github-app-token action.
232+
// Instead of actually creating a GitHub app token, it outputs a mock token.
233+
// The originalStep parameter is the original GitHub app token step to be mocked.
234+
// The original step must use the `actions/create-github-app-token` action.
235+
// If those conditions are not met, an error is returned.
236+
//
237+
// The mocked step outputs the `token` output expected by subsequent steps.
238+
func MockGitHubAppTokenStep(originalStep Step, token string) (Step, error) {
239+
if !strings.HasPrefix(originalStep.Uses, GitHubAppTokenAction) {
240+
return Step{}, fmt.Errorf("cannot mock github app token for a step that uses %q action, must be %q", originalStep.Uses, GitHubAppTokenAction)
241+
}
242+
return Step{
243+
Run: Commands{
244+
`echo "Mocking GitHub app token step"`,
245+
`echo "token=${MOCK_TOKEN}" >> "$GITHUB_OUTPUT"`,
246+
}.String(),
247+
Shell: "bash",
248+
Env: map[string]string{
249+
"MOCK_TOKEN": token,
250+
},
251+
}, nil
252+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package workflow
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestMockVaultSecretsStep(t *testing.T) {
11+
const (
12+
vaultAction = "grafana/shared-workflows/actions/get-vault-secrets@a37de51f3d713a30a9e4b21bcdfbd38170020593"
13+
bashOutput = `echo "secrets=${SECRETS_JSON}" >> "$GITHUB_OUTPUT"`
14+
)
15+
vault := VaultSecrets{
16+
CommonSecrets: map[string]string{
17+
"common_secret_1:a": "value1",
18+
"common_secret_2:b": "value2",
19+
},
20+
RepoSecrets: map[string]string{
21+
"repo_secret_1:c": "value3",
22+
"repo_secret_2:d": "value4",
23+
},
24+
}
25+
26+
t.Run("common secrets", func(t *testing.T) {
27+
step := Step{
28+
Name: "Get Vault Secrets",
29+
Uses: vaultAction,
30+
With: map[string]any{
31+
"common_secrets": strings.Join([]string{
32+
"SECRET1=common_secret_1:a",
33+
"SECRET2=common_secret_2:b",
34+
}, "\n"),
35+
"export_env": false,
36+
},
37+
}
38+
mockedStep, err := MockVaultSecretsStep(step, vault)
39+
require.NoError(t, err)
40+
require.Equal(t, "Get Vault Secrets (mocked)", mockedStep.Name)
41+
require.Contains(t, mockedStep.Run, bashOutput)
42+
exp := `{"SECRET1":"value1","SECRET2":"value2"}`
43+
require.Equal(t, exp, mockedStep.Env["SECRETS_JSON"])
44+
})
45+
46+
t.Run("repo secrets", func(t *testing.T) {
47+
step := Step{
48+
Name: "Get Vault Secrets",
49+
Uses: vaultAction,
50+
With: map[string]any{
51+
"repo_secrets": strings.Join([]string{
52+
"C=repo_secret_1:c",
53+
"D=repo_secret_2:d",
54+
}, "\n"),
55+
"export_env": false,
56+
},
57+
}
58+
mockedStep, err := MockVaultSecretsStep(step, vault)
59+
require.NoError(t, err)
60+
require.Equal(t, "Get Vault Secrets (mocked)", mockedStep.Name)
61+
require.Contains(t, mockedStep.Run, bashOutput)
62+
exp := `{"C":"value3","D":"value4"}`
63+
require.Equal(t, exp, mockedStep.Env["SECRETS_JSON"])
64+
})
65+
66+
t.Run("common + repo secrets", func(t *testing.T) {
67+
step := Step{
68+
Name: "Get Vault Secrets",
69+
Uses: vaultAction,
70+
With: map[string]any{
71+
"common_secrets": strings.Join([]string{
72+
"SECRET1=common_secret_1:a",
73+
"SECRET2=common_secret_2:b",
74+
}, "\n"),
75+
"repo_secrets": strings.Join([]string{
76+
"C=repo_secret_1:c",
77+
"D=repo_secret_2:d",
78+
}, "\n"),
79+
"export_env": false,
80+
},
81+
}
82+
mockedStep, err := MockVaultSecretsStep(step, vault)
83+
require.NoError(t, err)
84+
require.Equal(t, "Get Vault Secrets (mocked)", mockedStep.Name)
85+
require.Contains(t, mockedStep.Run, bashOutput)
86+
exp := `{"C":"value3","D":"value4","SECRET1":"value1","SECRET2":"value2"}`
87+
require.Equal(t, exp, mockedStep.Env["SECRETS_JSON"])
88+
})
89+
90+
t.Run("unexisting secret", func(t *testing.T) {
91+
step := Step{
92+
Name: "Get Vault Secrets",
93+
Uses: vaultAction,
94+
With: map[string]any{
95+
"common_secrets": strings.Join([]string{
96+
"SECRET1=this_secret_does_not_exist:a",
97+
}, "\n"),
98+
"export_env": false,
99+
},
100+
}
101+
_, err := MockVaultSecretsStep(step, vault)
102+
require.ErrorContains(t, err, "this_secret_does_not_exist")
103+
require.ErrorContains(t, err, "not found in provided fake secrets")
104+
})
105+
106+
t.Run("unexisting secret with default value", func(t *testing.T) {
107+
step := Step{
108+
Name: "Get Vault Secrets",
109+
Uses: vaultAction,
110+
With: map[string]any{
111+
"common_secrets": strings.Join([]string{
112+
"SECRET1=a:b",
113+
"SECRET2=this_secret_does_not_exist:a",
114+
}, "\n"),
115+
"export_env": false,
116+
},
117+
}
118+
defaultValue := "foo"
119+
mockedStep, err := MockVaultSecretsStep(step, VaultSecrets{
120+
CommonSecrets: map[string]string{
121+
"a:b": "c",
122+
},
123+
DefaultValue: &defaultValue,
124+
})
125+
require.NoError(t, err)
126+
require.Equal(t, `{"SECRET1":"c","SECRET2":"foo"}`, mockedStep.Env["SECRETS_JSON"])
127+
})
128+
}
129+
130+
func TestMockArgoWorkflowStep(t *testing.T) {
131+
step := Step{
132+
Name: "Trigger Argo Workflow",
133+
Uses: "grafana/shared-workflows/actions/trigger-argo-workflow@e100806688f1209051080dfea5719fbbd1d18cc0",
134+
}
135+
mockedStep, err := MockArgoWorkflowStep(step)
136+
require.NoError(t, err)
137+
require.Equal(t, "Trigger Argo Workflow (mocked)", mockedStep.Name)
138+
require.Contains(t, mockedStep.Run, `echo "uri=https://mock-argo-workflows.example.com/workflows/grafana-plugins-cd/mock-workflow-id" >> "$GITHUB_OUTPUT"`)
139+
}

0 commit comments

Comments
 (0)