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}