1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/config"
15 "bnema.dev/ermite/internal/domain"
16)
17
18func TestParseEnvFile(t *testing.T) {
19 got, err := parseEnvFile(strings.NewReader(`# comment
20
21A=1
22export B = two words
23C='single $quoted\n'
24D="double\nquoted \"q\" \\ end"
25E=
26F=a=b
27G="unterminated
28`))
29 require.NoError(t, err)
30 assert.Equal(t, [][2]string{
31 {"A", "1"}, {"B", "two words"}, {"C", `single $quoted\n`}, {"D", "double\nquoted \"q\" \\ end"},
32 {"E", ""}, {"F", "a=b"}, {"G", `"unterminated`},
33 }, got)
34
35 for _, bad := range []string{"no equals sign\n", "=value\n", " = x\n"} {
36 _, err := parseEnvFile(strings.NewReader(bad))
37 require.Error(t, err, bad)
38 assert.Contains(t, err.Error(), "line 1")
39 }
40}
41
42func newSecretsCLI(t *testing.T) *cliHarness {
43 t.Helper()
44 h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, nil)
45 h.createRepo("proj", domain.VisibilityPrivate)
46 return &cliHarness{harness: h, url: h.srv.URL}
47}
48
49// `ermite secret|variable|environment` against a real server: scopes, value
50// sources, listings, and that no command ever prints a secret value.
51func TestCLISecretsVariablesEnvironments(t *testing.T) {
52 h := newSecretsCLI(t)
53 run := func(stdin string, args ...string) result {
54 return runCLI(t, h.vars(adminToken), stdin, args...)
55 }
56 const value = "S3CRET-VALUE-do-not-print"
57 var outputs strings.Builder
58 record := func(r result) result { outputs.WriteString(r.stdout + "\n" + r.stderr + "\n"); return r }
59
60 // A value from standard input, with its trailing line break dropped; one
61 // from --body; and a multi-line one.
62 r := record(run(value+"\n", "secret", "set", "api_token", "--repo", "proj").ok(t))
63 assert.Equal(t, "set secret API_TOKEN for proj\n", r.stdout)
64 record(run("", "secret", "set", "BODY_ONE", "--repo", "proj", "--body", value+"-body").ok(t))
65 record(run("line one\nline two\n", "secret", "set", "MULTI", "-R", "proj").ok(t))
66 record(run(value+"-inst", "secret", "set", "INST", "--instance").ok(t))
67
68 // An environment: created by the command, then used with --env.
69 r = run("", "environment", "create", "Production", "--repo", "proj").ok(t)
70 assert.Equal(t, "environment Production of proj\n", r.stdout)
71 run("", "environment", "create", "production", "-R", "proj").ok(t)
72 record(run(value+"-env", "secret", "set", "ENV_ONE", "--repo", "proj", "--env", "PRODUCTION").ok(t))
73
74 // list: names and dates only, ordered, per scope; --json too.
75 r = record(run("", "secret", "list", "--repo", "proj").ok(t))
76 assert.Contains(t, r.stdout, "NAME")
77 assert.Regexp(t, `(?s)API_TOKEN.*BODY_ONE.*MULTI`, r.stdout)
78 assert.NotContains(t, r.stdout, "ENV_ONE", "the environment's own scope is not mixed in")
79 r = record(run("", "secret", "list", "--repo", "proj", "--env", "production", "--json").ok(t))
80 secrets := decode[[]jsonSecret](t, r.stdout)
81 require.Len(t, secrets, 1)
82 assert.Equal(t, "ENV_ONE", secrets[0].Name)
83 assert.NotNil(t, secrets[0].UpdatedAt)
84 assert.Equal(t, []string{"name", "updated_at"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(r.stdout), "[]"))))
85 r = record(run("", "secret", "list", "--instance", "--json").ok(t))
86 assert.Equal(t, "INST", decode[[]jsonSecret](t, r.stdout)[0].Name)
87
88 // delete
89 r = run("", "secret", "delete", "body_one", "--repo", "proj").ok(t)
90 assert.Equal(t, "deleted secret BODY_ONE from proj\n", r.stdout)
91 gone := run("", "secret", "delete", "BODY_ONE", "--repo", "proj")
92 require.Error(t, gone.err)
93 r = record(run("", "secret", "list", "--repo", "proj", "--json").ok(t))
94 assert.Len(t, decode[[]jsonSecret](t, r.stdout), 2)
95
96 // --env-file sets several at once, in a repository and on the instance.
97 envFile := filepath.Join(t.TempDir(), ".env")
98 require.NoError(t, os.WriteFile(envFile, []byte("# secrets\nFROM_FILE_A="+value+"-a\nexport FROM_FILE_B='"+value+"-b'\n"), 0o600))
99 r = record(run("", "secret", "set", "--env-file", envFile, "--repo", "proj").ok(t))
100 assert.Equal(t, "set secret FROM_FILE_A for proj\nset secret FROM_FILE_B for proj\n", r.stdout)
101 record(run("X_STDIN="+value+"-x\n", "secret", "set", "-f", "-", "--instance").ok(t))
102 r = record(run("", "secret", "list", "--instance", "--json").ok(t))
103 assert.Len(t, decode[[]jsonSecret](t, r.stdout), 2)
104
105 // Variables are readable.
106 run("hello world\n", "variable", "set", "greeting", "--repo", "proj").ok(t)
107 run("", "variable", "set", "GREETING", "--instance", "--body", "hi").ok(t)
108 run("", "variable", "set", "ENV_VAR", "--repo", "proj", "--env", "Production", "--body", "e").ok(t)
109 run("", "variable", "set", "EMPTY", "--repo", "proj", "--body", "").ok(t)
110 assert.Equal(t, "hello world\n", run("", "variable", "get", "GREETING", "--repo", "proj").ok(t).stdout)
111 assert.Equal(t, "hi\n", run("", "variable", "get", "greeting", "--instance").ok(t).stdout)
112 r = run("", "variable", "get", "GREETING", "--repo", "proj", "--json").ok(t)
113 v := decode[jsonVariable](t, r.stdout)
114 assert.Equal(t, jsonVariable{Name: "GREETING", Value: "hello world", UpdatedAt: v.UpdatedAt}, v)
115 r = run("", "variable", "list", "--repo", "proj").ok(t)
116 assert.Regexp(t, `(?s)EMPTY.*GREETING\s+hello world`, r.stdout)
117 assert.Equal(t, "e\n", run("", "variable", "get", "ENV_VAR", "-R", "proj", "-e", "production").ok(t).stdout)
118 r = run("", "variable", "delete", "empty", "--repo", "proj").ok(t)
119 assert.Equal(t, "deleted variable EMPTY from proj\n", r.stdout)
120 require.Error(t, run("", "variable", "get", "EMPTY", "--repo", "proj").err)
121
122 // environments: list, delete (with their content).
123 r = run("", "environment", "list", "--repo", "proj", "--json").ok(t)
124 envs := decode[[]jsonEnvironment](t, r.stdout)
125 require.Len(t, envs, 1)
126 assert.Equal(t, "Production", envs[0].Name)
127 assert.Equal(t, "proj", envs[0].Repo)
128 assert.Contains(t, run("", "environment", "list", "--repo", "proj").ok(t).stdout, "Production")
129 r = run("", "environment", "delete", "production", "--repo", "proj").ok(t)
130 assert.Equal(t, "deleted environment production of proj\n", r.stdout)
131 assert.Contains(t, run("", "environment", "list", "--repo", "proj").ok(t).stderr, "no environments")
132 require.Error(t, run("", "secret", "list", "--repo", "proj", "--env", "production").err, "its secrets went with it")
133
134 // No command printed a value, and none of them was logged by the server.
135 for _, secret := range []string{value, "line one", "line two"} {
136 assert.NotContains(t, outputs.String(), secret)
137 assert.NotContains(t, h.logs.String(), secret)
138 }
139}
140
141func TestCLISecretsArgumentsAndErrors(t *testing.T) {
142 h := newSecretsCLI(t)
143 run := func(stdin string, args ...string) result {
144 return runCLI(t, h.vars(adminToken), stdin, args...)
145 }
146 for name, args := range map[string][]string{
147 "no scope": {"secret", "set", "A", "--body", "v"},
148 "env without repo": {"secret", "set", "A", "--body", "v", "--env", "prod"},
149 "instance with repo": {"secret", "set", "A", "--body", "v", "--instance", "--repo", "proj"},
150 "instance with env": {"secret", "set", "A", "--body", "v", "--instance", "--env", "prod"},
151 "no name": {"secret", "set", "--repo", "proj", "--body", "v"},
152 "name and env-file": {"secret", "set", "A", "--env-file", "x", "--repo", "proj"},
153 "body and env-file": {"secret", "set", "--body", "v", "--env-file", "x", "--repo", "proj"},
154 "missing env-file": {"secret", "set", "--env-file", "/nonexistent/.env", "--repo", "proj"},
155 "environment without repo": {"environment", "list"},
156 } {
157 require.Error(t, run("", args...).err, name)
158 }
159
160 // A bad name or value is the server's refusal, naming the field, not the value.
161 r := run("", "secret", "set", "GITHUB_TOKEN", "--repo", "proj", "--body", "leak-me")
162 require.Error(t, r.err)
163 assert.Contains(t, r.err.Error(), "GITHUB_")
164 assert.NotContains(t, r.err.Error()+r.stderr+r.stdout, "leak-me")
165 r = run("", "secret", "set", "BIG", "--repo", "proj", "--body", strings.Repeat("x", domain.MaxSettingValueBytes+1))
166 require.Error(t, r.err)
167 assert.Contains(t, r.err.Error(), "49,152")
168 // A file with one bad name sets the others and reports the bad one.
169 f := filepath.Join(t.TempDir(), "e")
170 require.NoError(t, os.WriteFile(f, []byte("GOOD=1\n9BAD=2\nALSO_GOOD=3\n"), 0o600))
171 r = run("", "secret", "set", "-f", f, "--repo", "proj")
172 require.Error(t, r.err)
173 assert.Contains(t, r.err.Error(), "9BAD")
174 assert.Equal(t, "set secret GOOD for proj\nset secret ALSO_GOOD for proj\n", r.stdout)
175 r = run("", "secret", "set", "X", "--repo", "ghost", "--body", "v")
176 require.Error(t, r.err)
177
178 // Not authorised: a token with the scope on another repository only.
179 reader := h.token([]domain.Scope{domain.ScopeRepoRead}, "proj")
180 r = runCLI(t, h.vars(reader), "", "secret", "list", "--repo", "proj")
181 require.Error(t, r.err)
182 assert.Contains(t, r.err.Error(), "secrets:write")
183}
184
185// Standard input larger than the cap is refused with a size error, also when
186// the excess is only line breaks: it is never silently cut. The exact maximum
187// (and its line break) still goes through to the server.
188func TestCLISecretSetStdinSize(t *testing.T) {
189 h := newSecretsCLI(t)
190 run := func(stdin string, args ...string) result {
191 return runCLI(t, h.vars(adminToken), stdin, args...)
192 }
193 max := domain.MaxSettingValueBytes
194 for name, in := range map[string]string{
195 "value over the cap": strings.Repeat("x", max+2000),
196 "newline-padded over cap": strings.Repeat("x", max) + strings.Repeat("\n", 5000),
197 "newlines only, over cap": strings.Repeat("\n", max+2000),
198 "cap plus slack plus a byte": strings.Repeat("x", max+1025),
199 } {
200 r := run(in, "secret", "set", "BIG", "--repo", "proj")
201 require.Error(t, r.err, name)
202 assert.Contains(t, r.err.Error(), "larger than", name)
203 assert.Empty(t, r.stdout, name)
204 }
205 r := run(strings.Repeat("x", max)+"\n", "secret", "set", "EXACT", "--repo", "proj").ok(t)
206 assert.Equal(t, "set secret EXACT for proj\n", r.stdout)
207}
208
209// Secret and variable `set` share the reading of their pairs.
210func TestCLISetPairModes(t *testing.T) {
211 h := newSecretsCLI(t)
212 run := func(stdin string, args ...string) result {
213 return runCLI(t, h.vars(adminToken), stdin, args...)
214 }
215 for _, noun := range []string{"secret", "variable"} {
216 r := run("", noun, "set", "--repo", "proj")
217 require.Error(t, r.err, noun)
218 assert.Contains(t, r.err.Error(), "give the NAME of the "+noun)
219 r = run("", noun, "set", "A", "--env-file", "x", "--repo", "proj")
220 require.Error(t, r.err, noun)
221 assert.Contains(t, r.err.Error(), "not both")
222 empty := filepath.Join(t.TempDir(), "empty")
223 require.NoError(t, os.WriteFile(empty, []byte("# nothing\n"), 0o600))
224 r = run("", noun, "set", "-f", empty, "--repo", "proj")
225 require.Error(t, r.err, noun)
226 assert.Contains(t, r.err.Error(), "holds no NAME=value")
227 run("viaStdin\n", noun, "set", "S"+strings.ToUpper(noun[:1]), "--repo", "proj").ok(t)
228 }
229}