1package domainsecrets
2
3import (
4 "errors"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/gordon/internal/domain"
13)
14
15func TestFileStore_PathTraversal(t *testing.T) {
16 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
17 require.NoError(t, err)
18 defer os.RemoveAll(tmpDir)
19
20 store, err := NewFileStore(tmpDir, testLogger())
21 require.NoError(t, err)
22
23 tests := []struct {
24 name string
25 domain string
26 wantErr bool
27 }{
28 // Valid domains
29 {"simple domain", "example.com", false},
30 {"subdomain", "sub.example.com", false},
31 {"domain with port", "example.com:8080", false},
32 {"domain with path", "example.com/path", false},
33 {"hyphenated domain", "my-app.example.com", false},
34 {"single char", "a", false},
35
36 // Path traversal attempts - should fail
37 {"path traversal with dots", "../../../etc/passwd", true},
38 {"path traversal in middle", "example.com/../../../etc/passwd", true},
39 {"double dots only", "..", true},
40 {"path traversal at end", "example.com/..", true},
41
42 // Invalid format - should fail
43 {"empty domain", "", true},
44 {"starts with dot", ".example.com", true},
45 {"ends with dot", "example.com.", true},
46 {"special chars semicolon", "example;rm -rf /", true},
47 {"null byte", "example\x00.com", true},
48 {"newline", "example\n.com", true},
49 }
50
51 for _, tt := range tests {
52 t.Run(tt.name, func(t *testing.T) {
53 // Test ListKeys
54 _, err := store.ListKeys(tt.domain)
55 if tt.wantErr {
56 require.Error(t, err)
57 assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
58 } else {
59 // No error, or file not found (which is fine)
60 if err != nil {
61 assert.False(t, errors.Is(err, domain.ErrPathTraversal), "unexpected ErrPathTraversal")
62 }
63 }
64
65 // Test Set
66 err = store.Set(tt.domain, map[string]string{"KEY": "value"})
67 if tt.wantErr {
68 require.Error(t, err)
69 assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
70 } else {
71 assert.NoError(t, err)
72 }
73
74 // Test GetAll
75 _, err = store.GetAll(tt.domain)
76 if tt.wantErr {
77 require.Error(t, err)
78 assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
79 }
80
81 // Test Delete
82 err = store.Delete(tt.domain, "KEY")
83 if tt.wantErr {
84 require.Error(t, err)
85 assert.True(t, errors.Is(err, domain.ErrPathTraversal), "expected ErrPathTraversal, got: %v", err)
86 }
87 })
88 }
89}
90
91func TestFileStore_PathContainment(t *testing.T) {
92 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
93 require.NoError(t, err)
94 defer os.RemoveAll(tmpDir)
95
96 store, err := NewFileStore(tmpDir, testLogger())
97 require.NoError(t, err)
98
99 // Valid domain - file should be created inside tmpDir
100 err = store.Set("example.com", map[string]string{"KEY": "value"})
101 require.NoError(t, err)
102
103 // Verify file exists inside tmpDir
104 expectedFile := filepath.Join(tmpDir, "ZXhhbXBsZS5jb20.env")
105 _, err = os.Stat(expectedFile)
106 assert.NoError(t, err, "expected env file to exist at %s", expectedFile)
107
108 // Verify no file was created outside tmpDir
109 entries, err := filepath.Glob(tmpDir + "/../*")
110 require.NoError(t, err)
111 for _, entry := range entries {
112 if filepath.Base(entry) != filepath.Base(tmpDir) {
113 // Check it's not our test file escaped
114 assert.NotContains(t, entry, ".env", "unexpected .env file outside tmpDir: %s", entry)
115 }
116 }
117}
118
119func TestFileStore_DistinctDomainsUseDistinctFiles(t *testing.T) {
120 store, err := NewFileStore(t.TempDir(), testLogger())
121 require.NoError(t, err)
122
123 dotPath, err := store.validateEnvFilePath("app.example.com")
124 require.NoError(t, err)
125
126 portPath, err := store.validateEnvFilePath("app:example:com")
127 require.NoError(t, err)
128
129 pathPath, err := store.validateEnvFilePath("app/example/com")
130 require.NoError(t, err)
131
132 assert.NotEqual(t, dotPath, portPath)
133 assert.NotEqual(t, dotPath, pathPath)
134 assert.NotEqual(t, portPath, pathPath)
135}
136
137func TestSanitizeDomainForContainer(t *testing.T) {
138 tests := []struct {
139 domain string
140 expected string
141 description string
142 }{
143 {
144 domain: "git.example.com",
145 expected: "git__example__com",
146 description: "Dots become double underscores",
147 },
148 {
149 domain: "git-example.com",
150 expected: "git-example__com",
151 description: "Hyphens preserved, dots become underscores",
152 },
153 {
154 domain: "app:8080.example.com",
155 expected: "app-_8080__example__com",
156 description: "Colons become hyphen-underscore",
157 },
158 {
159 domain: "git.example.com:3000",
160 expected: "git__example__com-_3000",
161 description: "Multiple separators handled distinctly",
162 },
163 {
164 domain: "simple.com",
165 expected: "simple__com",
166 description: "Simple domain",
167 },
168 }
169
170 for _, tt := range tests {
171 t.Run(tt.domain, func(t *testing.T) {
172 result := domain.SanitizeDomainForContainer(tt.domain)
173 assert.Equal(t, tt.expected, result, "sanitization should match expected")
174 })
175 }
176
177 // Verify no collisions between potentially conflicting domains
178 t.Run("NoCollisions", func(t *testing.T) {
179 domains := []string{
180 "git.example.com",
181 "git-example.com",
182 "app:8080.example.com",
183 "app-8080-example.com",
184 }
185
186 results := make(map[string]string)
187 for _, d := range domains {
188 result := domain.SanitizeDomainForContainer(d)
189 if original, exists := results[result]; exists {
190 t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
191 }
192 results[result] = d
193 }
194 })
195}
196
197func TestFileStore_AttachmentOperations(t *testing.T) {
198 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
199 require.NoError(t, err)
200 defer os.RemoveAll(tmpDir)
201
202 store, err := NewFileStore(tmpDir, testLogger())
203 require.NoError(t, err)
204
205 containerName := "gitea-postgres"
206
207 t.Run("SetAttachment creates new file", func(t *testing.T) {
208 err := store.SetAttachment(containerName, map[string]string{
209 "POSTGRES_DB": "gitea",
210 "POSTGRES_PASSWORD": "secret123",
211 })
212 require.NoError(t, err)
213
214 // Verify file exists
215 expectedFile := filepath.Join(tmpDir, "gordon-"+containerName+".env")
216 _, err = os.Stat(expectedFile)
217 assert.NoError(t, err, "expected attachment env file to exist at %s", expectedFile)
218 })
219
220 t.Run("GetAllAttachment retrieves secrets", func(t *testing.T) {
221 secrets, err := store.GetAllAttachment(containerName)
222 require.NoError(t, err)
223 assert.Equal(t, "gitea", secrets["POSTGRES_DB"])
224 assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"])
225 })
226
227 t.Run("SetAttachment merges with existing", func(t *testing.T) {
228 err := store.SetAttachment(containerName, map[string]string{
229 "NEW_VAR": "new_value",
230 })
231 require.NoError(t, err)
232
233 // Should have both old and new secrets
234 secrets, err := store.GetAllAttachment(containerName)
235 require.NoError(t, err)
236 assert.Equal(t, "gitea", secrets["POSTGRES_DB"])
237 assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"])
238 assert.Equal(t, "new_value", secrets["NEW_VAR"])
239 })
240
241 t.Run("GetAllAttachment returns empty map for non-existent container", func(t *testing.T) {
242 secrets, err := store.GetAllAttachment("non-existent")
243 require.NoError(t, err)
244 assert.Empty(t, secrets)
245 })
246
247 t.Run("SetAttachment with hyphenated container name", func(t *testing.T) {
248 hyphenatedContainer := "gordon-git-example-com-gitea-postgres"
249 err := store.SetAttachment(hyphenatedContainer, map[string]string{
250 "KEY": "value",
251 })
252 require.NoError(t, err)
253
254 secrets, err := store.GetAllAttachment(hyphenatedContainer)
255 require.NoError(t, err)
256 assert.Equal(t, "value", secrets["KEY"])
257 })
258}
259
260func TestFileStore_AttachmentPathTraversal(t *testing.T) {
261 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
262 require.NoError(t, err)
263 defer os.RemoveAll(tmpDir)
264
265 store, err := NewFileStore(tmpDir, testLogger())
266 require.NoError(t, err)
267
268 tests := []struct {
269 name string
270 container string
271 wantSetErr bool
272 wantGetErr bool
273 }{
274 // Valid container names
275 {"simple", "postgres", false, false},
276 {"with hyphen", "gitea-postgres", false, false},
277 {"with underscore", "gitea_postgres", false, false},
278 {"complex real container", "gordon-git-example-com-gitea-postgres", false, false},
279
280 // Invalid container names - should fail
281 {"empty", "", true, true},
282 {"starts with number", "1container", true, true},
283 {"starts with hyphen", "-container", true, true},
284 {"starts with underscore", "_container", true, true},
285 {"path traversal", "../etc", true, true},
286 {"contains slash", "container/name", true, true},
287 {"contains backslash", "container\\name", true, true},
288 }
289
290 for _, tt := range tests {
291 t.Run(tt.name, func(t *testing.T) {
292 // Test SetAttachment
293 err := store.SetAttachment(tt.container, map[string]string{"KEY": "value"})
294 if tt.wantSetErr {
295 require.Error(t, err)
296 } else {
297 assert.NoError(t, err)
298 }
299
300 // Test GetAllAttachment
301 _, err = store.GetAllAttachment(tt.container)
302 if tt.wantGetErr {
303 require.Error(t, err)
304 } else {
305 if !tt.wantSetErr {
306 // Only check no error if we successfully set it
307 assert.NoError(t, err)
308 }
309 }
310 })
311 }
312}
313
314func TestFileStore_ValidDomainOperations(t *testing.T) {
315 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
316 require.NoError(t, err)
317 defer os.RemoveAll(tmpDir)
318
319 store, err := NewFileStore(tmpDir, testLogger())
320 require.NoError(t, err)
321
322 domain := "test.example.com"
323
324 // Set secrets
325 err = store.Set(domain, map[string]string{
326 "DB_HOST": "localhost",
327 "DB_PASSWORD": "secret123",
328 })
329 require.NoError(t, err)
330
331 // Get all secrets
332 secrets, err := store.GetAll(domain)
333 require.NoError(t, err)
334 assert.Equal(t, "localhost", secrets["DB_HOST"])
335 assert.Equal(t, "secret123", secrets["DB_PASSWORD"])
336
337 // List keys
338 keys, err := store.ListKeys(domain)
339 require.NoError(t, err)
340 assert.Len(t, keys, 2)
341 assert.Contains(t, keys, "DB_HOST")
342 assert.Contains(t, keys, "DB_PASSWORD")
343
344 // Delete a key
345 err = store.Delete(domain, "DB_PASSWORD")
346 require.NoError(t, err)
347
348 // Verify deletion
349 secrets, err = store.GetAll(domain)
350 require.NoError(t, err)
351 assert.Len(t, secrets, 1)
352 assert.Equal(t, "localhost", secrets["DB_HOST"])
353 _, exists := secrets["DB_PASSWORD"]
354 assert.False(t, exists)
355}
356
357func TestFileStore_DeleteAttachment(t *testing.T) {
358 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
359 require.NoError(t, err)
360 defer os.RemoveAll(tmpDir)
361
362 store, err := NewFileStore(tmpDir, testLogger())
363 require.NoError(t, err)
364
365 containerName := "gitea-postgres"
366
367 // Seed with two secrets
368 err = store.SetAttachment(containerName, map[string]string{
369 "POSTGRES_USER": "gitea",
370 "POSTGRES_PASSWORD": "secret123",
371 })
372 require.NoError(t, err)
373
374 // Delete one key
375 err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
376 require.NoError(t, err)
377
378 // Verify remaining
379 secrets, err := store.GetAllAttachment(containerName)
380 require.NoError(t, err)
381 assert.Len(t, secrets, 1)
382 assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
383 _, exists := secrets["POSTGRES_PASSWORD"]
384 assert.False(t, exists)
385
386 // Delete nonexistent key - should be no-op
387 err = store.DeleteAttachment(containerName, "NONEXISTENT_KEY")
388 assert.NoError(t, err)
389
390 // Verify secrets unchanged after no-op delete
391 secrets, err = store.GetAllAttachment(containerName)
392 require.NoError(t, err)
393 assert.Len(t, secrets, 1)
394 assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
395}
396
397func TestGetEnvFilePath(t *testing.T) {
398 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
399 require.NoError(t, err)
400 defer os.RemoveAll(tmpDir)
401
402 store, err := NewFileStore(tmpDir, testLogger())
403 require.NoError(t, err)
404
405 tests := []struct {
406 domain string
407 wantEmpty bool
408 wantFilename string
409 }{
410 {"example.com", false, "ZXhhbXBsZS5jb20.env"},
411 {"sub.example.com", false, "c3ViLmV4YW1wbGUuY29t.env"},
412 {"example.com:8080", false, "ZXhhbXBsZS5jb206ODA4MA.env"},
413 {"example.com/path", false, "ZXhhbXBsZS5jb20vcGF0aA.env"},
414 {"../evil", true, ""},
415 {"", true, ""},
416 }
417
418 for _, tt := range tests {
419 t.Run(tt.domain, func(t *testing.T) {
420 path, err := store.getEnvFilePath(tt.domain)
421
422 if tt.wantEmpty {
423 assert.Error(t, err)
424 assert.Empty(t, path)
425 } else {
426 require.NoError(t, err)
427 assert.NotEmpty(t, path)
428 assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
429 }
430 })
431 }
432}