v2.4.0 79b3461

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	outmocks "gordon/internal/boundaries/out/mocks"
13)
14
15func testLogger() zerowrap.Logger {
16	return zerowrap.Default()
17}
18
19func TestValidateDomain(t *testing.T) {
20	tests := []struct {
21		name    string
22		domain  string
23		wantErr error
24	}{
25		// Valid domains
26		{
27			name:    "valid simple domain",
28			domain:  "example.com",
29			wantErr: nil,
30		},
31		{
32			name:    "valid subdomain",
33			domain:  "app.example.com",
34			wantErr: nil,
35		},
36		{
37			name:    "valid domain with port",
38			domain:  "app.example.com:8080",
39			wantErr: nil,
40		},
41		{
42			name:    "valid domain with hyphens",
43			domain:  "my-app.example-site.com",
44			wantErr: nil,
45		},
46		{
47			name:    "valid single label",
48			domain:  "localhost",
49			wantErr: nil,
50		},
51		// Empty domain
52		{
53			name:    "empty domain",
54			domain:  "",
55			wantErr: ErrDomainEmpty,
56		},
57		// Path traversal attempts
58		{
59			name:    "path traversal with double dots",
60			domain:  "../etc/passwd",
61			wantErr: ErrDomainPathTraversal,
62		},
63		{
64			name:    "path traversal in middle",
65			domain:  "app/../etc/passwd",
66			wantErr: ErrDomainPathTraversal,
67		},
68		{
69			name:    "path traversal at end",
70			domain:  "app.example.com/..",
71			wantErr: ErrDomainPathTraversal,
72		},
73		{
74			name:    "multiple path traversal",
75			domain:  "..../....//etc/passwd",
76			wantErr: ErrDomainPathTraversal,
77		},
78		{
79			name:    "encoded path traversal still blocked",
80			domain:  "app..example.com",
81			wantErr: ErrDomainPathTraversal,
82		},
83		// Domain too long
84		{
85			name:    "domain at max length (253)",
86			domain:  strings.Repeat("a", 253),
87			wantErr: nil,
88		},
89		{
90			name:    "domain exceeds max length",
91			domain:  strings.Repeat("a", 254),
92			wantErr: ErrDomainTooLong,
93		},
94		{
95			name:    "domain way too long",
96			domain:  strings.Repeat("a", 1000),
97			wantErr: ErrDomainTooLong,
98		},
99		// Invalid characters
100		{
101			name:    "null byte in domain",
102			domain:  "app\x00.example.com",
103			wantErr: ErrDomainInvalidChars,
104		},
105		{
106			name:    "null byte at start",
107			domain:  "\x00example.com",
108			wantErr: ErrDomainInvalidChars,
109		},
110	}
111
112	for _, tt := range tests {
113		t.Run(tt.name, func(t *testing.T) {
114			err := ValidateDomain(tt.domain)
115			if tt.wantErr != nil {
116				assert.ErrorIs(t, err, tt.wantErr)
117			} else {
118				assert.NoError(t, err)
119			}
120		})
121	}
122}
123
124func TestService_ListKeys_ValidationErrors(t *testing.T) {
125	store := outmocks.NewMockDomainSecretStore(t)
126	svc := NewService(store, testLogger())
127
128	tests := []struct {
129		name    string
130		domain  string
131		wantErr error
132	}{
133		{
134			name:    "empty domain",
135			domain:  "",
136			wantErr: ErrDomainEmpty,
137		},
138		{
139			name:    "path traversal",
140			domain:  "../etc/passwd",
141			wantErr: ErrDomainPathTraversal,
142		},
143		{
144			name:    "domain too long",
145			domain:  strings.Repeat("a", 300),
146			wantErr: ErrDomainTooLong,
147		},
148	}
149
150	for _, tt := range tests {
151		t.Run(tt.name, func(t *testing.T) {
152			keys, err := svc.ListKeys(context.Background(), tt.domain)
153			assert.Nil(t, keys)
154			assert.ErrorIs(t, err, tt.wantErr)
155		})
156	}
157}
158
159func TestService_ListKeys_Success(t *testing.T) {
160	store := outmocks.NewMockDomainSecretStore(t)
161	svc := NewService(store, testLogger())
162
163	expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
164	store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
165
166	keys, err := svc.ListKeys(context.Background(), "app.example.com")
167	assert.NoError(t, err)
168	assert.Equal(t, expectedKeys, keys)
169}
170
171func TestService_GetAll_ValidationErrors(t *testing.T) {
172	store := outmocks.NewMockDomainSecretStore(t)
173	svc := NewService(store, testLogger())
174
175	tests := []struct {
176		name    string
177		domain  string
178		wantErr error
179	}{
180		{
181			name:    "empty domain",
182			domain:  "",
183			wantErr: ErrDomainEmpty,
184		},
185		{
186			name:    "path traversal",
187			domain:  "app/../secret",
188			wantErr: ErrDomainPathTraversal,
189		},
190	}
191
192	for _, tt := range tests {
193		t.Run(tt.name, func(t *testing.T) {
194			secrets, err := svc.GetAll(context.Background(), tt.domain)
195			assert.Nil(t, secrets)
196			assert.ErrorIs(t, err, tt.wantErr)
197		})
198	}
199}
200
201func TestService_GetAll_Success(t *testing.T) {
202	store := outmocks.NewMockDomainSecretStore(t)
203	svc := NewService(store, testLogger())
204
205	expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
206	store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil)
207
208	secrets, err := svc.GetAll(context.Background(), "app.example.com")
209	assert.NoError(t, err)
210	assert.Equal(t, expectedSecrets, secrets)
211}
212
213func TestService_Set_ValidationErrors(t *testing.T) {
214	store := outmocks.NewMockDomainSecretStore(t)
215	svc := NewService(store, testLogger())
216
217	secrets := map[string]string{"API_KEY": "secret123"}
218
219	tests := []struct {
220		name    string
221		domain  string
222		wantErr error
223	}{
224		{
225			name:    "empty domain",
226			domain:  "",
227			wantErr: ErrDomainEmpty,
228		},
229		{
230			name:    "path traversal",
231			domain:  "../../../etc/passwd",
232			wantErr: ErrDomainPathTraversal,
233		},
234	}
235
236	for _, tt := range tests {
237		t.Run(tt.name, func(t *testing.T) {
238			err := svc.Set(context.Background(), tt.domain, secrets)
239			assert.ErrorIs(t, err, tt.wantErr)
240		})
241	}
242}
243
244func TestService_Set_Success(t *testing.T) {
245	store := outmocks.NewMockDomainSecretStore(t)
246	svc := NewService(store, testLogger())
247
248	secrets := map[string]string{"API_KEY": "secret123"}
249	store.EXPECT().Set("app.example.com", secrets).Return(nil)
250
251	err := svc.Set(context.Background(), "app.example.com", secrets)
252	assert.NoError(t, err)
253}
254
255func TestService_Delete_ValidationErrors(t *testing.T) {
256	store := outmocks.NewMockDomainSecretStore(t)
257	svc := NewService(store, testLogger())
258
259	tests := []struct {
260		name    string
261		domain  string
262		wantErr error
263	}{
264		{
265			name:    "empty domain",
266			domain:  "",
267			wantErr: ErrDomainEmpty,
268		},
269		{
270			name:    "path traversal",
271			domain:  "..%2F..%2Fetc%2Fpasswd",
272			wantErr: ErrDomainPathTraversal,
273		},
274	}
275
276	for _, tt := range tests {
277		t.Run(tt.name, func(t *testing.T) {
278			err := svc.Delete(context.Background(), tt.domain, "API_KEY")
279			assert.ErrorIs(t, err, tt.wantErr)
280		})
281	}
282}
283
284func TestService_Delete_Success(t *testing.T) {
285	store := outmocks.NewMockDomainSecretStore(t)
286	svc := NewService(store, testLogger())
287
288	store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
289
290	err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
291	assert.NoError(t, err)
292}
293
294func TestService_StoreError_PropagatedCorrectly(t *testing.T) {
295	store := outmocks.NewMockDomainSecretStore(t)
296	svc := NewService(store, testLogger())
297
298	storeErr := assert.AnError
299	store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
300
301	keys, err := svc.ListKeys(context.Background(), "app.example.com")
302	assert.Nil(t, keys)
303	assert.ErrorIs(t, err, storeErr)
304}
305
306// TestPathTraversalVariants tests various path traversal attack patterns.
307func TestPathTraversalVariants(t *testing.T) {
308	tests := []struct {
309		name   string
310		domain string
311	}{
312		{"simple parent dir", ".."},
313		{"parent dir prefix", "../secret"},
314		{"parent dir suffix", "secret/.."},
315		{"parent dir in middle", "app/../secret"},
316		{"multiple parent dirs", "../../etc/passwd"},
317		{"windows style", "..\\..\\etc\\passwd"},
318		{"mixed slashes with dots", "app/./../../etc"},
319		{"double dots only", "...."},
320		{"triple dots", "..."},
321		{"dot dot in subdomain style", "app..evil.com"},
322	}
323
324	for _, tt := range tests {
325		t.Run(tt.name, func(t *testing.T) {
326			err := ValidateDomain(tt.domain)
327			// All these should be rejected (either path traversal or other validation)
328			// The main goal is that ".." patterns are caught
329			if strings.Contains(tt.domain, "..") {
330				assert.ErrorIs(t, err, ErrDomainPathTraversal, "domain %q should be rejected for path traversal", tt.domain)
331			}
332		})
333	}
334}
335
336// TestService_ValidationHappensBeforeStore ensures validation runs before any store calls.
337func TestService_ValidationHappensBeforeStore(t *testing.T) {
338	store := outmocks.NewMockDomainSecretStore(t)
339	svc := NewService(store, testLogger())
340
341	// With path traversal domain, store methods should never be called
342	// We don't set up any expectations, so if store is called, the test will fail
343
344	_, err := svc.ListKeys(context.Background(), "../etc/passwd")
345	assert.ErrorIs(t, err, ErrDomainPathTraversal)
346
347	_, err = svc.GetAll(context.Background(), "../etc/passwd")
348	assert.ErrorIs(t, err, ErrDomainPathTraversal)
349
350	err = svc.Set(context.Background(), "../etc/passwd", map[string]string{"key": "value"})
351	assert.ErrorIs(t, err, ErrDomainPathTraversal)
352
353	err = svc.Delete(context.Background(), "../etc/passwd", "key")
354	assert.ErrorIs(t, err, ErrDomainPathTraversal)
355
356	// Verify no store methods were called (mockery would fail if unexpected calls happened)
357	mock.AssertExpectationsForObjects(t, store)
358}

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