1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
|
package config
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// writeUserConfig creates the ~/.config/foostore.json file inside the given
// HOME directory (which must already exist). Used to exercise Load() directly.
func writeUserConfig(t *testing.T, home, content string) {
t.Helper()
cfgDir := filepath.Join(home, ".config")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
path := filepath.Join(cfgDir, "foostore.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
}
// captureStderr redirects os.Stderr to a pipe, calls fn, then returns whatever
// was written to the pipe and restores the original os.Stderr.
func captureStderr(fn func()) string {
orig := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
fn()
w.Close()
os.Stderr = orig
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
return buf.String()
}
// ---- expandTilde -----------------------------------------------------------
// TestExpandTilde verifies the three expansion cases: tilde prefix, no tilde, empty.
func TestExpandTilde(t *testing.T) {
home, _ := os.UserHomeDir()
cases := []struct {
name string
input string
want string
}{
{"tilde only", "~", home},
{"tilde with subpath", "~/foo/bar", home + "/foo/bar"},
{"absolute path unchanged", "/etc/passwd", "/etc/passwd"},
{"relative path unchanged", "relative/path", "relative/path"},
{"empty string unchanged", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := expandTilde(tc.input)
if got != tc.want {
t.Errorf("expandTilde(%q) = %q; want %q", tc.input, got, tc.want)
}
})
}
}
// ---- Load() ----------------------------------------------------------------
// TestLoad_defaults verifies all 10 default values when no config file exists.
// HOME is redirected to a temp dir so Load() looks for a file that will not exist.
// EDITOR is unset so EditCmd falls back to "vi".
func TestLoad_defaults(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("EDITOR", "") // ensure fallback to "vi"
cfg := Load()
cases := []struct{ name, got, want string }{
{"DataDir", cfg.DataDir, filepath.Join(dir, "git", "foostore-data")},
{"ExportDir", cfg.ExportDir, filepath.Join(dir, ".foostore-export")},
{"KeyFile", cfg.KeyFile, filepath.Join(dir, ".foostore.key")},
{"EncAlg", cfg.EncAlg, "AES-256-CBC"},
{"AddToIV", cfg.AddToIV, "Hello world"},
{"EditCmd", cfg.EditCmd, "vi"},
{"GnomeClipboardCmd", cfg.GnomeClipboardCmd, "gpaste-client"},
{"MacOSClipboardCmd", cfg.MacOSClipboardCmd, "pbcopy"},
}
for _, tc := range cases {
if tc.got != tc.want {
t.Errorf("%s = %q; want %q", tc.name, tc.got, tc.want)
}
}
if cfg.KeyLength != 32 {
t.Errorf("KeyLength = %d; want 32", cfg.KeyLength)
}
if len(cfg.SyncRepos) != 2 || cfg.SyncRepos[0] != "git1" || cfg.SyncRepos[1] != "git2" {
t.Errorf("SyncRepos = %v; want [git1 git2]", cfg.SyncRepos)
}
}
// TestLoad_editorEnvVar verifies that when $EDITOR is set, defaultConfig uses it
// as the EditCmd, and that a JSON config value overrides $EDITOR.
func TestLoad_editorEnvVar(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
// $EDITOR set, no config file — EditCmd must equal $EDITOR.
t.Setenv("EDITOR", "nano")
cfg := Load()
if cfg.EditCmd != "nano" {
t.Errorf("EditCmd = %q; want nano (from $EDITOR)", cfg.EditCmd)
}
// JSON config overrides $EDITOR.
writeUserConfig(t, dir, `{"edit_cmd":"vim"}`)
cfg = Load()
if cfg.EditCmd != "vim" {
t.Errorf("EditCmd = %q; want vim (from config file)", cfg.EditCmd)
}
}
// TestLoad_override calls Load() directly (via a redirected HOME) and verifies
// that JSON-supplied fields override defaults while absent fields keep defaults.
func TestLoad_override(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
writeUserConfig(t, dir, `{"edit_cmd":"nvim","key_length":64,"sync_repos":["github","gitlab"]}`)
cfg := Load()
// Overridden fields must change.
if cfg.EditCmd != "nvim" {
t.Errorf("EditCmd = %q; want nvim", cfg.EditCmd)
}
if cfg.KeyLength != 64 {
t.Errorf("KeyLength = %d; want 64", cfg.KeyLength)
}
if len(cfg.SyncRepos) != 2 || cfg.SyncRepos[0] != "github" || cfg.SyncRepos[1] != "gitlab" {
t.Errorf("SyncRepos = %v; want [github gitlab]", cfg.SyncRepos)
}
// Non-overridden fields must remain at their defaults (with the temp HOME).
if cfg.EncAlg != "AES-256-CBC" {
t.Errorf("EncAlg = %q; want AES-256-CBC", cfg.EncAlg)
}
if cfg.DataDir != filepath.Join(dir, "git", "foostore-data") {
t.Errorf("DataDir = %q; want default", cfg.DataDir)
}
}
// TestLoad_pathOverride calls Load() directly and verifies that a tilde path
// supplied via JSON is expanded to an absolute path after loading.
func TestLoad_pathOverride(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
writeUserConfig(t, dir, `{"data_dir":"~/custom/vault"}`)
cfg := Load()
want := filepath.Join(dir, "custom", "vault")
if cfg.DataDir != want {
t.Errorf("DataDir = %q; want %q", cfg.DataDir, want)
}
}
// TestLoad_invalid_json verifies that invalid JSON causes Load() to emit a
// warning to stderr and return defaults (including EditCmd = "vi" when EDITOR
// is unset).
func TestLoad_invalid_json(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("EDITOR", "") // ensure fallback to "vi"
writeUserConfig(t, dir, `{invalid json}`)
var cfg Config
stderr := captureStderr(func() { cfg = Load() })
if !strings.Contains(stderr, "Unable to read") {
t.Errorf("expected warning on stderr, got: %q", stderr)
}
// Defaults must be returned with the redirected HOME.
if cfg.EditCmd != "vi" {
t.Errorf("EditCmd = %q; want vi (default)", cfg.EditCmd)
}
if cfg.KeyLength != 32 {
t.Errorf("KeyLength = %d; want 32 (default)", cfg.KeyLength)
}
}
// TestLoad_missing_file_no_warning verifies that a missing config file does NOT
// produce any output — absence is normal for a first-run or unconfigured install.
func TestLoad_missing_file_no_warning(t *testing.T) {
dir := t.TempDir() // no foostore.json inside
t.Setenv("HOME", dir)
stderr := captureStderr(func() { _ = Load() })
if stderr != "" {
t.Errorf("expected no stderr output for missing file, got: %q", stderr)
}
}
// TestLoad_unreadable_file verifies that a config file that exists but cannot
// be read emits a warning and returns defaults (the !os.IsNotExist branch).
// EDITOR is unset so EditCmd falls back to "vi".
func TestLoad_unreadable_file(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("running as root: permission checks do not apply")
}
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("EDITOR", "") // ensure fallback to "vi"
writeUserConfig(t, dir, `{"edit_cmd":"nvim"}`)
// Make the file unreadable.
cfgPath := filepath.Join(dir, ".config", "foostore.json")
if err := os.Chmod(cfgPath, 0o000); err != nil {
t.Fatalf("Chmod: %v", err)
}
t.Cleanup(func() { _ = os.Chmod(cfgPath, 0o600) })
var cfg Config
stderr := captureStderr(func() { cfg = Load() })
if !strings.Contains(stderr, "Unable to read") {
t.Errorf("expected warning on stderr, got: %q", stderr)
}
// Must return pure defaults, not the file content.
if cfg.EditCmd != "vi" {
t.Errorf("EditCmd = %q; want vi (default)", cfg.EditCmd)
}
}
|