v2.15.1 c808592

service_test.go RawHistoryFolder

1package secrets
2
3import (
4	"context"
5	"strings"
6	"testing"
7
8	"github.com/bnema/zerowrap"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/mock"
11
12	"github.com/bnema/gordon/internal/boundaries/out"
13	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14)
15
16func testLogger() zerowrap.Logger {
17	return zerowrap.Default()
18}
19
20func TestValidateDomain(t *testing.T) {
21	tests := []struct {
22		name    string
23		domain  string
24		wantErr error
25	}{
26		// Valid domains
27		{
28			name:    "valid simple domain",
29			domain:  "example.com",
30			wantErr: nil,
31		},
32		{
33			name:    "valid subdomain",
34			domain:  "app.example.com",
35			wantErr: nil,
36		},
37		{
38			name:    "valid domain with port",
39			domain:  "app.example.com:8080",
40			wantErr: nil,
41		},
42		{
43			name:    "valid domain with hyphens",
44			domain:  "my-app.example-site.com",
45			wantErr: nil,
46		},
47		{
48			name:    "valid single label",
49			domain:  "localhost",
50			wantErr: nil,
51		},
52		// Empty domain
53		{
54			name:    "empty domain",
55			domain:  "",
56			wantErr: ErrDomainEmpty,
57		},
58		// Path traversal attempts
59		{
60			name:    "path traversal with double dots",
61			domain:  "../etc/passwd",
62			wantErr: ErrDomainPathTraversal,
63		},
64		{
65			name:    "path traversal in middle",
66			domain:  "app/../etc/passwd",
67			wantErr: ErrDomainPathTraversal,
68		},
69		{
70			name:    "path traversal at end",
71			domain:  "app.example.com/..",
72			wantErr: ErrDomainPathTraversal,
73		},
74		{
75			name:    "multiple path traversal",
76			domain:  "..../....//etc/passwd",
77			wantErr: ErrDomainPathTraversal,
78		},
79		{
80			name:    "encoded path traversal still blocked",
81			domain:  "app..example.com",
82			wantErr: ErrDomainPathTraversal,
83		},
84		// Domain too long
85		{
86			name:    "domain at max length (253)",
87			domain:  strings.Repeat("a", 253),
88			wantErr: nil,
89		},
90		{
91			name:    "domain exceeds max length",
92			domain:  strings.Repeat("a", 254),
93			wantErr: ErrDomainTooLong,
94		},
95		{
96			name:    "domain way too long",
97			domain:  strings.Repeat("a", 1000),
98			wantErr: ErrDomainTooLong,
99		},
100		// Invalid characters
101		{
102			name:    "null byte in domain",
103			domain:  "app\x00.example.com",
104			wantErr: ErrDomainInvalidChars,
105		},
106		{
107			name:    "null byte at start",
108			domain:  "\x00example.com",
109			wantErr: ErrDomainInvalidChars,
110		},
111	}
112
113	for _, tt := range tests {
114		t.Run(tt.name, func(t *testing.T) {
115			err := ValidateDomain(tt.domain)
116			if tt.wantErr != nil {
117				assert.ErrorIs(t, err, tt.wantErr)
118			} else {
119				assert.NoError(t, err)
120			}
121		})
122	}
123}
124
125func TestService_ListKeys_ValidationErrors(t *testing.T) {
126	store := outmocks.NewMockDomainSecretStore(t)
127	svc := NewService(store, testLogger())
128
129	tests := []struct {
130		name    string
131		domain  string
132		wantErr error
133	}{
134		{
135			name:    "empty domain",
136			domain:  "",
137			wantErr: ErrDomainEmpty,
138		},
139		{
140			name:    "path traversal",
141			domain:  "../etc/passwd",
142			wantErr: ErrDomainPathTraversal,
143		},
144		{
145			name:    "domain too long",
146			domain:  strings.Repeat("a", 300),
147			wantErr: ErrDomainTooLong,
148		},
149	}
150
151	for _, tt := range tests {
152		t.Run(tt.name, func(t *testing.T) {
153			keys, err := svc.ListKeys(context.Background(), tt.domain)
154			assert.Nil(t, keys)
155			assert.ErrorIs(t, err, tt.wantErr)
156		})
157	}
158}
159
160func TestService_ListKeys_Success(t *testing.T) {
161	store := outmocks.NewMockDomainSecretStore(t)
162	svc := NewService(store, testLogger())
163
164	expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
165	store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
166
167	keys, err := svc.ListKeys(context.Background(), "app.example.com")
168	assert.NoError(t, err)
169	assert.Equal(t, expectedKeys, keys)
170}
171
172func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) {
173	store := outmocks.NewMockDomainSecretStore(t)
174	svc := NewService(store, testLogger())
175
176	tests := []struct {
177		name    string
178		domain  string
179		wantErr error
180	}{
181		{
182			name:    "empty domain",
183			domain:  "",
184			wantErr: ErrDomainEmpty,
185		},
186		{
187			name:    "path traversal",
188			domain:  "../etc/passwd",
189			wantErr: ErrDomainPathTraversal,
190		},
191		{
192			name:    "domain too long",
193			domain:  strings.Repeat("a", 300),
194			wantErr: ErrDomainTooLong,
195		},
196	}
197
198	for _, tt := range tests {
199		t.Run(tt.name, func(t *testing.T) {
200			keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), tt.domain)
201			assert.Nil(t, keys)
202			assert.Nil(t, attachments)
203			assert.ErrorIs(t, err, tt.wantErr)
204		})
205	}
206}
207
208func TestService_ListKeysWithAttachments_Success(t *testing.T) {
209	t.Run("returns both domain and attachment secrets", func(t *testing.T) {
210		store := outmocks.NewMockDomainSecretStore(t)
211		svc := NewService(store, testLogger())
212
213		expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
214		expectedAttachments := []out.AttachmentSecrets{
215			{Service: "postgres", Keys: []string{"POSTGRES_PASSWORD"}},
216			{Service: "redis", Keys: []string{"REDIS_PASSWORD", "REDIS_USER"}},
217		}
218
219		store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
220		store.EXPECT().ListAttachmentKeys("app.example.com").Return(expectedAttachments, nil)
221
222		keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
223		assert.NoError(t, err)
224		assert.Equal(t, expectedKeys, keys)
225		assert.Equal(t, expectedAttachments, attachments)
226	})
227
228	t.Run("returns empty attachments when none exist", func(t *testing.T) {
229		store := outmocks.NewMockDomainSecretStore(t)
230		svc := NewService(store, testLogger())
231
232		expectedKeys := []string{"API_KEY"}
233
234		store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
235		store.EXPECT().ListAttachmentKeys("app.example.com").Return(nil, nil)
236
237		keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
238		assert.NoError(t, err)
239		assert.Equal(t, expectedKeys, keys)
240		assert.Nil(t, attachments)
241	})
242
243	t.Run("continues without attachments on attachment error", func(t *testing.T) {
244		store := outmocks.NewMockDomainSecretStore(t)
245		svc := NewService(store, testLogger())
246
247		expectedKeys := []string{"API_KEY"}
248		attachmentErr := assert.AnError
249
250		store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
251		store.EXPECT().ListAttachmentKeys("app.example.com").Return(nil, attachmentErr)
252
253		// Should still succeed, just without attachments
254		keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
255		assert.NoError(t, err)
256		assert.Equal(t, expectedKeys, keys)
257		assert.Nil(t, attachments)
258	})
259}
260
261func TestService_ListKeysWithAttachments_StoreError(t *testing.T) {
262	store := outmocks.NewMockDomainSecretStore(t)
263	svc := NewService(store, testLogger())
264
265	storeErr := assert.AnError
266	store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
267
268	keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
269	assert.Nil(t, keys)
270	assert.Nil(t, attachments)
271	assert.ErrorIs(t, err, storeErr)
272}
273
274func TestService_GetAll_ValidationErrors(t *testing.T) {
275	store := outmocks.NewMockDomainSecretStore(t)
276	svc := NewService(store, testLogger())
277
278	tests := []struct {
279		name    string
280		domain  string
281		wantErr error
282	}{
283		{
284			name:    "empty domain",
285			domain:  "",
286			wantErr: ErrDomainEmpty,
287		},
288		{
289			name:    "path traversal",
290			domain:  "app/../secret",
291			wantErr: ErrDomainPathTraversal,
292		},
293	}
294
295	for _, tt := range tests {
296		t.Run(tt.name, func(t *testing.T) {
297			secrets, err := svc.GetAll(context.Background(), tt.domain)
298			assert.Nil(t, secrets)
299			assert.ErrorIs(t, err, tt.wantErr)
300		})
301	}
302}
303
304func TestService_GetAll_Success(t *testing.T) {
305	store := outmocks.NewMockDomainSecretStore(t)
306	svc := NewService(store, testLogger())
307
308	expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
309	store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil)
310
311	secrets, err := svc.GetAll(context.Background(), "app.example.com")
312	assert.NoError(t, err)
313	assert.Equal(t, expectedSecrets, secrets)
314}
315
316func TestService_Set_ValidationErrors(t *testing.T) {
317	store := outmocks.NewMockDomainSecretStore(t)
318	svc := NewService(store, testLogger())
319
320	secrets := map[string]string{"API_KEY": "secret123"}
321
322	tests := []struct {
323		name    string
324		domain  string
325		wantErr error
326	}{
327		{
328			name:    "empty domain",
329			domain:  "",
330			wantErr: ErrDomainEmpty,
331		},
332		{
333			name:    "path traversal",
334			domain:  "../../../etc/passwd",
335			wantErr: ErrDomainPathTraversal,
336		},
337	}
338
339	for _, tt := range tests {
340		t.Run(tt.name, func(t *testing.T) {
341			err := svc.Set(context.Background(), tt.domain, secrets)
342			assert.ErrorIs(t, err, tt.wantErr)
343		})
344	}
345}
346
347func TestService_Set_Success(t *testing.T) {
348	store := outmocks.NewMockDomainSecretStore(t)
349	svc := NewService(store, testLogger())
350
351	secrets := map[string]string{"API_KEY": "secret123"}
352	store.EXPECT().Set("app.example.com", secrets).Return(nil)
353
354	err := svc.Set(context.Background(), "app.example.com", secrets)
355	assert.NoError(t, err)
356}
357
358func TestService_Delete_ValidationErrors(t *testing.T) {
359	store := outmocks.NewMockDomainSecretStore(t)
360	svc := NewService(store, testLogger())
361
362	tests := []struct {
363		name    string
364		domain  string
365		wantErr error
366	}{
367		{
368			name:    "empty domain",
369			domain:  "",
370			wantErr: ErrDomainEmpty,
371		},
372		{
373			name:    "path traversal",
374			domain:  "..%2F..%2Fetc%2Fpasswd",
375			wantErr: ErrDomainPathTraversal,
376		},
377	}
378
379	for _, tt := range tests {
380		t.Run(tt.name, func(t *testing.T) {
381			err := svc.Delete(context.Background(), tt.domain, "API_KEY")
382			assert.ErrorIs(t, err, tt.wantErr)
383		})
384	}
385}
386
387func TestService_Delete_Success(t *testing.T) {
388	store := outmocks.NewMockDomainSecretStore(t)
389	svc := NewService(store, testLogger())
390
391	store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
392
393	err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
394	assert.NoError(t, err)
395}
396
397func TestService_StoreError_PropagatedCorrectly(t *testing.T) {
398	store := outmocks.NewMockDomainSecretStore(t)
399	svc := NewService(store, testLogger())
400
401	storeErr := assert.AnError
402	store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
403
404	keys, err := svc.ListKeys(context.Background(), "app.example.com")
405	assert.Nil(t, keys)
406	assert.ErrorIs(t, err, storeErr)
407}
408
409// TestPathTraversalVariants tests various path traversal attack patterns.
410func TestPathTraversalVariants(t *testing.T) {
411	tests := []struct {
412		name   string
413		domain string
414	}{
415		{"simple parent dir", ".."},
416		{"parent dir prefix", "../secret"},
417		{"parent dir suffix", "secret/.."},
418		{"parent dir in middle", "app/../secret"},
419		{"multiple parent dirs", "../../etc/passwd"},
420		{"windows style", "..\\..\\etc\\passwd"},
421		{"mixed slashes with dots", "app/./../../etc"},
422		{"double dots only", "...."},
423		{"triple dots", "..."},
424		{"dot dot in subdomain style", "app..evil.com"},
425	}
426
427	for _, tt := range tests {
428		t.Run(tt.name, func(t *testing.T) {
429			err := ValidateDomain(tt.domain)
430			// All these should be rejected (either path traversal or other validation)
431			// The main goal is that ".." patterns are caught
432			if strings.Contains(tt.domain, "..") {
433				assert.ErrorIs(t, err, ErrDomainPathTraversal, "domain %q should be rejected for path traversal", tt.domain)
434			}
435		})
436	}
437}
438
439// TestService_ValidationHappensBeforeStore ensures validation runs before any store calls.
440func TestService_ValidationHappensBeforeStore(t *testing.T) {
441	store := outmocks.NewMockDomainSecretStore(t)
442	svc := NewService(store, testLogger())
443
444	// With path traversal domain, store methods should never be called
445	// We don't set up any expectations, so if store is called, the test will fail
446
447	_, err := svc.ListKeys(context.Background(), "../etc/passwd")
448	assert.ErrorIs(t, err, ErrDomainPathTraversal)
449
450	_, err = svc.GetAll(context.Background(), "../etc/passwd")
451	assert.ErrorIs(t, err, ErrDomainPathTraversal)
452
453	err = svc.Set(context.Background(), "../etc/passwd", map[string]string{"key": "value"})
454	assert.ErrorIs(t, err, ErrDomainPathTraversal)
455
456	err = svc.Delete(context.Background(), "../etc/passwd", "key")
457	assert.ErrorIs(t, err, ErrDomainPathTraversal)
458
459	// Verify no store methods were called (mockery would fail if unexpected calls happened)
460	mock.AssertExpectationsForObjects(t, store)
461}
462
463func TestService_SetAttachment(t *testing.T) {
464	store := outmocks.NewMockDomainSecretStore(t)
465	svc := NewService(store, testLogger())
466
467	secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"}
468	store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil)
469
470	err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)
471	assert.NoError(t, err)
472}
473
474func TestService_DeleteAttachment(t *testing.T) {
475	store := outmocks.NewMockDomainSecretStore(t)
476	svc := NewService(store, testLogger())
477
478	store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil)
479
480	err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")
481	assert.NoError(t, err)
482}
483
484func TestService_SetAttachment_InvalidDomain(t *testing.T) {
485	store := outmocks.NewMockDomainSecretStore(t)
486	svc := NewService(store, testLogger())
487
488	err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"})
489	assert.ErrorIs(t, err, ErrDomainEmpty)
490}
491
492func TestService_SetAttachment_EmptyService(t *testing.T) {
493	store := outmocks.NewMockDomainSecretStore(t)
494	svc := NewService(store, testLogger())
495
496	err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"})
497	assert.ErrorIs(t, err, ErrServiceEmpty)
498}
499
500func TestService_DeleteAttachment_InvalidDomain(t *testing.T) {
501	store := outmocks.NewMockDomainSecretStore(t)
502	svc := NewService(store, testLogger())
503
504	err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY")
505	assert.ErrorIs(t, err, ErrDomainEmpty)
506}
507
508func TestService_DeleteAttachment_EmptyService(t *testing.T) {
509	store := outmocks.NewMockDomainSecretStore(t)
510	svc := NewService(store, testLogger())
511
512	err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY")
513	assert.ErrorIs(t, err, ErrServiceEmpty)
514}
515
516func TestService_SetAttachment_InvalidServiceName(t *testing.T) {
517	store := outmocks.NewMockDomainSecretStore(t)
518	svc := NewService(store, testLogger())
519
520	err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"})
521	assert.ErrorIs(t, err, ErrInvalidServiceName)
522}
523
524func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) {
525	store := outmocks.NewMockDomainSecretStore(t)
526	svc := NewService(store, testLogger())
527
528	err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY")
529	assert.ErrorIs(t, err, ErrInvalidServiceName)
530}
531
532func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) {
533	store := outmocks.NewMockDomainSecretStore(t)
534	svc := NewService(store, testLogger())
535
536	// Service name exceeds DNS label limit of 63 characters
537	longService := strings.Repeat("a", 64)
538	err := svc.SetAttachment(context.Background(), "app.example.com", longService, map[string]string{"K": "V"})
539	assert.ErrorIs(t, err, ErrInvalidServiceName)
540}
541
542func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) {
543	store := outmocks.NewMockDomainSecretStore(t)
544	svc := NewService(store, testLogger())
545
546	err := svc.SetAttachment(context.Background(), "app.example.com", "postgres-", map[string]string{"K": "V"})
547	assert.ErrorIs(t, err, ErrInvalidServiceName)
548}
549
550func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) {
551	store := outmocks.NewMockDomainSecretStore(t)
552	svc := NewService(store, testLogger())
553
554	// Service name exceeds DNS label limit of 63 characters
555	longService := strings.Repeat("a", 64)
556	err := svc.DeleteAttachment(context.Background(), "app.example.com", longService, "SOME_KEY")
557	assert.ErrorIs(t, err, ErrInvalidServiceName)
558}
559
560func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) {
561	store := outmocks.NewMockDomainSecretStore(t)
562	svc := NewService(store, testLogger())
563
564	err := svc.DeleteAttachment(context.Background(), "app.example.com", "redis-", "SOME_KEY")
565	assert.ErrorIs(t, err, ErrInvalidServiceName)
566}
567
568func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) {
569	store := outmocks.NewMockDomainSecretStore(t)
570	svc := NewService(store, testLogger())
571
572	// With invalid service name, store methods should never be called
573	err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"key": "value"})
574	assert.ErrorIs(t, err, ErrInvalidServiceName)
575
576	err = svc.DeleteAttachment(context.Background(), "app.example.com", "../evil", "key")
577	assert.ErrorIs(t, err, ErrInvalidServiceName)
578
579	// Verify no store methods were called
580	mock.AssertExpectationsForObjects(t, store)
581}

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