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}