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}