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}