v2.11.0 dca6537

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, "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 TestFileStore_DeleteAttachment(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	containerName := "gitea-postgres"
348
349	// Seed with two secrets
350	err = store.SetAttachment(containerName, map[string]string{
351		"POSTGRES_USER":     "gitea",
352		"POSTGRES_PASSWORD": "secret123",
353	})
354	require.NoError(t, err)
355
356	// Delete one key
357	err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
358	require.NoError(t, err)
359
360	// Verify remaining
361	secrets, err := store.GetAllAttachment(containerName)
362	require.NoError(t, err)
363	assert.Len(t, secrets, 1)
364	assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
365	_, exists := secrets["POSTGRES_PASSWORD"]
366	assert.False(t, exists)
367
368	// Delete nonexistent key - should be no-op
369	err = store.DeleteAttachment(containerName, "NONEXISTENT_KEY")
370	assert.NoError(t, err)
371
372	// Verify secrets unchanged after no-op delete
373	secrets, err = store.GetAllAttachment(containerName)
374	require.NoError(t, err)
375	assert.Len(t, secrets, 1)
376	assert.Equal(t, "gitea", secrets["POSTGRES_USER"])
377}
378
379func TestGetEnvFilePath(t *testing.T) {
380	tmpDir, err := os.MkdirTemp("", "domainsecrets-test-*")
381	require.NoError(t, err)
382	defer os.RemoveAll(tmpDir)
383
384	store, err := NewFileStore(tmpDir, testLogger())
385	require.NoError(t, err)
386
387	tests := []struct {
388		domain       string
389		wantEmpty    bool
390		wantFilename string
391	}{
392		{"example.com", false, "example_com.env"},
393		{"sub.example.com", false, "sub_example_com.env"},
394		{"example.com:8080", false, "example_com_8080.env"},
395		{"example.com/path", false, "example_com_path.env"},
396		{"../evil", true, ""},
397		{"", true, ""},
398	}
399
400	for _, tt := range tests {
401		t.Run(tt.domain, func(t *testing.T) {
402			path := store.getEnvFilePath(tt.domain)
403
404			if tt.wantEmpty {
405				assert.Empty(t, path)
406			} else {
407				assert.NotEmpty(t, path)
408				assert.Equal(t, filepath.Join(tmpDir, tt.wantFilename), path)
409			}
410		})
411	}
412}

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