v3.0.0-alpha.1 6fdddb8

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 "github.com/bnema/gordon/internal/boundaries/out/mocks"
13	"github.com/bnema/gordon/internal/domain"
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			name:    "underscore rejected to avoid env filename collision",
113			domain:  "app_example.com",
114			wantErr: ErrDomainInvalidChars,
115		},
116	}
117
118	for _, tt := range tests {
119		t.Run(tt.name, func(t *testing.T) {
120			err := ValidateDomain(tt.domain)
121			if tt.wantErr != nil {
122				assert.ErrorIs(t, err, tt.wantErr)
123			} else {
124				assert.NoError(t, err)
125			}
126		})
127	}
128}
129
130func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) {
131	for _, domain := range []string{"app.example.com", "app:example:com", "app/example/com"} {
132		assert.NoError(t, ValidateDomain(domain), "expected %q to remain valid", domain)
133	}
134}
135
136func TestService_ListKeys_ValidationErrors(t *testing.T) {
137	store := outmocks.NewMockDomainSecretStore(t)
138	svc := NewService(store, testLogger(), nil)
139
140	tests := []struct {
141		name    string
142		domain  string
143		wantErr error
144	}{
145		{
146			name:    "empty domain",
147			domain:  "",
148			wantErr: ErrDomainEmpty,
149		},
150		{
151			name:    "path traversal",
152			domain:  "../etc/passwd",
153			wantErr: ErrDomainPathTraversal,
154		},
155		{
156			name:    "domain too long",
157			domain:  strings.Repeat("a", 300),
158			wantErr: ErrDomainTooLong,
159		},
160	}
161
162	for _, tt := range tests {
163		t.Run(tt.name, func(t *testing.T) {
164			keys, err := svc.ListKeys(context.Background(), tt.domain)
165			assert.Nil(t, keys)
166			assert.ErrorIs(t, err, tt.wantErr)
167		})
168	}
169}
170
171func TestService_ListKeys_Success(t *testing.T) {
172	store := outmocks.NewMockDomainSecretStore(t)
173	svc := NewService(store, testLogger(), nil)
174
175	expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
176	store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
177
178	keys, err := svc.ListKeys(context.Background(), "app.example.com")
179	assert.NoError(t, err)
180	assert.Equal(t, expectedKeys, keys)
181}
182
183func TestService_GetAll_ValidationErrors(t *testing.T) {
184	store := outmocks.NewMockDomainSecretStore(t)
185	svc := NewService(store, testLogger(), nil)
186
187	tests := []struct {
188		name    string
189		domain  string
190		wantErr error
191	}{
192		{
193			name:    "empty domain",
194			domain:  "",
195			wantErr: ErrDomainEmpty,
196		},
197		{
198			name:    "path traversal",
199			domain:  "app/../secret",
200			wantErr: ErrDomainPathTraversal,
201		},
202	}
203
204	for _, tt := range tests {
205		t.Run(tt.name, func(t *testing.T) {
206			secrets, err := svc.GetAll(context.Background(), tt.domain)
207			assert.Nil(t, secrets)
208			assert.ErrorIs(t, err, tt.wantErr)
209		})
210	}
211}
212
213func TestService_GetAll_Success(t *testing.T) {
214	store := outmocks.NewMockDomainSecretStore(t)
215	svc := NewService(store, testLogger(), nil)
216
217	expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
218	store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil)
219
220	secrets, err := svc.GetAll(context.Background(), "app.example.com")
221	assert.NoError(t, err)
222	assert.Equal(t, expectedSecrets, secrets)
223}
224
225func TestService_Set_ValidationErrors(t *testing.T) {
226	store := outmocks.NewMockDomainSecretStore(t)
227	svc := NewService(store, testLogger(), nil)
228
229	secrets := map[string]string{"API_KEY": "secret123"}
230
231	tests := []struct {
232		name    string
233		domain  string
234		wantErr error
235	}{
236		{
237			name:    "empty domain",
238			domain:  "",
239			wantErr: ErrDomainEmpty,
240		},
241		{
242			name:    "path traversal",
243			domain:  "../../../etc/passwd",
244			wantErr: ErrDomainPathTraversal,
245		},
246	}
247
248	for _, tt := range tests {
249		t.Run(tt.name, func(t *testing.T) {
250			err := svc.Set(context.Background(), tt.domain, secrets)
251			assert.ErrorIs(t, err, tt.wantErr)
252		})
253	}
254}
255
256func TestService_Set_Success(t *testing.T) {
257	store := outmocks.NewMockDomainSecretStore(t)
258	eventBus := outmocks.NewMockEventPublisher(t)
259	svc := NewService(store, testLogger(), eventBus)
260
261	secrets := map[string]string{"API_KEY": "secret123"}
262	store.EXPECT().Set("app.example.com", secrets).Return(nil)
263	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
264
265	err := svc.Set(context.Background(), "app.example.com", secrets)
266	assert.NoError(t, err)
267}
268
269func TestService_Set_PublishesSecretsChangedEvent(t *testing.T) {
270	store := outmocks.NewMockDomainSecretStore(t)
271	eventBus := outmocks.NewMockEventPublisher(t)
272	svc := NewService(store, testLogger(), eventBus)
273
274	secrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
275	store.EXPECT().Set("app.example.com", secrets).Return(nil)
276	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool {
277		assert.Equal(t, "app.example.com", payload.Domain)
278		assert.Equal(t, "set", payload.Operation)
279		assert.ElementsMatch(t, []string{"API_KEY", "DB_PASSWORD"}, payload.Keys)
280		return true
281	})).Return(nil)
282
283	err := svc.Set(context.Background(), "app.example.com", secrets)
284	assert.NoError(t, err)
285}
286
287func TestService_Delete_ValidationErrors(t *testing.T) {
288	store := outmocks.NewMockDomainSecretStore(t)
289	svc := NewService(store, testLogger(), nil)
290
291	tests := []struct {
292		name    string
293		domain  string
294		wantErr error
295	}{
296		{
297			name:    "empty domain",
298			domain:  "",
299			wantErr: ErrDomainEmpty,
300		},
301		{
302			name:    "path traversal",
303			domain:  "..%2F..%2Fetc%2Fpasswd",
304			wantErr: ErrDomainPathTraversal,
305		},
306	}
307
308	for _, tt := range tests {
309		t.Run(tt.name, func(t *testing.T) {
310			err := svc.Delete(context.Background(), tt.domain, "API_KEY")
311			assert.ErrorIs(t, err, tt.wantErr)
312		})
313	}
314}
315
316func TestService_Delete_Success(t *testing.T) {
317	store := outmocks.NewMockDomainSecretStore(t)
318	eventBus := outmocks.NewMockEventPublisher(t)
319	svc := NewService(store, testLogger(), eventBus)
320
321	store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
322	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
323
324	err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
325	assert.NoError(t, err)
326}
327
328func TestService_Delete_PublishesSecretsChangedEvent(t *testing.T) {
329	store := outmocks.NewMockDomainSecretStore(t)
330	eventBus := outmocks.NewMockEventPublisher(t)
331	svc := NewService(store, testLogger(), eventBus)
332
333	store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
334	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool {
335		assert.Equal(t, "app.example.com", payload.Domain)
336		assert.Equal(t, "delete", payload.Operation)
337		assert.Equal(t, []string{"API_KEY"}, payload.Keys)
338		return true
339	})).Return(nil)
340
341	err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
342	assert.NoError(t, err)
343}
344
345func TestService_Set_SucceedsEvenIfEventPublishFails(t *testing.T) {
346	store := outmocks.NewMockDomainSecretStore(t)
347	eventBus := outmocks.NewMockEventPublisher(t)
348	svc := NewService(store, testLogger(), eventBus)
349
350	secrets := map[string]string{"API_KEY": "secret123"}
351	store.EXPECT().Set("app.example.com", secrets).Return(nil)
352	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(assert.AnError)
353
354	err := svc.Set(context.Background(), "app.example.com", secrets)
355	assert.NoError(t, err)
356}
357
358func TestService_NilEventPublisher_DoesNotPanic(t *testing.T) {
359	store := outmocks.NewMockDomainSecretStore(t)
360	svc := NewService(store, testLogger(), nil)
361
362	secrets := map[string]string{"API_KEY": "secret123"}
363	store.EXPECT().Set("app.example.com", secrets).Return(nil)
364
365	assert.NotPanics(t, func() {
366		err := svc.Set(context.Background(), "app.example.com", secrets)
367		assert.NoError(t, err)
368	})
369}
370
371func TestService_StoreError_PropagatedCorrectly(t *testing.T) {
372	store := outmocks.NewMockDomainSecretStore(t)
373	svc := NewService(store, testLogger(), nil)
374
375	storeErr := assert.AnError
376	store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
377
378	keys, err := svc.ListKeys(context.Background(), "app.example.com")
379	assert.Nil(t, keys)
380	assert.ErrorIs(t, err, storeErr)
381}
382
383// TestPathTraversalVariants tests various path traversal attack patterns.
384func TestPathTraversalVariants(t *testing.T) {
385	tests := []struct {
386		name   string
387		domain string
388	}{
389		{"simple parent dir", ".."},
390		{"parent dir prefix", "../secret"},
391		{"parent dir suffix", "secret/.."},
392		{"parent dir in middle", "app/../secret"},
393		{"multiple parent dirs", "../../etc/passwd"},
394		{"windows style", "..\\..\\etc\\passwd"},
395		{"mixed slashes with dots", "app/./../../etc"},
396		{"double dots only", "...."},
397		{"triple dots", "..."},
398		{"dot dot in subdomain style", "app..evil.com"},
399	}
400
401	for _, tt := range tests {
402		t.Run(tt.name, func(t *testing.T) {
403			err := ValidateDomain(tt.domain)
404			// All these should be rejected (either path traversal or other validation)
405			// The main goal is that ".." patterns are caught
406			if strings.Contains(tt.domain, "..") {
407				assert.ErrorIs(t, err, ErrDomainPathTraversal, "domain %q should be rejected for path traversal", tt.domain)
408			}
409		})
410	}
411}
412
413// TestService_ValidationHappensBeforeStore ensures validation runs before any store calls.
414func TestService_ValidationHappensBeforeStore(t *testing.T) {
415	store := outmocks.NewMockDomainSecretStore(t)
416	svc := NewService(store, testLogger(), nil)
417
418	// With path traversal domain, store methods should never be called
419	// We don't set up any expectations, so if store is called, the test will fail
420
421	_, err := svc.ListKeys(context.Background(), "../etc/passwd")
422	assert.ErrorIs(t, err, ErrDomainPathTraversal)
423
424	_, err = svc.GetAll(context.Background(), "../etc/passwd")
425	assert.ErrorIs(t, err, ErrDomainPathTraversal)
426
427	err = svc.Set(context.Background(), "../etc/passwd", map[string]string{"key": "value"})
428	assert.ErrorIs(t, err, ErrDomainPathTraversal)
429
430	err = svc.Delete(context.Background(), "../etc/passwd", "key")
431	assert.ErrorIs(t, err, ErrDomainPathTraversal)
432
433	// Verify no store methods were called (mockery would fail if unexpected calls happened)
434	mock.AssertExpectationsForObjects(t, store)
435}

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