v0.14.0 815fb98

cli_settings_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close