v2.31.0-dev8 ec8c5b9

file_test.go RawHistoryFolder

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

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