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 "gordon/internal/boundaries/out"
13 outmocks "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}