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, "example_com.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 TestSanitizeDomainForContainer(t *testing.T) {
120 tests := []struct {
121 domain string
122 expected string
123 description string
124 }{
125 {
126 domain: "git.example.com",
127 expected: "git__example__com",
128 description: "Dots become double underscores",
129 },
130 {
131 domain: "git-example.com",
132 expected: "git-example__com",
133 description: "Hyphens preserved, dots become underscores",
134 },
135 {
136 domain: "app:8080.example.com",
137 expected: "app-_8080__example__com",
138 description: "Colons become hyphen-underscore",
139 },
140 {
141 domain: "git.example.com:3000",
142 expected: "git__example__com-_3000",
143 description: "Multiple separators handled distinctly",
144 },
145 {
146 domain: "simple.com",
147 expected: "simple__com",
148 description: "Simple domain",
149 },
150 }
151
152 for _, tt := range tests {
153 t.Run(tt.domain, func(t *testing.T) {
154 result := domain.SanitizeDomainForContainer(tt.domain)
155 assert.Equal(t, tt.expected, result, "sanitization should match expected")
156 })
157 }
158
159 // Verify no collisions between potentially conflicting domains
160 t.Run("NoCollisions", func(t *testing.T) {
161 domains := []string{
162 "git.example.com",
163 "git-example.com",
164 "app:8080.example.com",
165 "app-8080-example.com",
166 }
167
168 results := make(map[string]string)
169 for _, d := range domains {
170 result := domain.SanitizeDomainForContainer(d)
171 if original, exists := results[result]; exists {
172 t.Errorf("COLLISION: %q and %q both sanitize to %q", original, d, result)
173 }
174 results[result] = d
175 }
176 })
177}
178
179func TestFileStore_AttachmentOperations(t *testing.T) {
180 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
181 require.NoError(t, err)
182 defer os.RemoveAll(tmpDir)
183
184 store, err := NewFileStore(tmpDir, testLogger())
185 require.NoError(t, err)
186
187 containerName := "gitea-postgres"
188
189 t.Run("SetAttachment creates new file", func(t *testing.T) {
190 err := store.SetAttachment(containerName, map[string]string{
191 "POSTGRES_DB": "gitea",
192 "POSTGRES_PASSWORD": "secret123",
193 })
194 require.NoError(t, err)
195
196 // Verify file exists
197 expectedFile := filepath.Join(tmpDir, "gordon-"+containerName+".env")
198 _, err = os.Stat(expectedFile)
199 assert.NoError(t, err, "expected attachment env file to exist at %s", expectedFile)
200 })
201
202 t.Run("GetAllAttachment retrieves secrets", func(t *testing.T) {
203 secrets, err := store.GetAllAttachment(containerName)
204 require.NoError(t, err)
205 assert.Equal(t, "gitea", secrets["POSTGRES_DB"])
206 assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"])
207 })
208
209 t.Run("SetAttachment merges with existing", func(t *testing.T) {
210 err := store.SetAttachment(containerName, map[string]string{
211 "NEW_VAR": "new_value",
212 })
213 require.NoError(t, err)
214
215 // Should have both old and new secrets
216 secrets, err := store.GetAllAttachment(containerName)
217 require.NoError(t, err)
218 assert.Equal(t, "gitea", secrets["POSTGRES_DB"])
219 assert.Equal(t, "secret123", secrets["POSTGRES_PASSWORD"])
220 assert.Equal(t, "new_value", secrets["NEW_VAR"])
221 })
222
223 t.Run("GetAllAttachment returns empty map for non-existent container", func(t *testing.T) {
224 secrets, err := store.GetAllAttachment("non-existent")
225 require.NoError(t, err)
226 assert.Empty(t, secrets)
227 })
228
229 t.Run("SetAttachment with hyphenated container name", func(t *testing.T) {
230 hyphenatedContainer := "gordon-git-example-com-gitea-postgres"
231 err := store.SetAttachment(hyphenatedContainer, map[string]string{
232 "KEY": "value",
233 })
234 require.NoError(t, err)
235
236 secrets, err := store.GetAllAttachment(hyphenatedContainer)
237 require.NoError(t, err)
238 assert.Equal(t, "value", secrets["KEY"])
239 })
240}
241
242func TestFileStore_AttachmentPathTraversal(t *testing.T) {
243 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
244 require.NoError(t, err)
245 defer os.RemoveAll(tmpDir)
246
247 store, err := NewFileStore(tmpDir, testLogger())
248 require.NoError(t, err)
249
250 tests := []struct {
251 name string
252 container string
253 wantSetErr bool
254 wantGetErr bool
255 }{
256 // Valid container names
257 {"simple", "postgres", false, false},
258 {"with hyphen", "gitea-postgres", false, false},
259 {"with underscore", "gitea_postgres", false, false},
260 {"complex real container", "gordon-git-example-com-gitea-postgres", false, false},
261
262 // Invalid container names - should fail
263 {"empty", "", true, true},
264 {"starts with number", "1container", true, true},
265 {"starts with hyphen", "-container", true, true},
266 {"starts with underscore", "_container", true, true},
267 {"path traversal", "../etc", true, true},
268 {"contains slash", "container/name", true, true},
269 {"contains backslash", "container\\name", true, true},
270 }
271
272 for _, tt := range tests {
273 t.Run(tt.name, func(t *testing.T) {
274 // Test SetAttachment
275 err := store.SetAttachment(tt.container, map[string]string{"KEY": "value"})
276 if tt.wantSetErr {
277 require.Error(t, err)
278 } else {
279 assert.NoError(t, err)
280 }
281
282 // Test GetAllAttachment
283 _, err = store.GetAllAttachment(tt.container)
284 if tt.wantGetErr {
285 require.Error(t, err)
286 } else {
287 if !tt.wantSetErr {
288 // Only check no error if we successfully set it
289 assert.NoError(t, err)
290 }
291 }
292 })
293 }
294}
295
296func TestFileStore_ValidDomainOperations(t *testing.T) {
297 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
298 require.NoError(t, err)
299 defer os.RemoveAll(tmpDir)
300
301 store, err := NewFileStore(tmpDir, testLogger())
302 require.NoError(t, err)
303
304 domain := "test.example.com"
305
306 // Set secrets
307 err = store.Set(domain, map[string]string{
308 "DB_HOST": "localhost",
309 "DB_PASSWORD": "secret123",
310 })
311 require.NoError(t, err)
312
313 // Get all secrets
314 secrets, err := store.GetAll(domain)
315 require.NoError(t, err)
316 assert.Equal(t, "localhost", secrets["DB_HOST"])
317 assert.Equal(t, "secret123", secrets["DB_PASSWORD"])
318
319 // List keys
320 keys, err := store.ListKeys(domain)
321 require.NoError(t, err)
322 assert.Len(t, keys, 2)
323 assert.Contains(t, keys, "DB_HOST")
324 assert.Contains(t, keys, "DB_PASSWORD")
325
326 // Delete a key
327 err = store.Delete(domain, "DB_PASSWORD")
328 require.NoError(t, err)
329
330 // Verify deletion
331 secrets, err = store.GetAll(domain)
332 require.NoError(t, err)
333 assert.Len(t, secrets, 1)
334 assert.Equal(t, "localhost", secrets["DB_HOST"])
335 _, exists := secrets["DB_PASSWORD"]
336 assert.False(t, exists)
337}
338
339func TestGetEnvFilePath(t *testing.T) {
340 tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
341 require.NoError(t, err)
342 defer os.RemoveAll(tmpDir)
343
344 store, err := NewFileStore(tmpDir, testLogger())
345 require.NoError(t, err)
346
347 tests := []struct {
348 domain string
349 wantEmpty bool
350 wantFilename string
351 }{
352 {"example.com", false, "example_com.env"},
353 {"sub.example.com", false, "sub_example_com.env"},
354 {"example.com:8080", false, "example_com_8080.env"},
355 {"example.com/path", false, "example_com_path.env"},
356 {"../evil", true, ""},
357 {"", true, ""},
358 }
359
360 for _, tt := range tests {
361 t.Run(tt.domain, func(t *testing.T) {
362 path := store.getEnvFilePath(tt.domain)
363
364 if tt.wantEmpty {
365 assert.Empty(t, path)
366 } else {
367 assert.NotEmpty(t, path)
368 assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
369 }
370 })
371 }
372}