v2.31.0-dev22 8d342c4

file_test.go RawHistoryFolder

1package envloader
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"os"
8	"path/filepath"
9	"testing"
10
11	"github.com/bnema/zerowrap"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14
15	"github.com/bnema/gordon/internal/boundaries/out/mocks"
16	"github.com/bnema/gordon/internal/domain"
17)
18
19func TestFileLoader_ResolveSecrets(t *testing.T) {
20	// Create a temp directory for tests
21	tmpDir := t.TempDir()
22	log := zerowrap.New(zerowrap.Config{Level: "fatal"})
23
24	loader, err := NewFileLoader(tmpDir, log)
25	require.NoError(t, err)
26
27	ctx := context.Background()
28
29	t.Run("no secret syntax returns value unchanged", func(t *testing.T) {
30		result, err := loader.resolveSecrets(ctx, "plain-value")
31		assert.NoError(t, err)
32		assert.Equal(t, "plain-value", result)
33	})
34
35	t.Run("unclosed secret syntax returns error", func(t *testing.T) {
36		_, err := loader.resolveSecrets(ctx, "${pass:secret")
37		assert.Error(t, err)
38		assert.Contains(t, err.Error(), "unclosed secret syntax")
39	})
40
41	t.Run("invalid secret syntax without colon returns error", func(t *testing.T) {
42		_, err := loader.resolveSecrets(ctx, "${invalid}")
43		assert.Error(t, err)
44		assert.Contains(t, err.Error(), "invalid secret syntax")
45	})
46
47	t.Run("unknown provider returns error", func(t *testing.T) {
48		_, err := loader.resolveSecrets(ctx, "${unknown:path}")
49		assert.Error(t, err)
50		assert.Contains(t, err.Error(), "unknown secret provider")
51	})
52
53	t.Run("pass provider resolves secret", func(t *testing.T) {
54		mockPass := mocks.NewMockSecretProvider(t)
55		mockPass.EXPECT().Name().Return("pass")
56		mockPass.EXPECT().GetSecret(ctx, "company/api-key").Return("secret-value-123", nil)
57
58		loader.RegisterSecretProvider(mockPass)
59
60		result, err := loader.resolveSecrets(ctx, "${pass:company/api-key}")
61		assert.NoError(t, err)
62		assert.Equal(t, "secret-value-123", result)
63	})
64
65	t.Run("sops provider resolves secret", func(t *testing.T) {
66		mockSops := mocks.NewMockSecretProvider(t)
67		mockSops.EXPECT().Name().Return("sops")
68		mockSops.EXPECT().GetSecret(ctx, "secrets.yaml:app.password").Return("sops-secret", nil)
69
70		loader.RegisterSecretProvider(mockSops)
71
72		result, err := loader.resolveSecrets(ctx, "${sops:secrets.yaml:app.password}")
73		assert.NoError(t, err)
74		assert.Equal(t, "sops-secret", result)
75	})
76
77	t.Run("multiple secrets in one value", func(t *testing.T) {
78		mockPass := mocks.NewMockSecretProvider(t)
79		mockPass.EXPECT().Name().Return("pass")
80		mockPass.EXPECT().GetSecret(ctx, "db/user").Return("admin", nil)
81		mockPass.EXPECT().GetSecret(ctx, "db/pass").Return("secret123", nil)
82
83		loader.RegisterSecretProvider(mockPass)
84
85		result, err := loader.resolveSecrets(ctx, "postgresql://${pass:db/user}:${pass:db/pass}@localhost:5432/db")
86		assert.NoError(t, err)
87		assert.Equal(t, "postgresql://admin:secret123@localhost:5432/db", result)
88	})
89
90	t.Run("provider error is propagated", func(t *testing.T) {
91		mockPass := mocks.NewMockSecretProvider(t)
92		mockPass.EXPECT().Name().Return("pass")
93		mockPass.EXPECT().GetSecret(ctx, "missing/secret").Return("", assert.AnError)
94
95		loader.RegisterSecretProvider(mockPass)
96
97		_, err := loader.resolveSecrets(ctx, "${pass:missing/secret}")
98		assert.Error(t, err)
99		assert.Contains(t, err.Error(), "failed to get secret from provider")
100	})
101}
102
103func TestFileLoader_LoadEnv(t *testing.T) {
104	log := zerowrap.New(zerowrap.Config{Level: "fatal"})
105	ctx := context.Background()
106
107	t.Run("missing env file returns empty slice", func(t *testing.T) {
108		tmpDir := t.TempDir()
109		loader, err := NewFileLoader(tmpDir, log)
110		require.NoError(t, err)
111
112		envVars, err := loader.LoadEnv(ctx, "nonexistent.example.com")
113		assert.NoError(t, err)
114		assert.Empty(t, envVars)
115	})
116
117	t.Run("loads simple env file", func(t *testing.T) {
118		tmpDir := t.TempDir()
119		loader, err := NewFileLoader(tmpDir, log)
120		require.NoError(t, err)
121
122		// Create env file
123		envContent := `# Comment line
124NODE_ENV=production
125API_URL=https://api.example.com
126`
127		storageKey, err := domain.NewEnvStorageKey("app.example.com")
128		require.NoError(t, err)
129		envFile := filepath.Join(tmpDir, storageKey.FileName())
130		require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
131
132		envVars, err := loader.LoadEnv(ctx, "app.example.com")
133		assert.NoError(t, err)
134		assert.Len(t, envVars, 2)
135		assert.Contains(t, envVars, "NODE_ENV=production")
136		assert.Contains(t, envVars, "API_URL=https://api.example.com")
137	})
138
139	t.Run("loads env file with secrets", func(t *testing.T) {
140		tmpDir := t.TempDir()
141		loader, err := NewFileLoader(tmpDir, log)
142		require.NoError(t, err)
143
144		// Register mock provider
145		mockPass := mocks.NewMockSecretProvider(t)
146		mockPass.EXPECT().Name().Return("pass")
147		mockPass.EXPECT().GetSecret(ctx, "app/api-key").Return("super-secret-key", nil)
148		loader.RegisterSecretProvider(mockPass)
149
150		// Create env file with secret reference
151		envContent := `NODE_ENV=production
152API_KEY=${pass:app/api-key}
153`
154		storageKey, err := domain.NewEnvStorageKey("app.example.com")
155		require.NoError(t, err)
156		envFile := filepath.Join(tmpDir, storageKey.FileName())
157		require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
158
159		envVars, err := loader.LoadEnv(ctx, "app.example.com")
160		assert.NoError(t, err)
161		assert.Len(t, envVars, 2)
162		assert.Contains(t, envVars, "NODE_ENV=production")
163		assert.Contains(t, envVars, "API_KEY=super-secret-key")
164	})
165
166	t.Run("handles quoted values", func(t *testing.T) {
167		tmpDir := t.TempDir()
168		loader, err := NewFileLoader(tmpDir, log)
169		require.NoError(t, err)
170
171		envContent := `DOUBLE_QUOTED="hello world"
172SINGLE_QUOTED='hello world'
173`
174		storageKey, err := domain.NewEnvStorageKey("app.example.com")
175		require.NoError(t, err)
176		envFile := filepath.Join(tmpDir, storageKey.FileName())
177		require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
178
179		envVars, err := loader.LoadEnv(ctx, "app.example.com")
180		assert.NoError(t, err)
181		assert.Len(t, envVars, 2)
182		assert.Contains(t, envVars, "DOUBLE_QUOTED=hello world")
183		assert.Contains(t, envVars, "SINGLE_QUOTED=hello world")
184	})
185
186	t.Run("skips invalid lines", func(t *testing.T) {
187		tmpDir := t.TempDir()
188		loader, err := NewFileLoader(tmpDir, log)
189		require.NoError(t, err)
190
191		envContent := `VALID=value
192invalid line without equals
193ALSO_VALID=another
194`
195		storageKey, err := domain.NewEnvStorageKey("app.example.com")
196		require.NoError(t, err)
197		envFile := filepath.Join(tmpDir, storageKey.FileName())
198		require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600))
199
200		envVars, err := loader.LoadEnv(ctx, "app.example.com")
201		assert.NoError(t, err)
202		assert.Len(t, envVars, 2)
203		assert.Contains(t, envVars, "VALID=value")
204		assert.Contains(t, envVars, "ALSO_VALID=another")
205	})
206}
207
208func TestFileLoader_LoadEnv_RedactsSecretBearingDiagnostics(t *testing.T) {
209	ctx := context.Background()
210
211	t.Run("invalid line warning omits raw line content", func(t *testing.T) {
212		var logs bytes.Buffer
213		log := zerowrap.New(zerowrap.Config{Level: "warn", Format: "json", Output: &logs})
214		loader, err := NewFileLoader(t.TempDir(), log)
215		require.NoError(t, err)
216		ctx := zerowrap.WithCtx(ctx, log)
217
218		envFile, err := loader.getEnvFilePath("app.example.com")
219		require.NoError(t, err)
220		require.NoError(t, os.WriteFile(envFile, []byte("valid line leaks super-secret-token\nVALID=value\n"), 0600))
221
222		envVars, err := loader.LoadEnv(ctx, "app.example.com")
223		require.NoError(t, err)
224		assert.Equal(t, []string{"VALID=value"}, envVars)
225		assert.NotContains(t, logs.String(), "super-secret-token")
226		assert.NotContains(t, logs.String(), "valid line leaks")
227		assert.Contains(t, logs.String(), "line")
228	})
229
230	t.Run("secret resolution error omits unclosed raw value", func(t *testing.T) {
231		log := zerowrap.New(zerowrap.Config{Level: "fatal"})
232		loader, err := NewFileLoader(t.TempDir(), log)
233		require.NoError(t, err)
234
235		envFile, err := loader.getEnvFilePath("app.example.com")
236		require.NoError(t, err)
237		require.NoError(t, os.WriteFile(envFile, []byte("DATABASE_URL=postgres://user:${pass:db-password-super-secret\n"), 0600))
238
239		_, err = loader.LoadEnv(ctx, "app.example.com")
240		require.Error(t, err)
241		assert.Contains(t, err.Error(), "failed to resolve secret")
242		assert.Contains(t, err.Error(), "DATABASE_URL")
243		assert.NotContains(t, err.Error(), "postgres://user")
244		assert.NotContains(t, err.Error(), "db-password-super-secret")
245	})
246
247	t.Run("secret resolution error omits malformed closed secret ref", func(t *testing.T) {
248		log := zerowrap.New(zerowrap.Config{Level: "fatal"})
249		loader, err := NewFileLoader(t.TempDir(), log)
250		require.NoError(t, err)
251
252		envFile, err := loader.getEnvFilePath("app.example.com")
253		require.NoError(t, err)
254		require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${db-password-super-secret}\n"), 0600))
255
256		_, err = loader.LoadEnv(ctx, "app.example.com")
257		require.Error(t, err)
258		assert.Contains(t, err.Error(), "failed to resolve secret")
259		assert.Contains(t, err.Error(), "TOKEN")
260		assert.NotContains(t, err.Error(), "db-password-super-secret")
261	})
262
263	t.Run("secret resolution error omits provider error details", func(t *testing.T) {
264		log := zerowrap.New(zerowrap.Config{Level: "fatal"})
265		loader, err := NewFileLoader(t.TempDir(), log)
266		require.NoError(t, err)
267
268		mockPass := mocks.NewMockSecretProvider(t)
269		mockPass.EXPECT().Name().Return("pass")
270		mockPass.EXPECT().GetSecret(ctx, "db-password-super-secret").Return("", errors.New("backend leaked db-password-super-secret"))
271		loader.RegisterSecretProvider(mockPass)
272
273		envFile, err := loader.getEnvFilePath("app.example.com")
274		require.NoError(t, err)
275		require.NoError(t, os.WriteFile(envFile, []byte("TOKEN=${pass:db-password-super-secret}\n"), 0600))
276
277		_, err = loader.LoadEnv(ctx, "app.example.com")
278		require.Error(t, err)
279		assert.Contains(t, err.Error(), "failed to resolve secret")
280		assert.Contains(t, err.Error(), "TOKEN")
281		assert.Contains(t, err.Error(), "provider pass")
282		assert.NotContains(t, err.Error(), "db-password-super-secret")
283	})
284}
285
286func TestFileLoader_GetEnvFilePath(t *testing.T) {
287	tmpDir := t.TempDir()
288	log := zerowrap.New(zerowrap.Config{Level: "fatal"})
289
290	loader, err := NewFileLoader(tmpDir, log)
291	require.NoError(t, err)
292
293	tests := []struct {
294		domain   string
295		expected string
296	}{
297		{"app.example.com", "YXBwLmV4YW1wbGUuY29t.env"},
298		{"api.sub.example.com", "YXBpLnN1Yi5leGFtcGxlLmNvbQ.env"},
299		{"localhost:8080", "bG9jYWxob3N0OjgwODA.env"},
300	}
301
302	for _, tt := range tests {
303		t.Run(tt.domain, func(t *testing.T) {
304			result, err := loader.getEnvFilePath(tt.domain)
305			require.NoError(t, err)
306			assert.Equal(t, filepath.Join(tmpDir, tt.expected), result)
307		})
308	}
309}
310
311func TestFileLoader_GetEnvFilePath_DistinguishesSeparators(t *testing.T) {
312	tmpDir := t.TempDir()
313	log := zerowrap.New(zerowrap.Config{Level: "fatal"})
314
315	loader, err := NewFileLoader(tmpDir, log)
316	require.NoError(t, err)
317
318	dotPath, err := loader.getEnvFilePath("app.example.com")
319	require.NoError(t, err)
320	portPath, err := loader.getEnvFilePath("app:example:com")
321	require.NoError(t, err)
322	slashPath, err := loader.getEnvFilePath("app/example/com")
323	require.NoError(t, err)
324
325	assert.NotEqual(t, dotPath, portPath)
326	assert.NotEqual(t, dotPath, slashPath)
327	assert.NotEqual(t, portPath, slashPath)
328}
329
330func TestFileLoader_GetEnvFilePath_RejectsInvalidDomain(t *testing.T) {
331	tmpDir := t.TempDir()
332	log := zerowrap.New(zerowrap.Config{Level: "fatal"})
333
334	loader, err := NewFileLoader(tmpDir, log)
335	require.NoError(t, err)
336
337	_, err = loader.getEnvFilePath("../etc/passwd")
338	require.ErrorIs(t, err, domain.ErrPathTraversal)
339}
340
341func TestFileLoader_EnvFileExists(t *testing.T) {
342	log := zerowrap.New(zerowrap.Config{Level: "fatal"})
343
344	t.Run("returns false nil when file is missing", func(t *testing.T) {
345		tmpDir := t.TempDir()
346		loader, err := NewFileLoader(tmpDir, log)
347		require.NoError(t, err)
348
349		exists, err := loader.EnvFileExists("app.example.com")
350		require.NoError(t, err)
351		assert.False(t, exists)
352	})
353
354	t.Run("returns true nil when file exists", func(t *testing.T) {
355		tmpDir := t.TempDir()
356		loader, err := NewFileLoader(tmpDir, log)
357		require.NoError(t, err)
358
359		storageKey, err := domain.NewEnvStorageKey("app.example.com")
360		require.NoError(t, err)
361		envFile := filepath.Join(tmpDir, storageKey.FileName())
362		require.NoError(t, os.WriteFile(envFile, []byte("KEY=value\n"), 0600))
363
364		exists, err := loader.EnvFileExists("app.example.com")
365		require.NoError(t, err)
366		assert.True(t, exists)
367	})
368}

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 7

Tags 106

↑↓ moveenter openesc close