v0.27.0 0039612

cli_configfile_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"context"
7	"errors"
8	"os"
9	"path/filepath"
10	"runtime"
11	"strings"
12	"testing"
13	"time"
14
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/require"
17	"golang.org/x/term"
18
19	"bnema.dev/ermite-api/gen/ermite"
20)
21
22// writeConfigFile writes the config file of env with the given mode.
23func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string {
24	t.Helper()
25	path, err := env.configPath()
26	require.NoError(t, err)
27	require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
28	require.NoError(t, os.WriteFile(path, []byte(content), mode))
29	require.NoError(t, os.Chmod(path, mode)) // umask
30	return path
31}
32
33// tokenCmdStub makes runTokenCmd return out and err, and counts the runs.
34func tokenCmdStub(env *cliEnv, out string, err error) *[]string {
35	var ran []string
36	env.runTokenCmd = func(_ context.Context, line string) (string, error) {
37		ran = append(ran, line)
38		return out, err
39	}
40	return &ran
41}
42
43func listFake() *fakeAPI {
44	return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
45		return &ermite.ListReposResponse{}, nil
46	}}
47}
48
49func lastAuth(f *fakeAPI) string {
50	f.mu.Lock()
51	defer f.mu.Unlock()
52	if len(f.header) == 0 {
53		return "<no call>"
54	}
55	return f.header[len(f.header)-1].Get("Authorization")
56}
57
58func TestConfigFileSuppliesURLAndToken(t *testing.T) {
59	f := listFake()
60	url := f.serve(t)
61	env := testEnv(t, nil)
62	writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600)
63	ran := tokenCmdStub(env, " ermite_fromcmd\n", nil)
64
65	runCLIWith(t, env, "", "repo", "list").ok(t)
66	assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f))
67	assert.Equal(t, []string{"pass show ermite/token"}, *ran)
68}
69
70func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) {
71	f := listFake()
72	env := testEnv(t, nil)
73	writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600)
74
75	runCLIWith(t, env, "", "repo", "list").ok(t)
76	assert.Empty(t, lastAuth(f))
77}
78
79func TestEnvironmentWinsOverConfigFile(t *testing.T) {
80	fileSrv, envSrv := listFake(), listFake()
81	env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"})
82	writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600)
83
84	runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called
85	assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv))
86	calls, _ := fileSrv.snapshot()
87	assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set")
88}
89
90func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) {
91	f := listFake()
92	tests := []struct {
93		name    string
94		envURL  string // "" means the fake server
95		file    string // the content of the config file
96		wantErr bool
97	}{
98		{name: "other url in the file", file: "url = http://file.invalid\ntoken.cmd = get-token\n"},
99		{name: "file without url", file: "token.cmd = get-token\n"},
100		{name: "invalid ERMITE_URL", envURL: "nope", file: "url = http://file.invalid\ntoken.cmd = get-token\n", wantErr: true},
101	}
102	for _, tt := range tests {
103		t.Run(tt.name, func(t *testing.T) {
104			envURL := tt.envURL
105			if envURL == "" {
106				envURL = f.serve(t)
107			}
108			env := testEnv(t, map[string]string{"ERMITE_URL": envURL})
109			writeConfigFile(t, env, tt.file, 0o600)
110			ran := tokenCmdStub(env, "ermite_cmd", nil)
111
112			res := runCLIWith(t, env, "", "repo", "list")
113			assert.Empty(t, *ran, "the token of the file's server must not go to another server")
114			if tt.wantErr {
115				require.Error(t, res.err)
116				return
117			}
118			require.NoError(t, res.err)
119			assert.Empty(t, lastAuth(f))
120			assert.Equal(t, 1, strings.Count(res.stderr, "token.cmd not run"), res.stderr)
121			assert.Contains(t, res.stderr, "ERMITE_TOKEN")
122		})
123	}
124}
125
126func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) {
127	f := listFake()
128	url := f.serve(t)
129	// the same address, written differently
130	env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "})
131	writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
132	ran := tokenCmdStub(env, "ermite_cmd", nil)
133
134	res := runCLIWith(t, env, "", "repo", "list").ok(t)
135	assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
136	assert.Len(t, *ran, 1)
137	assert.NotContains(t, res.stderr, "token.cmd")
138}
139
140func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) {
141	f := listFake()
142	env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
143	writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue
144	ran := tokenCmdStub(env, "other", nil)
145
146	runCLIWith(t, env, "", "repo", "list").ok(t)
147	assert.Empty(t, *ran)
148	assert.Equal(t, "Bearer ermite_env", lastAuth(f))
149}
150
151func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) {
152	f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
153		return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
154	}}
155	url := f.serve(t)
156
157	for name, args := range map[string][]string{
158		"version":        {"version"},
159		"version --json": {"version", "--json"},
160		"completion":     {"completion", "bash"},
161	} {
162		t.Run(name, func(t *testing.T) {
163			env := testEnv(t, nil)
164			writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
165			ran := tokenCmdStub(env, "", errors.New("must not run"))
166			runCLIWith(t, env, "", args...).ok(t)
167			assert.Empty(t, *ran)
168		})
169	}
170
171	t.Run("version without any url", func(t *testing.T) {
172		env := testEnv(t, nil)
173		writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
174		ran := tokenCmdStub(env, "", errors.New("must not run"))
175		res := runCLIWith(t, env, "", "version").ok(t)
176		assert.Equal(t, "ermite v0.0.1\n", res.stdout)
177		assert.Empty(t, *ran)
178	})
179
180	t.Run("version uses the url of the file and sends no token", func(t *testing.T) {
181		env := testEnv(t, nil)
182		writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
183		tokenCmdStub(env, "ermite_cmd", nil)
184		res := runCLIWith(t, env, "", "version").ok(t)
185		assert.Contains(t, res.stdout, "v0.4.0 at "+url)
186		assert.Empty(t, lastAuth(f))
187	})
188}
189
190func TestTokenCmdRunsOncePerProcess(t *testing.T) {
191	f := listFake()
192	env := testEnv(t, nil)
193	writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
194	ran := tokenCmdStub(env, "ermite_cmd", nil)
195	root := newRootCmdWith(env)
196	for range 2 {
197		c, _, err := root.Find([]string{"repo", "list"})
198		require.NoError(t, err)
199		_, err = env.client(c)
200		require.NoError(t, err)
201	}
202	assert.Len(t, *ran, 1)
203}
204
205func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) {
206	if runtime.GOOS == "windows" {
207		t.Skip("no Unix permission bits")
208	}
209	for _, mode := range []os.FileMode{0o666, 0o662, 0o620} {
210		t.Run(mode.String(), func(t *testing.T) {
211			env := testEnv(t, nil)
212			path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode)
213			ran := tokenCmdStub(env, "ermite_cmd", nil)
214			res := runCLIWith(t, env, "", "repo", "list")
215			require.Error(t, res.err)
216			assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error())
217			assert.Empty(t, *ran)
218		})
219	}
220	t.Run("group readable is fine", func(t *testing.T) {
221		f := listFake()
222		env := testEnv(t, nil)
223		writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644)
224		tokenCmdStub(env, "ermite_cmd", nil)
225		runCLIWith(t, env, "", "repo", "list").ok(t)
226	})
227}
228
229func TestTokenCmdFailuresNeverShowOutput(t *testing.T) {
230	const secret = "s3cret-token-value"
231	tests := []struct {
232		name string
233		out  string
234		err  error
235		want string
236	}{
237		{"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"},
238		{"empty", "  \n", nil, "token.cmd printed nothing"},
239		{"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"},
240		{"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"},
241	}
242	for _, tt := range tests {
243		t.Run(tt.name, func(t *testing.T) {
244			env := testEnv(t, nil)
245			writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
246			tokenCmdStub(env, tt.out, tt.err)
247			res := runCLIWith(t, env, "", "repo", "list")
248			require.Error(t, res.err)
249			assert.Equal(t, tt.want, res.err.Error())
250			assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret)
251		})
252	}
253}
254
255func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) {
256	env := testEnv(t, nil)
257	writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
258	ran := tokenCmdStub(env, "ermite_cmd", nil)
259	res := runCLIWith(t, env, "", "repo", "list")
260	require.Error(t, res.err)
261	assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
262	assert.Contains(t, res.err.Error(), "ermite config set url")
263	assert.Empty(t, *ran)
264}
265
266func TestInvalidFileURLIsReported(t *testing.T) {
267	env := testEnv(t, nil)
268	path := writeConfigFile(t, env, "url = git.example.com\n", 0o600)
269	res := runCLIWith(t, env, "", "repo", "list")
270	require.Error(t, res.err)
271	assert.Contains(t, res.err.Error(), "url in "+filepath.Dir(path)) // the file named
272	assert.Contains(t, res.err.Error(), "ermite config set url")
273	assert.Contains(t, res.err.Error(), "http")
274}
275
276func TestConfigFileWarnings(t *testing.T) {
277	f := listFake()
278	env := testEnv(t, nil)
279	writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600)
280	res := runCLIWith(t, env, "", "repo", "list").ok(t)
281	assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr)
282	assert.Contains(t, res.stderr, "colour")
283	assert.NotContains(t, res.stderr, "blue", "values are never echoed")
284}
285
286func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) {
287	env := testEnv(t, nil)
288	writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600)
289	res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
290	assert.NotContains(t, res.stderr, "warning")
291}
292
293func TestERMITE_CONFIGSelectsTheFile(t *testing.T) {
294	f := listFake()
295	path := filepath.Join(t.TempDir(), "other", "conf")
296	require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
297	require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600))
298
299	env := testEnv(t, map[string]string{"ERMITE_CONFIG": path})
300	env.configPath = nil // the default resolution, which reads ERMITE_CONFIG
301	runCLIWith(t, env, "", "repo", "list").ok(t)
302}
303
304func TestTokenCmdRunsThroughTheShell(t *testing.T) {
305	if runtime.GOOS == "windows" {
306		t.Skip("uses sh")
307	}
308	f := listFake()
309	env := testEnv(t, nil)
310	writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600)
311	env.runTokenCmd = runTokenShell // the real thing
312
313	runCLIWith(t, env, "", "repo", "list").ok(t)
314	assert.Equal(t, "Bearer secret", lastAuth(f))
315}
316
317func TestRunShell(t *testing.T) {
318	if runtime.GOOS == "windows" {
319		t.Skip("uses sh")
320	}
321	tests := []struct {
322		name    string
323		line    string
324		want    string // the output on success
325		errHas  string // the error on failure
326		errNot  string // what the error must not show
327		wantErr bool
328	}{
329		{name: "exit status", line: "echo s3cret; exit 3", wantErr: true, errHas: "exit status 3", errNot: "s3cret"},
330		{name: "too much output", line: "head -c 5000 /dev/zero | tr '\\0' a", wantErr: true, errHas: "more than 4096 bytes", errNot: "aaa"},
331		{name: "output", line: "echo ermite_ok", want: "ermite_ok\n"},
332	}
333	for _, tt := range tests {
334		t.Run(tt.name, func(t *testing.T) {
335			out, err := runShell(t.Context(), tt.line, time.Minute)
336			if !tt.wantErr {
337				require.NoError(t, err)
338				assert.Equal(t, tt.want, out)
339				return
340			}
341			require.Error(t, err)
342			assert.Contains(t, err.Error(), tt.errHas)
343			assert.NotContains(t, err.Error(), tt.errNot)
344		})
345	}
346}
347
348// requireNoTerminal skips a test of the non-terminal path when the tests run
349// with a terminal as standard input.
350func requireNoTerminal(t *testing.T) {
351	t.Helper()
352	if runtime.GOOS == "windows" {
353		t.Skip("uses sh")
354	}
355	if term.IsTerminal(int(os.Stdin.Fd())) {
356		t.Skip("standard input is a terminal")
357	}
358}
359
360func TestTokenCmdTimesOut(t *testing.T) {
361	requireNoTerminal(t)
362	f := listFake()
363	env := testEnv(t, nil)
364	writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = sleep 30; :\n", 0o600)
365	env.runTokenCmd = func(ctx context.Context, line string) (string, error) {
366		return runShell(ctx, line, 100*time.Millisecond)
367	}
368
369	start := time.Now()
370	res := runCLIWith(t, env, "", "repo", "list")
371	require.Error(t, res.err)
372	assert.Equal(t, "token.cmd failed: timed out after 100ms", res.err.Error())
373	assert.Less(t, time.Since(start), 10*time.Second)
374}
375
376func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) {
377	requireNoTerminal(t)
378	out, err := runShell(t.Context(), "cat; echo done", time.Minute)
379	require.NoError(t, err)
380	assert.Equal(t, "done\n", out, "the command read nothing")
381}
382
383func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) {
384	if runtime.GOOS == "windows" {
385		t.Skip("no Unix permission bits")
386	}
387	env := testEnv(t, nil)
388	path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
389	dir := filepath.Dir(path)
390	require.NoError(t, os.Chmod(dir, 0o777))
391	ran := tokenCmdStub(env, "ermite_cmd", nil)
392
393	res := runCLIWith(t, env, "", "repo", "list")
394	require.Error(t, res.err)
395	assert.Equal(t, "config directory "+dir+" is writable by others: token.cmd not run", res.err.Error())
396	assert.Empty(t, *ran)
397
398	t.Run("sticky directory is fine", func(t *testing.T) {
399		require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky))
400		f := listFake()
401		env := testEnvAt(t, path)
402		require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\ntoken.cmd = get-token\n"), 0o600))
403		tokenCmdStub(env, "ermite_cmd", nil)
404		runCLIWith(t, env, "", "repo", "list").ok(t)
405	})
406}
407
408func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) {
409	if runtime.GOOS == "windows" {
410		t.Skip("symlinks")
411	}
412	f := listFake()
413	env := testEnv(t, nil)
414	real := writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
415	link := filepath.Join(t.TempDir(), "dotfile-link")
416	require.NoError(t, os.Symlink(real, link))
417	tokenCmdStub(env, "ermite_cmd", nil)
418	env.configPath = func() (string, error) { return link, nil }
419	runCLIWith(t, env, "", "repo", "list").ok(t)
420	assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
421}
422
423func TestVersionWarnsAboutAnUnreadableConfigFile(t *testing.T) {
424	env := testEnv(t, nil)
425	path := writeConfigFile(t, env, "", 0o600)
426	require.NoError(t, os.Remove(path))
427	require.NoError(t, os.Mkdir(path, 0o700))
428	res := runCLIWith(t, env, "", "version").ok(t)
429	assert.Equal(t, "ermite v0.0.1\n", res.stdout)
430	assert.Contains(t, res.stderr, "ermite: warning: config:")
431}
432
433func TestCompletionNeverRunsTokenCmd(t *testing.T) {
434	f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
435		return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
436	}}
437	env := testEnv(t, nil)
438	writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
439	ran := tokenCmdStub(env, "ermite_cmd", nil)
440	res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
441	assert.Empty(t, *ran)
442	assert.Contains(t, res.stdout, "alpha")
443	assert.Empty(t, lastAuth(f))
444
445	// with ERMITE_TOKEN set the completion is authenticated
446	env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
447	writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
448	runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
449	assert.Equal(t, "Bearer ermite_env", lastAuth(f))
450}

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