v2.31.0-dev19 35467d7

store_test.go RawHistoryFolder

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}

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