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 "strconv"
12 "strings"
13 "testing"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17)
18
19func TestConfigShowWithoutFile(t *testing.T) {
20 env := testEnv(t, nil)
21 path, _ := env.configPath()
22 res := runCLIWith(t, env, "", "config", "show").ok(t)
23 assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout)
24 _, err := os.Stat(path)
25 assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file")
26}
27
28func TestConfigSetUnsetShowRoundTrip(t *testing.T) {
29 env := testEnv(t, nil)
30 path, _ := env.configPath()
31 writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600)
32
33 runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t)
34 runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t)
35 // a fresh process sees the new content
36 res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
37 assert.Contains(t, res.stdout, "url https://git.example.com\n")
38 assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n")
39
40 data, err := os.ReadFile(path)
41 require.NoError(t, err)
42 assert.Contains(t, string(data), "# my forge\n")
43 assert.Contains(t, string(data), "# secrets stay in pass\n")
44
45 runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t)
46 runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t)
47 data, err = os.ReadFile(path)
48 require.NoError(t, err)
49 assert.NotContains(t, string(data), "token.cmd")
50 assert.NotContains(t, string(data), "url")
51 assert.Contains(t, string(data), "# my forge\n", "comments survive")
52
53 res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
54 assert.Contains(t, res.stdout, "url (not set)\n")
55 assert.Contains(t, res.stdout, "token.cmd (not set)\n")
56}
57
58// testEnvAt is testEnv with the config file at path: a new process.
59func testEnvAt(t *testing.T, path string) *cliEnv {
60 t.Helper()
61 env := testEnv(t, nil)
62 env.configPath = func() (string, error) { return path, nil }
63 return env
64}
65
66func TestConfigSetCreatesPrivateFile(t *testing.T) {
67 env := testEnv(t, nil)
68 path, _ := env.configPath()
69 res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t)
70 assert.Contains(t, res.stderr, path)
71 info, err := os.Stat(path)
72 require.NoError(t, err)
73 if runtime.GOOS != "windows" {
74 assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
75 }
76 data, _ := os.ReadFile(path)
77 assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data))
78}
79
80func TestConfigSetRefusesBadInput(t *testing.T) {
81 tests := []struct {
82 name string
83 args []string
84 want string
85 }{
86 {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`},
87 {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`},
88 {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"},
89 {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"},
90 {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"},
91 {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"},
92 {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"},
93 {"missing value", []string{"set", "url"}, "accepts 2 arg"},
94 }
95 for _, tt := range tests {
96 t.Run(tt.name, func(t *testing.T) {
97 env := testEnv(t, nil)
98 path, _ := env.configPath()
99 res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...)
100 require.Error(t, res.err)
101 assert.Contains(t, res.err.Error(), tt.want)
102 assert.NotContains(t, res.err.Error(), "s3cret")
103 _, err := os.Stat(path)
104 assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written")
105 })
106 }
107}
108
109func TestConfigUnsetWithoutFile(t *testing.T) {
110 env := testEnv(t, nil)
111 path, _ := env.configPath()
112 res := runCLIWith(t, env, "", "config", "unset", "url").ok(t)
113 assert.Contains(t, res.stderr, "does not exist")
114 _, err := os.Stat(path)
115 assert.ErrorIs(t, err, os.ErrNotExist)
116}
117
118func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
119 env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"})
120 writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666)
121 res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs
122 assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored")
123 assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence")
124 assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used")
125 assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
126}
127
128func TestHelpDocumentsTheConfigFileContract(t *testing.T) {
129 root := runCLI(t, nil, "", "--help").ok(t).stdout
130 cfg := runCLI(t, nil, "", "config", "--help").ok(t).stdout
131 for name, help := range map[string]string{"root": root, "config": cfg} {
132 assert.Contains(t, help, "ermite/config", "%s help names the file", name)
133 assert.Contains(t, help, "ERMITE_CONFIG", "%s help names the override", name)
134 assert.Contains(t, help, "ERMITE_URL", "%s help states the precedence", name)
135 assert.Contains(t, help, "ERMITE_TOKEN", "%s help states the precedence", name)
136 }
137 assert.Contains(t, cfg, "token.cmd = ", "the config help has an example")
138 assert.Contains(t, cfg, "Windows", "the config help states the Windows limit")
139}
140
141func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
142 env := testEnv(t, nil)
143 path := writeConfigFile(t, env, "url = https://git.example.com\n", 0o600)
144 require.NoError(t, os.Remove(path))
145 require.NoError(t, os.Mkdir(path, 0o700)) // a directory is not a config file
146 res := runCLIWith(t, env, "", "config", "show")
147 require.Error(t, res.err)
148 assert.Contains(t, res.stdout, path)
149 assert.Contains(t, res.err.Error(), "config:")
150}
151
152func TestConfigUnsetOfAnAbsentKey(t *testing.T) {
153 env := testEnv(t, nil)
154 path := writeConfigFile(t, env, "# only a comment\nurl = https://git.example.com\n", 0o600)
155 res := runCLIWith(t, env, "", "config", "unset", "token.cmd").ok(t)
156 assert.Equal(t, "ermite: token.cmd is not set\n", res.stderr)
157 data, err := os.ReadFile(path)
158 require.NoError(t, err)
159 assert.Equal(t, "# only a comment\nurl = https://git.example.com\n", string(data), "the file is untouched")
160}
161
162func TestConfigShowJSON(t *testing.T) {
163 env := testEnv(t, nil)
164 path, _ := env.configPath()
165 res := runCLIWith(t, env, "", "config", "show", "--json").ok(t)
166 assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":null,"token_cmd":null}`, res.stdout)
167
168 env = testEnv(t, nil)
169 path = writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = pass show ermite/token\n", 0o600)
170 res = runCLIWith(t, env, "", "config", "show", "--json").ok(t) // testEnv fails the test if token.cmd runs
171 assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":"https://git.example.com","token_cmd":"pass show ermite/token"}`, res.stdout)
172}
173
174func TestConfigShowAndSetWarnWhenTokenCmdWouldBeRefused(t *testing.T) {
175 if runtime.GOOS == "windows" {
176 t.Skip("no Unix permission bits")
177 }
178 env := testEnv(t, nil)
179 path := writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = get-token\n", 0o666)
180
181 res := runCLIWith(t, env, "", "config", "show").ok(t)
182 assert.Contains(t, res.stderr, "ermite: warning: config file "+path+" is writable by others: token.cmd not run")
183
184 // a fresh process; set keeps the mode of an existing file
185 res = runCLIWith(t, testEnvAt(t, path), "", "config", "set", "url", "https://git2.example.com").ok(t)
186 assert.Contains(t, res.stderr, "writable by others")
187
188 // no warning without a token.cmd, or with a private file
189 env = testEnv(t, nil)
190 writeConfigFile(t, env, "url = https://git.example.com\n", 0o666)
191 assert.NotContains(t, runCLIWith(t, env, "", "config", "show").ok(t).stderr, "warning")
192 res = runCLIWith(t, testEnv(t, nil), "", "config", "set", "token.cmd", "get-token").ok(t)
193 assert.NotContains(t, res.stderr, "warning")
194}
195
196func TestDefaultConfigPathUsesXDGConfigHome(t *testing.T) {
197 xdg := t.TempDir()
198 t.Setenv("XDG_CONFIG_HOME", xdg)
199 t.Setenv("ERMITE_CONFIG", "")
200 f := listFake()
201
202 env := defaultCLIEnv() // the real path resolution, the real environment
203 res := runCLIWith(t, env, "", "config", "set", "url", f.serve(t)).ok(t)
204 want := filepath.Join(xdg, "ermite", "config")
205 assert.Contains(t, res.stderr, want)
206 info, err := os.Stat(want)
207 require.NoError(t, err)
208 if runtime.GOOS != "windows" {
209 assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
210 }
211
212 env = defaultCLIEnv()
213 env.runTokenCmd = func(context.Context, string) (string, error) { return "", errors.New("must not run") }
214 t.Setenv("ERMITE_URL", "")
215 t.Setenv("ERMITE_TOKEN", "")
216 runCLIWith(t, env, "", "repo", "list").ok(t)
217 assert.Equal(t, 1, callCount(f, "ListRepos"))
218
219 // ERMITE_CONFIG wins over the default
220 other := filepath.Join(t.TempDir(), "other")
221 t.Setenv("ERMITE_CONFIG", other)
222 res = runCLIWith(t, defaultCLIEnv(), "", "config", "show").ok(t)
223 assert.Contains(t, res.stdout, other)
224}
225
226func callCount(f *fakeAPI, name string) int {
227 calls, _ := f.snapshot()
228 n := 0
229 for _, c := range calls {
230 if c == name {
231 n++
232 }
233 }
234 return n
235}