1package secrets
2
3import (
4 "context"
5 "log/slog"
6 "os"
7 "testing"
8 "time"
9
10 "github.com/bluesky-social/indigo/atproto/syntax"
11 "github.com/stretchr/testify/assert"
12)
13
14// MockOpenBaoManager is a mock implementation of Manager interface for testing
15type MockOpenBaoManager struct {
16 secrets map[string]UnlockedSecret // key: repo_key format
17 shouldError bool
18 errorToReturn error
19}
20
21func NewMockOpenBaoManager() *MockOpenBaoManager {
22 return &MockOpenBaoManager{secrets: make(map[string]UnlockedSecret)}
23}
24
25func (m *MockOpenBaoManager) SetError(err error) {
26 m.shouldError = true
27 m.errorToReturn = err
28}
29
30func (m *MockOpenBaoManager) ClearError() {
31 m.shouldError = false
32 m.errorToReturn = nil
33}
34
35func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string {
36 return string(repo) + "_" + key
37}
38
39func (m *MockOpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
40 if m.shouldError {
41 return m.errorToReturn
42 }
43
44 key := m.buildKey(secret.Repo, secret.Key)
45 if _, exists := m.secrets[key]; exists {
46 return ErrKeyAlreadyPresent
47 }
48
49 m.secrets[key] = secret
50 return nil
51}
52
53func (m *MockOpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
54 if m.shouldError {
55 return m.errorToReturn
56 }
57
58 key := m.buildKey(secret.Repo, secret.Key)
59 if _, exists := m.secrets[key]; !exists {
60 return ErrKeyNotFound
61 }
62
63 delete(m.secrets, key)
64 return nil
65}
66
67func (m *MockOpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
68 if m.shouldError {
69 return nil, m.errorToReturn
70 }
71
72 var result []LockedSecret
73 for _, secret := range m.secrets {
74 if secret.Repo == repo {
75 result = append(result, LockedSecret{
76 Key: secret.Key,
77 Repo: secret.Repo,
78 CreatedAt: secret.CreatedAt,
79 CreatedBy: secret.CreatedBy,
80 })
81 }
82 }
83
84 return result, nil
85}
86
87func (m *MockOpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
88 if m.shouldError {
89 return nil, m.errorToReturn
90 }
91
92 var result []UnlockedSecret
93 for _, secret := range m.secrets {
94 if secret.Repo == repo {
95 result = append(result, secret)
96 }
97 }
98
99 return result, nil
100}
101
102func createTestSecretForOpenBao(repo, key, value, createdBy string) UnlockedSecret {
103 return UnlockedSecret{
104 Key: key,
105 Value: value,
106 Repo: DidSlashRepo(repo),
107 CreatedAt: time.Now(),
108 CreatedBy: syntax.DID(createdBy),
109 }
110}
111
112// Test MockOpenBaoManager interface compliance
113func TestMockOpenBaoManagerInterface(t *testing.T) {
114 var _ Manager = (*MockOpenBaoManager)(nil)
115}
116
117func TestOpenBaoManagerInterface(t *testing.T) {
118 var _ Manager = (*OpenBaoManager)(nil)
119}
120
121func TestNewOpenBaoManager(t *testing.T) {
122 tests := []struct {
123 name string
124 proxyAddr string
125 opts []OpenBaoManagerOpt
126 expectError bool
127 errorContains string
128 }{
129 {
130 name: "empty proxy address",
131 proxyAddr: "",
132 opts: nil,
133 expectError: true,
134 errorContains: "proxy address cannot be empty",
135 },
136 {
137 name: "valid proxy address",
138 proxyAddr: "http://localhost:8200",
139 opts: nil,
140 expectError: true, // Will fail because no real proxy is running
141 errorContains: "failed to connect to bao proxy",
142 },
143 {
144 name: "with mount path option",
145 proxyAddr: "http://localhost:8200",
146 opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")},
147 expectError: true, // Will fail because no real proxy is running
148 errorContains: "failed to connect to bao proxy",
149 },
150 }
151
152 for _, tt := range tests {
153 t.Run(tt.name, func(t *testing.T) {
154 logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
155 manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
156
157 if tt.expectError {
158 assert.Error(t, err)
159 assert.Nil(t, manager)
160 assert.Contains(t, err.Error(), tt.errorContains)
161 } else {
162 assert.NoError(t, err)
163 assert.NotNil(t, manager)
164 }
165 })
166 }
167}
168
169func TestOpenBaoManager_PathBuilding(t *testing.T) {
170 manager := &OpenBaoManager{mountPath: "secret"}
171
172 tests := []struct {
173 name string
174 repo DidSlashRepo
175 key string
176 expected string
177 }{
178 {
179 name: "simple repo path",
180 repo: DidSlashRepo("did:plc:foo/repo"),
181 key: "api_key",
182 expected: "repos/did_plc_foo_repo/api_key",
183 },
184 {
185 name: "complex repo path with dots",
186 repo: DidSlashRepo("did:web:example.com/my-repo"),
187 key: "secret_key",
188 expected: "repos/did_web_example_com_my-repo/secret_key",
189 },
190 }
191
192 for _, tt := range tests {
193 t.Run(tt.name, func(t *testing.T) {
194 result := manager.buildSecretPath(tt.repo, tt.key)
195 assert.Equal(t, tt.expected, result)
196 })
197 }
198}
199
200func TestOpenBaoManager_buildRepoPath(t *testing.T) {
201 manager := &OpenBaoManager{mountPath: "test"}
202
203 tests := []struct {
204 name string
205 repo DidSlashRepo
206 expected string
207 }{
208 {
209 name: "simple repo",
210 repo: "did:plc:test/myrepo",
211 expected: "repos/did_plc_test_myrepo",
212 },
213 {
214 name: "repo with dots",
215 repo: "did:plc:example.com/my.repo",
216 expected: "repos/did_plc_example_com_my_repo",
217 },
218 {
219 name: "complex repo",
220 repo: "did:web:example.com:8080/path/to/repo",
221 expected: "repos/did_web_example_com_8080_path_to_repo",
222 },
223 }
224
225 for _, tt := range tests {
226 t.Run(tt.name, func(t *testing.T) {
227 result := manager.buildRepoPath(tt.repo)
228 assert.Equal(t, tt.expected, result)
229 })
230 }
231}
232
233func TestWithMountPath(t *testing.T) {
234 manager := &OpenBaoManager{mountPath: "default"}
235
236 opt := WithMountPath("custom-mount")
237 opt(manager)
238
239 assert.Equal(t, "custom-mount", manager.mountPath)
240}
241
242func TestMockOpenBaoManager_AddSecret(t *testing.T) {
243 tests := []struct {
244 name string
245 secrets []UnlockedSecret
246 expectError bool
247 }{
248 {
249 name: "add single secret",
250 secrets: []UnlockedSecret{
251 createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
252 },
253 expectError: false,
254 },
255 {
256 name: "add multiple secrets",
257 secrets: []UnlockedSecret{
258 createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
259 createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
260 },
261 expectError: false,
262 },
263 {
264 name: "add duplicate secret",
265 secrets: []UnlockedSecret{
266 createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
267 createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "newsecret", "did:plc:creator"),
268 },
269 expectError: true,
270 },
271 }
272
273 for _, tt := range tests {
274 t.Run(tt.name, func(t *testing.T) {
275 mock := NewMockOpenBaoManager()
276 ctx := context.Background()
277 var err error
278
279 for i, secret := range tt.secrets {
280 err = mock.AddSecret(ctx, secret)
281 if tt.expectError && i == 1 { // Second secret should fail for duplicate test
282 assert.Equal(t, ErrKeyAlreadyPresent, err)
283 return
284 }
285 if !tt.expectError {
286 assert.NoError(t, err)
287 }
288 }
289
290 if !tt.expectError {
291 assert.NoError(t, err)
292 }
293 })
294 }
295}
296
297func TestMockOpenBaoManager_RemoveSecret(t *testing.T) {
298 tests := []struct {
299 name string
300 setupSecrets []UnlockedSecret
301 removeSecret Secret[any]
302 expectError bool
303 }{
304 {
305 name: "remove existing secret",
306 setupSecrets: []UnlockedSecret{
307 createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
308 },
309 removeSecret: Secret[any]{
310 Key: "API_KEY",
311 Repo: DidSlashRepo("did:plc:test/repo1"),
312 },
313 expectError: false,
314 },
315 {
316 name: "remove non-existent secret",
317 setupSecrets: []UnlockedSecret{},
318 removeSecret: Secret[any]{
319 Key: "API_KEY",
320 Repo: DidSlashRepo("did:plc:test/repo1"),
321 },
322 expectError: true,
323 },
324 }
325
326 for _, tt := range tests {
327 t.Run(tt.name, func(t *testing.T) {
328 mock := NewMockOpenBaoManager()
329 ctx := context.Background()
330
331 // Setup secrets
332 for _, secret := range tt.setupSecrets {
333 err := mock.AddSecret(ctx, secret)
334 assert.NoError(t, err)
335 }
336
337 // Remove secret
338 err := mock.RemoveSecret(ctx, tt.removeSecret)
339
340 if tt.expectError {
341 assert.Equal(t, ErrKeyNotFound, err)
342 } else {
343 assert.NoError(t, err)
344 }
345 })
346 }
347}
348
349func TestMockOpenBaoManager_GetSecretsLocked(t *testing.T) {
350 tests := []struct {
351 name string
352 setupSecrets []UnlockedSecret
353 queryRepo DidSlashRepo
354 expectedCount int
355 expectedKeys []string
356 expectError bool
357 }{
358 {
359 name: "get secrets from repo with secrets",
360 setupSecrets: []UnlockedSecret{
361 createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
362 createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
363 createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"),
364 },
365 queryRepo: DidSlashRepo("did:plc:test/repo1"),
366 expectedCount: 2,
367 expectedKeys: []string{"API_KEY", "DB_PASSWORD"},
368 expectError: false,
369 },
370 {
371 name: "get secrets from empty repo",
372 setupSecrets: []UnlockedSecret{},
373 queryRepo: DidSlashRepo("did:plc:test/empty"),
374 expectedCount: 0,
375 expectedKeys: []string{},
376 expectError: false,
377 },
378 }
379
380 for _, tt := range tests {
381 t.Run(tt.name, func(t *testing.T) {
382 mock := NewMockOpenBaoManager()
383 ctx := context.Background()
384
385 // Setup
386 for _, secret := range tt.setupSecrets {
387 err := mock.AddSecret(ctx, secret)
388 assert.NoError(t, err)
389 }
390
391 // Test
392 secrets, err := mock.GetSecretsLocked(ctx, tt.queryRepo)
393
394 if tt.expectError {
395 assert.Error(t, err)
396 } else {
397 assert.NoError(t, err)
398 assert.Len(t, secrets, tt.expectedCount)
399
400 // Check keys
401 actualKeys := make([]string, len(secrets))
402 for i, secret := range secrets {
403 actualKeys[i] = secret.Key
404 }
405
406 for _, expectedKey := range tt.expectedKeys {
407 assert.Contains(t, actualKeys, expectedKey)
408 }
409 }
410 })
411 }
412}
413
414func TestMockOpenBaoManager_GetSecretsUnlocked(t *testing.T) {
415 tests := []struct {
416 name string
417 setupSecrets []UnlockedSecret
418 queryRepo DidSlashRepo
419 expectedCount int
420 expectedSecrets map[string]string // key -> value
421 expectError bool
422 }{
423 {
424 name: "get unlocked secrets from repo",
425 setupSecrets: []UnlockedSecret{
426 createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"),
427 createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"),
428 createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"),
429 },
430 queryRepo: DidSlashRepo("did:plc:test/repo1"),
431 expectedCount: 2,
432 expectedSecrets: map[string]string{
433 "API_KEY": "secret123",
434 "DB_PASSWORD": "dbpass456",
435 },
436 expectError: false,
437 },
438 {
439 name: "get secrets from empty repo",
440 setupSecrets: []UnlockedSecret{},
441 queryRepo: DidSlashRepo("did:plc:test/empty"),
442 expectedCount: 0,
443 expectedSecrets: map[string]string{},
444 expectError: false,
445 },
446 }
447
448 for _, tt := range tests {
449 t.Run(tt.name, func(t *testing.T) {
450 mock := NewMockOpenBaoManager()
451 ctx := context.Background()
452
453 // Setup
454 for _, secret := range tt.setupSecrets {
455 err := mock.AddSecret(ctx, secret)
456 assert.NoError(t, err)
457 }
458
459 // Test
460 secrets, err := mock.GetSecretsUnlocked(ctx, tt.queryRepo)
461
462 if tt.expectError {
463 assert.Error(t, err)
464 } else {
465 assert.NoError(t, err)
466 assert.Len(t, secrets, tt.expectedCount)
467
468 // Check key-value pairs
469 actualSecrets := make(map[string]string)
470 for _, secret := range secrets {
471 actualSecrets[secret.Key] = secret.Value
472 }
473
474 for expectedKey, expectedValue := range tt.expectedSecrets {
475 actualValue, exists := actualSecrets[expectedKey]
476 assert.True(t, exists, "Expected key %s not found", expectedKey)
477 assert.Equal(t, expectedValue, actualValue)
478 }
479 }
480 })
481 }
482}
483
484func TestMockOpenBaoManager_ErrorHandling(t *testing.T) {
485 mock := NewMockOpenBaoManager()
486 ctx := context.Background()
487 testError := assert.AnError
488
489 // Test error injection
490 mock.SetError(testError)
491
492 secret := createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator")
493
494 // All operations should return the injected error
495 err := mock.AddSecret(ctx, secret)
496 assert.Equal(t, testError, err)
497
498 _, err = mock.GetSecretsLocked(ctx, "did:plc:test/repo1")
499 assert.Equal(t, testError, err)
500
501 _, err = mock.GetSecretsUnlocked(ctx, "did:plc:test/repo1")
502 assert.Equal(t, testError, err)
503
504 err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: "did:plc:test/repo1"})
505 assert.Equal(t, testError, err)
506
507 // Clear error and test normal operation
508 mock.ClearError()
509 err = mock.AddSecret(ctx, secret)
510 assert.NoError(t, err)
511}
512
513func TestMockOpenBaoManager_Integration(t *testing.T) {
514 tests := []struct {
515 name string
516 scenario func(t *testing.T, mock *MockOpenBaoManager)
517 }{
518 {
519 name: "complete workflow",
520 scenario: func(t *testing.T, mock *MockOpenBaoManager) {
521 ctx := context.Background()
522 repo := DidSlashRepo("did:plc:test/integration")
523
524 // Start with empty repo
525 secrets, err := mock.GetSecretsLocked(ctx, repo)
526 assert.NoError(t, err)
527 assert.Empty(t, secrets)
528
529 // Add some secrets
530 secret1 := createTestSecretForOpenBao(string(repo), "API_KEY", "secret123", "did:plc:creator")
531 secret2 := createTestSecretForOpenBao(string(repo), "DB_PASSWORD", "dbpass456", "did:plc:creator")
532
533 err = mock.AddSecret(ctx, secret1)
534 assert.NoError(t, err)
535
536 err = mock.AddSecret(ctx, secret2)
537 assert.NoError(t, err)
538
539 // Verify secrets exist
540 secrets, err = mock.GetSecretsLocked(ctx, repo)
541 assert.NoError(t, err)
542 assert.Len(t, secrets, 2)
543
544 unlockedSecrets, err := mock.GetSecretsUnlocked(ctx, repo)
545 assert.NoError(t, err)
546 assert.Len(t, unlockedSecrets, 2)
547
548 // Remove one secret
549 err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: repo})
550 assert.NoError(t, err)
551
552 // Verify only one secret remains
553 secrets, err = mock.GetSecretsLocked(ctx, repo)
554 assert.NoError(t, err)
555 assert.Len(t, secrets, 1)
556 assert.Equal(t, "DB_PASSWORD", secrets[0].Key)
557 },
558 },
559 }
560
561 for _, tt := range tests {
562 t.Run(tt.name, func(t *testing.T) {
563 mock := NewMockOpenBaoManager()
564 tt.scenario(t, mock)
565 })
566 }
567}
568
569func TestOpenBaoManager_ProxyConfiguration(t *testing.T) {
570 tests := []struct {
571 name string
572 proxyAddr string
573 description string
574 }{
575 {
576 name: "default_localhost",
577 proxyAddr: "http://127.0.0.1:8200",
578 description: "Should connect to default localhost proxy",
579 },
580 {
581 name: "custom_host",
582 proxyAddr: "http://bao-proxy:8200",
583 description: "Should connect to custom proxy host",
584 },
585 {
586 name: "https_proxy",
587 proxyAddr: "https://127.0.0.1:8200",
588 description: "Should connect to HTTPS proxy",
589 },
590 }
591
592 for _, tt := range tests {
593 t.Run(tt.name, func(t *testing.T) {
594 t.Log("Testing scenario:", tt.description)
595 logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
596
597 // All these will fail because no real proxy is running
598 // but we can test that the configuration is properly accepted
599 manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
600 assert.Error(t, err) // Expected because no real proxy
601 assert.Nil(t, manager)
602 assert.Contains(t, err.Error(), "failed to connect to bao proxy")
603 })
604 }
605}