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}