refactor(secrets): move domain validation to use case layer
- Create SecretService interface in boundaries/in - Create DomainSecretStore interface in boundaries/out - Implement file-based DomainSecretStore adapter - Create secrets use case with domain validation - Add comprehensive tests for path traversal prevention - Update HTTP adapter to use SecretService - Remove direct file operations from HTTP adapter
- commit
- 22b6b8562f17e7951e2ab53a9b3621107ad543a4
- author
- bnema · · 2026-01-16 22:54 UTC
- parent
- 20ba381
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +1257 -103
M .mockery.yaml +2 -0
| @@ -20,6 +20,7 @@ packages: | |||
| 20 | 20 | EnvLoader: | |
| 21 | 21 | ContainerLogWriter: | |
| 22 | 22 | TokenStore: | |
| 23 | + | DomainSecretStore: | |
| 23 | 24 | gordon/internal/boundaries/in: | |
| 24 | 25 | interfaces: | |
| 25 | 26 | ContainerService: | |
| @@ -29,3 +30,4 @@ packages: | |||
| 29 | 30 | AuthService: | |
| 30 | 31 | HealthService: | |
| 31 | 32 | HTTPProber: | |
| 33 | + | SecretService: | |
| @@ -20,6 +20,7 @@ packages: | |||||
| 20 | EnvLoader: | 20 | EnvLoader: | ||
| 21 | ContainerLogWriter: | 21 | ContainerLogWriter: | ||
| 22 | TokenStore: | 22 | TokenStore: | ||
| 23 | + | DomainSecretStore: | |||
| 23 | gordon/internal/boundaries/in: | 24 | gordon/internal/boundaries/in: | ||
| 24 | interfaces: | 25 | interfaces: | ||
| 25 | ContainerService: | 26 | ContainerService: | ||
| @@ -29,3 +30,4 @@ packages: | |||||
| 29 | AuthService: | 30 | AuthService: | ||
| 30 | HealthService: | 31 | HealthService: | ||
| 31 | HTTPProber: | 32 | HTTPProber: | ||
| 33 | + | SecretService: | |||
M internal/adapters/in/http/admin/handler.go +10 -10
| @@ -22,8 +22,8 @@ type Handler struct { | |||
| 22 | 22 | authSvc in.AuthService | |
| 23 | 23 | containerSvc in.ContainerService | |
| 24 | 24 | healthSvc in.HealthService | |
| 25 | + | secretSvc in.SecretService | |
| 25 | 26 | eventBus out.EventPublisher | |
| 26 | - | envDir string | |
| 27 | 27 | log zerowrap.Logger | |
| 28 | 28 | } | |
| 29 | 29 | ||
| @@ -33,8 +33,8 @@ func NewHandler( | |||
| 33 | 33 | authSvc in.AuthService, | |
| 34 | 34 | containerSvc in.ContainerService, | |
| 35 | 35 | healthSvc in.HealthService, | |
| 36 | + | secretSvc in.SecretService, | |
| 36 | 37 | eventBus out.EventPublisher, | |
| 37 | - | envDir string, | |
| 38 | 38 | log zerowrap.Logger, | |
| 39 | 39 | ) *Handler { | |
| 40 | 40 | return &Handler{ | |
| @@ -42,8 +42,8 @@ func NewHandler( | |||
| 42 | 42 | authSvc: authSvc, | |
| 43 | 43 | containerSvc: containerSvc, | |
| 44 | 44 | healthSvc: healthSvc, | |
| 45 | + | secretSvc: secretSvc, | |
| 45 | 46 | eventBus: eventBus, | |
| 46 | - | envDir: envDir, | |
| 47 | 47 | log: log, | |
| 48 | 48 | } | |
| 49 | 49 | } | |
| @@ -273,13 +273,13 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||
| 273 | 273 | return | |
| 274 | 274 | } | |
| 275 | 275 | // List secrets for domain (names only, not values) | |
| 276 | - | secrets, err := h.listSecrets(secretDomain) | |
| 276 | + | keys, err := h.secretSvc.ListKeys(ctx, secretDomain) | |
| 277 | 277 | if err != nil { | |
| 278 | 278 | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets") | |
| 279 | - | h.sendError(w, http.StatusInternalServerError, "failed to list secrets") | |
| 279 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 280 | 280 | return | |
| 281 | 281 | } | |
| 282 | - | h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": secrets}) | |
| 282 | + | h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": keys}) | |
| 283 | 283 | ||
| 284 | 284 | case http.MethodPost: | |
| 285 | 285 | // Check write permission | |
| @@ -299,9 +299,9 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||
| 299 | 299 | return | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | - | if err := h.setSecrets(secretDomain, data); err != nil { | |
| 302 | + | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { | |
| 303 | 303 | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | |
| 304 | - | h.sendError(w, http.StatusInternalServerError, "failed to set secrets") | |
| 304 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 305 | 305 | return | |
| 306 | 306 | } | |
| 307 | 307 | ||
| @@ -319,9 +319,9 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||
| 319 | 319 | return | |
| 320 | 320 | } | |
| 321 | 321 | ||
| 322 | - | if err := h.deleteSecret(secretDomain, secretKey); err != nil { | |
| 322 | + | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { | |
| 323 | 323 | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | |
| 324 | - | h.sendError(w, http.StatusInternalServerError, "failed to delete secret") | |
| 324 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") | |
| 325 | 325 | return | |
| 326 | 326 | } | |
| 327 | 327 | ||
| @@ -22,8 +22,8 @@ type Handler struct { | |||||
| 22 | authSvc in.AuthService | 22 | authSvc in.AuthService | ||
| 23 | containerSvc in.ContainerService | 23 | containerSvc in.ContainerService | ||
| 24 | healthSvc in.HealthService | 24 | healthSvc in.HealthService | ||
| 25 | + | secretSvc in.SecretService | |||
| 25 | eventBus out.EventPublisher | 26 | eventBus out.EventPublisher | ||
| 26 | - | envDir string | |||
| 27 | log zerowrap.Logger | 27 | log zerowrap.Logger | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| @@ -33,8 +33,8 @@ func NewHandler( | |||||
| 33 | authSvc in.AuthService, | 33 | authSvc in.AuthService, | ||
| 34 | containerSvc in.ContainerService, | 34 | containerSvc in.ContainerService, | ||
| 35 | healthSvc in.HealthService, | 35 | healthSvc in.HealthService, | ||
| 36 | + | secretSvc in.SecretService, | |||
| 36 | eventBus out.EventPublisher, | 37 | eventBus out.EventPublisher, | ||
| 37 | - | envDir string, | |||
| 38 | log zerowrap.Logger, | 38 | log zerowrap.Logger, | ||
| 39 | ) *Handler { | 39 | ) *Handler { | ||
| 40 | return &Handler{ | 40 | return &Handler{ | ||
| @@ -42,8 +42,8 @@ func NewHandler( | |||||
| 42 | authSvc: authSvc, | 42 | authSvc: authSvc, | ||
| 43 | containerSvc: containerSvc, | 43 | containerSvc: containerSvc, | ||
| 44 | healthSvc: healthSvc, | 44 | healthSvc: healthSvc, | ||
| 45 | + | secretSvc: secretSvc, | |||
| 45 | eventBus: eventBus, | 46 | eventBus: eventBus, | ||
| 46 | - | envDir: envDir, | |||
| 47 | log: log, | 47 | log: log, | ||
| 48 | } | 48 | } | ||
| 49 | } | 49 | } | ||
| @@ -273,13 +273,13 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||||
| 273 | return | 273 | return | ||
| 274 | } | 274 | } | ||
| 275 | // List secrets for domain (names only, not values) | 275 | // List secrets for domain (names only, not values) | ||
| 276 | - | secrets, err := h.listSecrets(secretDomain) | 276 | + | keys, err := h.secretSvc.ListKeys(ctx, secretDomain) |
| 277 | if err != nil { | 277 | if err != nil { | ||
| 278 | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets") | 278 | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to list secrets") | ||
| 279 | - | h.sendError(w, http.StatusInternalServerError, "failed to list secrets") | 279 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") |
| 280 | return | 280 | return | ||
| 281 | } | 281 | } | ||
| 282 | - | h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": secrets}) | 282 | + | h.sendJSON(w, http.StatusOK, map[string]any{"domain": secretDomain, "keys": keys}) |
| 283 | 283 | ||||
| 284 | case http.MethodPost: | 284 | case http.MethodPost: | ||
| 285 | // Check write permission | 285 | // Check write permission | ||
| @@ -299,9 +299,9 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||||
| 299 | return | 299 | return | ||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | - | if err := h.setSecrets(secretDomain, data); err != nil { | 302 | + | if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil { |
| 303 | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | 303 | log.Error().Err(err).Str("domain", secretDomain).Msg("failed to set secrets") | ||
| 304 | - | h.sendError(w, http.StatusInternalServerError, "failed to set secrets") | 304 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") |
| 305 | return | 305 | return | ||
| 306 | } | 306 | } | ||
| 307 | 307 | ||||
| @@ -319,9 +319,9 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str | |||||
| 319 | return | 319 | return | ||
| 320 | } | 320 | } | ||
| 321 | 321 | ||||
| 322 | - | if err := h.deleteSecret(secretDomain, secretKey); err != nil { | 322 | + | if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil { |
| 323 | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | 323 | log.Error().Err(err).Str("domain", secretDomain).Str("key", secretKey).Msg("failed to delete secret") | ||
| 324 | - | h.sendError(w, http.StatusInternalServerError, "failed to delete secret") | 324 | + | h.sendError(w, http.StatusBadRequest, "invalid domain") |
| 325 | return | 325 | return | ||
| 326 | } | 326 | } | ||
| 327 | 327 | ||||
M internal/adapters/in/http/admin/handler_test.go +52 -33
| @@ -6,8 +6,6 @@ import ( | |||
| 6 | 6 | "encoding/json" | |
| 7 | 7 | "net/http" | |
| 8 | 8 | "net/http/httptest" | |
| 9 | - | "os" | |
| 10 | - | "path/filepath" | |
| 11 | 9 | "testing" | |
| 12 | 10 | ||
| 13 | 11 | "github.com/bnema/zerowrap" | |
| @@ -33,8 +31,9 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | |||
| 33 | 31 | configSvc := inmocks.NewMockConfigService(t) | |
| 34 | 32 | authSvc := inmocks.NewMockAuthService(t) | |
| 35 | 33 | containerSvc := inmocks.NewMockContainerService(t) | |
| 34 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 36 | 35 | ||
| 37 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 36 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 38 | 37 | ||
| 39 | 38 | tests := []struct { | |
| 40 | 39 | name string | |
| @@ -94,8 +93,9 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||
| 94 | 93 | configSvc := inmocks.NewMockConfigService(t) | |
| 95 | 94 | authSvc := inmocks.NewMockAuthService(t) | |
| 96 | 95 | containerSvc := inmocks.NewMockContainerService(t) | |
| 96 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 97 | 97 | ||
| 98 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 98 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 99 | 99 | ||
| 100 | 100 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | |
| 101 | 101 | ||
| @@ -152,8 +152,9 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 152 | 152 | configSvc := inmocks.NewMockConfigService(t) | |
| 153 | 153 | authSvc := inmocks.NewMockAuthService(t) | |
| 154 | 154 | containerSvc := inmocks.NewMockContainerService(t) | |
| 155 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 155 | 156 | ||
| 156 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 157 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 157 | 158 | ||
| 158 | 159 | routeJSON := `{"image": "myapp:v2"}` | |
| 159 | 160 | ||
| @@ -195,8 +196,9 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 195 | 196 | configSvc := inmocks.NewMockConfigService(t) | |
| 196 | 197 | authSvc := inmocks.NewMockAuthService(t) | |
| 197 | 198 | containerSvc := inmocks.NewMockContainerService(t) | |
| 199 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 198 | 200 | ||
| 199 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 201 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 200 | 202 | ||
| 201 | 203 | tests := []struct { | |
| 202 | 204 | name string | |
| @@ -238,11 +240,9 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||
| 238 | 240 | configSvc := inmocks.NewMockConfigService(t) | |
| 239 | 241 | authSvc := inmocks.NewMockAuthService(t) | |
| 240 | 242 | containerSvc := inmocks.NewMockContainerService(t) | |
| 243 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 241 | 244 | ||
| 242 | - | // Create temp env directory | |
| 243 | - | tmpDir := t.TempDir() | |
| 244 | - | ||
| 245 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger()) | |
| 245 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 246 | 246 | ||
| 247 | 247 | tests := []struct { | |
| 248 | 248 | name string | |
| @@ -278,6 +278,10 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||
| 278 | 278 | ||
| 279 | 279 | for _, tt := range tests { | |
| 280 | 280 | t.Run(tt.name, func(t *testing.T) { | |
| 281 | + | if tt.wantStatus == http.StatusOK { | |
| 282 | + | secretSvc.EXPECT().ListKeys(mock.Anything, "app.example.com").Return([]string{}, nil).Maybe() | |
| 283 | + | } | |
| 284 | + | ||
| 281 | 285 | req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil) | |
| 282 | 286 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 283 | 287 | rec := httptest.NewRecorder() | |
| @@ -293,11 +297,9 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 293 | 297 | configSvc := inmocks.NewMockConfigService(t) | |
| 294 | 298 | authSvc := inmocks.NewMockAuthService(t) | |
| 295 | 299 | containerSvc := inmocks.NewMockContainerService(t) | |
| 300 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 296 | 301 | ||
| 297 | - | // Create temp env directory | |
| 298 | - | tmpDir := t.TempDir() | |
| 299 | - | ||
| 300 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger()) | |
| 302 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 301 | 303 | ||
| 302 | 304 | secretsJSON := `{"API_KEY": "secret123"}` | |
| 303 | 305 | ||
| @@ -325,6 +327,10 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 325 | 327 | ||
| 326 | 328 | for _, tt := range tests { | |
| 327 | 329 | t.Run(tt.name, func(t *testing.T) { | |
| 330 | + | if tt.wantStatus == http.StatusOK { | |
| 331 | + | secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe() | |
| 332 | + | } | |
| 333 | + | ||
| 328 | 334 | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON)) | |
| 329 | 335 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 330 | 336 | rec := httptest.NewRecorder() | |
| @@ -340,13 +346,9 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 340 | 346 | configSvc := inmocks.NewMockConfigService(t) | |
| 341 | 347 | authSvc := inmocks.NewMockAuthService(t) | |
| 342 | 348 | containerSvc := inmocks.NewMockContainerService(t) | |
| 349 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 343 | 350 | ||
| 344 | - | // Create temp env directory with a secret | |
| 345 | - | tmpDir := t.TempDir() | |
| 346 | - | envFile := filepath.Join(tmpDir, "app_example_com.env") | |
| 347 | - | os.WriteFile(envFile, []byte("API_KEY=secret123\n"), 0600) | |
| 348 | - | ||
| 349 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger()) | |
| 351 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 350 | 352 | ||
| 351 | 353 | tests := []struct { | |
| 352 | 354 | name string | |
| @@ -367,6 +369,10 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 367 | 369 | ||
| 368 | 370 | for _, tt := range tests { | |
| 369 | 371 | t.Run(tt.name, func(t *testing.T) { | |
| 372 | + | if tt.wantStatus == http.StatusOK { | |
| 373 | + | secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe() | |
| 374 | + | } | |
| 375 | + | ||
| 370 | 376 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil) | |
| 371 | 377 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 372 | 378 | rec := httptest.NewRecorder() | |
| @@ -384,8 +390,9 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||
| 384 | 390 | configSvc := inmocks.NewMockConfigService(t) | |
| 385 | 391 | authSvc := inmocks.NewMockAuthService(t) | |
| 386 | 392 | containerSvc := inmocks.NewMockContainerService(t) | |
| 393 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 387 | 394 | ||
| 388 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 395 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 389 | 396 | ||
| 390 | 397 | tests := []struct { | |
| 391 | 398 | name string | |
| @@ -437,8 +444,9 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||
| 437 | 444 | configSvc := inmocks.NewMockConfigService(t) | |
| 438 | 445 | authSvc := inmocks.NewMockAuthService(t) | |
| 439 | 446 | containerSvc := inmocks.NewMockContainerService(t) | |
| 447 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 440 | 448 | ||
| 441 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 449 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 442 | 450 | ||
| 443 | 451 | tests := []struct { | |
| 444 | 452 | name string | |
| @@ -493,8 +501,9 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||
| 493 | 501 | configSvc := inmocks.NewMockConfigService(t) | |
| 494 | 502 | authSvc := inmocks.NewMockAuthService(t) | |
| 495 | 503 | containerSvc := inmocks.NewMockContainerService(t) | |
| 504 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 496 | 505 | ||
| 497 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 506 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 498 | 507 | ||
| 499 | 508 | tests := []struct { | |
| 500 | 509 | name string | |
| @@ -546,8 +555,9 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||
| 546 | 555 | configSvc := inmocks.NewMockConfigService(t) | |
| 547 | 556 | authSvc := inmocks.NewMockAuthService(t) | |
| 548 | 557 | containerSvc := inmocks.NewMockContainerService(t) | |
| 558 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 549 | 559 | ||
| 550 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 560 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 551 | 561 | ||
| 552 | 562 | expectedRoutes := []domain.Route{ | |
| 553 | 563 | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| @@ -576,8 +586,9 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||
| 576 | 586 | configSvc := inmocks.NewMockConfigService(t) | |
| 577 | 587 | authSvc := inmocks.NewMockAuthService(t) | |
| 578 | 588 | containerSvc := inmocks.NewMockContainerService(t) | |
| 589 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 579 | 590 | ||
| 580 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 591 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 581 | 592 | ||
| 582 | 593 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 583 | 594 | {Domain: "app.example.com", Image: "app:latest"}, | |
| @@ -602,8 +613,9 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||
| 602 | 613 | configSvc := inmocks.NewMockConfigService(t) | |
| 603 | 614 | authSvc := inmocks.NewMockAuthService(t) | |
| 604 | 615 | containerSvc := inmocks.NewMockContainerService(t) | |
| 616 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 605 | 617 | ||
| 606 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 618 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 607 | 619 | ||
| 608 | 620 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | |
| 609 | 621 | ||
| @@ -620,8 +632,9 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||
| 620 | 632 | configSvc := inmocks.NewMockConfigService(t) | |
| 621 | 633 | authSvc := inmocks.NewMockAuthService(t) | |
| 622 | 634 | containerSvc := inmocks.NewMockContainerService(t) | |
| 635 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 623 | 636 | ||
| 624 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 637 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 625 | 638 | ||
| 626 | 639 | tests := []struct { | |
| 627 | 640 | name string | |
| @@ -658,8 +671,9 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | |||
| 658 | 671 | configSvc := inmocks.NewMockConfigService(t) | |
| 659 | 672 | authSvc := inmocks.NewMockAuthService(t) | |
| 660 | 673 | containerSvc := inmocks.NewMockContainerService(t) | |
| 674 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 661 | 675 | ||
| 662 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 676 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 663 | 677 | ||
| 664 | 678 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | |
| 665 | 679 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -674,8 +688,9 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||
| 674 | 688 | configSvc := inmocks.NewMockConfigService(t) | |
| 675 | 689 | authSvc := inmocks.NewMockAuthService(t) | |
| 676 | 690 | containerSvc := inmocks.NewMockContainerService(t) | |
| 691 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 677 | 692 | ||
| 678 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 693 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 679 | 694 | ||
| 680 | 695 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | |
| 681 | 696 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -690,8 +705,9 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||
| 690 | 705 | configSvc := inmocks.NewMockConfigService(t) | |
| 691 | 706 | authSvc := inmocks.NewMockAuthService(t) | |
| 692 | 707 | containerSvc := inmocks.NewMockContainerService(t) | |
| 708 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 693 | 709 | ||
| 694 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 710 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 695 | 711 | ||
| 696 | 712 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | |
| 697 | 713 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -706,8 +722,9 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||
| 706 | 722 | configSvc := inmocks.NewMockConfigService(t) | |
| 707 | 723 | authSvc := inmocks.NewMockAuthService(t) | |
| 708 | 724 | containerSvc := inmocks.NewMockContainerService(t) | |
| 725 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 709 | 726 | ||
| 710 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 727 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 711 | 728 | ||
| 712 | 729 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | |
| 713 | 730 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -722,8 +739,9 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||
| 722 | 739 | configSvc := inmocks.NewMockConfigService(t) | |
| 723 | 740 | authSvc := inmocks.NewMockAuthService(t) | |
| 724 | 741 | containerSvc := inmocks.NewMockContainerService(t) | |
| 742 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 725 | 743 | ||
| 726 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 744 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 727 | 745 | ||
| 728 | 746 | tests := []struct { | |
| 729 | 747 | method string | |
| @@ -754,8 +772,9 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 754 | 772 | configSvc := inmocks.NewMockConfigService(t) | |
| 755 | 773 | authSvc := inmocks.NewMockAuthService(t) | |
| 756 | 774 | containerSvc := inmocks.NewMockContainerService(t) | |
| 775 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 757 | 776 | ||
| 758 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | |
| 777 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) | |
| 759 | 778 | ||
| 760 | 779 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | |
| 761 | 780 | req = req.WithContext(ctxWithScopes("admin:*:*")) | |
| @@ -6,8 +6,6 @@ import ( | |||||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | "net/http/httptest" | 8 | "net/http/httptest" | ||
| 9 | - | "os" | |||
| 10 | - | "path/filepath" | |||
| 11 | "testing" | 9 | "testing" | ||
| 12 | 10 | ||||
| 13 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| @@ -33,8 +31,9 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | |||||
| 33 | configSvc := inmocks.NewMockConfigService(t) | 31 | configSvc := inmocks.NewMockConfigService(t) | ||
| 34 | authSvc := inmocks.NewMockAuthService(t) | 32 | authSvc := inmocks.NewMockAuthService(t) | ||
| 35 | containerSvc := inmocks.NewMockContainerService(t) | 33 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 34 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 36 | 35 | ||||
| 37 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 36 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 38 | 37 | ||||
| 39 | tests := []struct { | 38 | tests := []struct { | ||
| 40 | name string | 39 | name string | ||
| @@ -94,8 +93,9 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||||
| 94 | configSvc := inmocks.NewMockConfigService(t) | 93 | configSvc := inmocks.NewMockConfigService(t) | ||
| 95 | authSvc := inmocks.NewMockAuthService(t) | 94 | authSvc := inmocks.NewMockAuthService(t) | ||
| 96 | containerSvc := inmocks.NewMockContainerService(t) | 95 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 96 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 97 | 97 | ||||
| 98 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 98 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 99 | 99 | ||||
| 100 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | 100 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | ||
| 101 | 101 | ||||
| @@ -152,8 +152,9 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||||
| 152 | configSvc := inmocks.NewMockConfigService(t) | 152 | configSvc := inmocks.NewMockConfigService(t) | ||
| 153 | authSvc := inmocks.NewMockAuthService(t) | 153 | authSvc := inmocks.NewMockAuthService(t) | ||
| 154 | containerSvc := inmocks.NewMockContainerService(t) | 154 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 155 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 155 | 156 | ||||
| 156 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 157 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 157 | 158 | ||||
| 158 | routeJSON := `{"image": "myapp:v2"}` | 159 | routeJSON := `{"image": "myapp:v2"}` | ||
| 159 | 160 | ||||
| @@ -195,8 +196,9 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||||
| 195 | configSvc := inmocks.NewMockConfigService(t) | 196 | configSvc := inmocks.NewMockConfigService(t) | ||
| 196 | authSvc := inmocks.NewMockAuthService(t) | 197 | authSvc := inmocks.NewMockAuthService(t) | ||
| 197 | containerSvc := inmocks.NewMockContainerService(t) | 198 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 199 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 198 | 200 | ||||
| 199 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 201 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 200 | 202 | ||||
| 201 | tests := []struct { | 203 | tests := []struct { | ||
| 202 | name string | 204 | name string | ||
| @@ -238,11 +240,9 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||||
| 238 | configSvc := inmocks.NewMockConfigService(t) | 240 | configSvc := inmocks.NewMockConfigService(t) | ||
| 239 | authSvc := inmocks.NewMockAuthService(t) | 241 | authSvc := inmocks.NewMockAuthService(t) | ||
| 240 | containerSvc := inmocks.NewMockContainerService(t) | 242 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 243 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 241 | 244 | ||||
| 242 | - | // Create temp env directory | 245 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 243 | - | tmpDir := t.TempDir() | |||
| 244 | - | ||||
| 245 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger()) | |||
| 246 | 246 | ||||
| 247 | tests := []struct { | 247 | tests := []struct { | ||
| 248 | name string | 248 | name string | ||
| @@ -278,6 +278,10 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||||
| 278 | 278 | ||||
| 279 | for _, tt := range tests { | 279 | for _, tt := range tests { | ||
| 280 | t.Run(tt.name, func(t *testing.T) { | 280 | t.Run(tt.name, func(t *testing.T) { | ||
| 281 | + | if tt.wantStatus == http.StatusOK { | |||
| 282 | + | secretSvc.EXPECT().ListKeys(mock.Anything, "app.example.com").Return([]string{}, nil).Maybe() | |||
| 283 | + | } | |||
| 284 | + | ||||
| 281 | req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil) | 285 | req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil) | ||
| 282 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | 286 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | ||
| 283 | rec := httptest.NewRecorder() | 287 | rec := httptest.NewRecorder() | ||
| @@ -293,11 +297,9 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||||
| 293 | configSvc := inmocks.NewMockConfigService(t) | 297 | configSvc := inmocks.NewMockConfigService(t) | ||
| 294 | authSvc := inmocks.NewMockAuthService(t) | 298 | authSvc := inmocks.NewMockAuthService(t) | ||
| 295 | containerSvc := inmocks.NewMockContainerService(t) | 299 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 300 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 296 | 301 | ||||
| 297 | - | // Create temp env directory | 302 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 298 | - | tmpDir := t.TempDir() | |||
| 299 | - | ||||
| 300 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger()) | |||
| 301 | 303 | ||||
| 302 | secretsJSON := `{"API_KEY": "secret123"}` | 304 | secretsJSON := `{"API_KEY": "secret123"}` | ||
| 303 | 305 | ||||
| @@ -325,6 +327,10 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||||
| 325 | 327 | ||||
| 326 | for _, tt := range tests { | 328 | for _, tt := range tests { | ||
| 327 | t.Run(tt.name, func(t *testing.T) { | 329 | t.Run(tt.name, func(t *testing.T) { | ||
| 330 | + | if tt.wantStatus == http.StatusOK { | |||
| 331 | + | secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe() | |||
| 332 | + | } | |||
| 333 | + | ||||
| 328 | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON)) | 334 | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON)) | ||
| 329 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | 335 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | ||
| 330 | rec := httptest.NewRecorder() | 336 | rec := httptest.NewRecorder() | ||
| @@ -340,13 +346,9 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||||
| 340 | configSvc := inmocks.NewMockConfigService(t) | 346 | configSvc := inmocks.NewMockConfigService(t) | ||
| 341 | authSvc := inmocks.NewMockAuthService(t) | 347 | authSvc := inmocks.NewMockAuthService(t) | ||
| 342 | containerSvc := inmocks.NewMockContainerService(t) | 348 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 349 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 343 | 350 | ||||
| 344 | - | // Create temp env directory with a secret | 351 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 345 | - | tmpDir := t.TempDir() | |||
| 346 | - | envFile := filepath.Join(tmpDir, "app_example_com.env") | |||
| 347 | - | os.WriteFile(envFile, []byte("API_KEY=secret123\n"), 0600) | |||
| 348 | - | ||||
| 349 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, tmpDir, testLogger()) | |||
| 350 | 352 | ||||
| 351 | tests := []struct { | 353 | tests := []struct { | ||
| 352 | name string | 354 | name string | ||
| @@ -367,6 +369,10 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||||
| 367 | 369 | ||||
| 368 | for _, tt := range tests { | 370 | for _, tt := range tests { | ||
| 369 | t.Run(tt.name, func(t *testing.T) { | 371 | t.Run(tt.name, func(t *testing.T) { | ||
| 372 | + | if tt.wantStatus == http.StatusOK { | |||
| 373 | + | secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe() | |||
| 374 | + | } | |||
| 375 | + | ||||
| 370 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil) | 376 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil) | ||
| 371 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | 377 | req = req.WithContext(ctxWithScopes(tt.scopes...)) | ||
| 372 | rec := httptest.NewRecorder() | 378 | rec := httptest.NewRecorder() | ||
| @@ -384,8 +390,9 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||||
| 384 | configSvc := inmocks.NewMockConfigService(t) | 390 | configSvc := inmocks.NewMockConfigService(t) | ||
| 385 | authSvc := inmocks.NewMockAuthService(t) | 391 | authSvc := inmocks.NewMockAuthService(t) | ||
| 386 | containerSvc := inmocks.NewMockContainerService(t) | 392 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 393 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 387 | 394 | ||||
| 388 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 395 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 389 | 396 | ||||
| 390 | tests := []struct { | 397 | tests := []struct { | ||
| 391 | name string | 398 | name string | ||
| @@ -437,8 +444,9 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||||
| 437 | configSvc := inmocks.NewMockConfigService(t) | 444 | configSvc := inmocks.NewMockConfigService(t) | ||
| 438 | authSvc := inmocks.NewMockAuthService(t) | 445 | authSvc := inmocks.NewMockAuthService(t) | ||
| 439 | containerSvc := inmocks.NewMockContainerService(t) | 446 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 447 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 440 | 448 | ||||
| 441 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 449 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 442 | 450 | ||||
| 443 | tests := []struct { | 451 | tests := []struct { | ||
| 444 | name string | 452 | name string | ||
| @@ -493,8 +501,9 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||||
| 493 | configSvc := inmocks.NewMockConfigService(t) | 501 | configSvc := inmocks.NewMockConfigService(t) | ||
| 494 | authSvc := inmocks.NewMockAuthService(t) | 502 | authSvc := inmocks.NewMockAuthService(t) | ||
| 495 | containerSvc := inmocks.NewMockContainerService(t) | 503 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 504 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 496 | 505 | ||||
| 497 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 506 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 498 | 507 | ||||
| 499 | tests := []struct { | 508 | tests := []struct { | ||
| 500 | name string | 509 | name string | ||
| @@ -546,8 +555,9 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||||
| 546 | configSvc := inmocks.NewMockConfigService(t) | 555 | configSvc := inmocks.NewMockConfigService(t) | ||
| 547 | authSvc := inmocks.NewMockAuthService(t) | 556 | authSvc := inmocks.NewMockAuthService(t) | ||
| 548 | containerSvc := inmocks.NewMockContainerService(t) | 557 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 558 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 549 | 559 | ||||
| 550 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 560 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 551 | 561 | ||||
| 552 | expectedRoutes := []domain.Route{ | 562 | expectedRoutes := []domain.Route{ | ||
| 553 | {Domain: "app1.example.com", Image: "app1:latest"}, | 563 | {Domain: "app1.example.com", Image: "app1:latest"}, | ||
| @@ -576,8 +586,9 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||||
| 576 | configSvc := inmocks.NewMockConfigService(t) | 586 | configSvc := inmocks.NewMockConfigService(t) | ||
| 577 | authSvc := inmocks.NewMockAuthService(t) | 587 | authSvc := inmocks.NewMockAuthService(t) | ||
| 578 | containerSvc := inmocks.NewMockContainerService(t) | 588 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 589 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 579 | 590 | ||||
| 580 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 591 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 581 | 592 | ||||
| 582 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | 593 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | ||
| 583 | {Domain: "app.example.com", Image: "app:latest"}, | 594 | {Domain: "app.example.com", Image: "app:latest"}, | ||
| @@ -602,8 +613,9 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||||
| 602 | configSvc := inmocks.NewMockConfigService(t) | 613 | configSvc := inmocks.NewMockConfigService(t) | ||
| 603 | authSvc := inmocks.NewMockAuthService(t) | 614 | authSvc := inmocks.NewMockAuthService(t) | ||
| 604 | containerSvc := inmocks.NewMockContainerService(t) | 615 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 616 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 605 | 617 | ||||
| 606 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 618 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 607 | 619 | ||||
| 608 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | 620 | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}) | ||
| 609 | 621 | ||||
| @@ -620,8 +632,9 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||||
| 620 | configSvc := inmocks.NewMockConfigService(t) | 632 | configSvc := inmocks.NewMockConfigService(t) | ||
| 621 | authSvc := inmocks.NewMockAuthService(t) | 633 | authSvc := inmocks.NewMockAuthService(t) | ||
| 622 | containerSvc := inmocks.NewMockContainerService(t) | 634 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 635 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 623 | 636 | ||||
| 624 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 637 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 625 | 638 | ||||
| 626 | tests := []struct { | 639 | tests := []struct { | ||
| 627 | name string | 640 | name string | ||
| @@ -658,8 +671,9 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | |||||
| 658 | configSvc := inmocks.NewMockConfigService(t) | 671 | configSvc := inmocks.NewMockConfigService(t) | ||
| 659 | authSvc := inmocks.NewMockAuthService(t) | 672 | authSvc := inmocks.NewMockAuthService(t) | ||
| 660 | containerSvc := inmocks.NewMockContainerService(t) | 673 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 674 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 661 | 675 | ||||
| 662 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 676 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 663 | 677 | ||||
| 664 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | 678 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | ||
| 665 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 679 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -674,8 +688,9 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||||
| 674 | configSvc := inmocks.NewMockConfigService(t) | 688 | configSvc := inmocks.NewMockConfigService(t) | ||
| 675 | authSvc := inmocks.NewMockAuthService(t) | 689 | authSvc := inmocks.NewMockAuthService(t) | ||
| 676 | containerSvc := inmocks.NewMockContainerService(t) | 690 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 691 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 677 | 692 | ||||
| 678 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 693 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 679 | 694 | ||||
| 680 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | 695 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | ||
| 681 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | 696 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | ||
| @@ -690,8 +705,9 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||||
| 690 | configSvc := inmocks.NewMockConfigService(t) | 705 | configSvc := inmocks.NewMockConfigService(t) | ||
| 691 | authSvc := inmocks.NewMockAuthService(t) | 706 | authSvc := inmocks.NewMockAuthService(t) | ||
| 692 | containerSvc := inmocks.NewMockContainerService(t) | 707 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 708 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 693 | 709 | ||||
| 694 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 710 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 695 | 711 | ||||
| 696 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | 712 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | ||
| 697 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | 713 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | ||
| @@ -706,8 +722,9 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||||
| 706 | configSvc := inmocks.NewMockConfigService(t) | 722 | configSvc := inmocks.NewMockConfigService(t) | ||
| 707 | authSvc := inmocks.NewMockAuthService(t) | 723 | authSvc := inmocks.NewMockAuthService(t) | ||
| 708 | containerSvc := inmocks.NewMockContainerService(t) | 724 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 725 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 709 | 726 | ||||
| 710 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 727 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 711 | 728 | ||||
| 712 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | 729 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | ||
| 713 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | 730 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | ||
| @@ -722,8 +739,9 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||||
| 722 | configSvc := inmocks.NewMockConfigService(t) | 739 | configSvc := inmocks.NewMockConfigService(t) | ||
| 723 | authSvc := inmocks.NewMockAuthService(t) | 740 | authSvc := inmocks.NewMockAuthService(t) | ||
| 724 | containerSvc := inmocks.NewMockContainerService(t) | 741 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 742 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 725 | 743 | ||||
| 726 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 744 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 727 | 745 | ||||
| 728 | tests := []struct { | 746 | tests := []struct { | ||
| 729 | method string | 747 | method string | ||
| @@ -754,8 +772,9 @@ func TestHandler_NotFound(t *testing.T) { | |||||
| 754 | configSvc := inmocks.NewMockConfigService(t) | 772 | configSvc := inmocks.NewMockConfigService(t) | ||
| 755 | authSvc := inmocks.NewMockAuthService(t) | 773 | authSvc := inmocks.NewMockAuthService(t) | ||
| 756 | containerSvc := inmocks.NewMockContainerService(t) | 774 | containerSvc := inmocks.NewMockContainerService(t) | ||
| 775 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 757 | 776 | ||||
| 758 | - | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), nil, "/tmp/env", testLogger()) | 777 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, testLogger()) |
| 759 | 778 | ||||
| 760 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | 779 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | ||
| 761 | req = req.WithContext(ctxWithScopes("admin:*:*")) | 780 | req = req.WithContext(ctxWithScopes("admin:*:*")) | ||
R internal/adapters/in/http/admin/secrets.go → internal/adapters/out/domainsecrets/store.go +56 -59 renamed, 54% similar
| @@ -1,4 +1,5 @@ | |||
| 1 | - | package admin | |
| 1 | + | // Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files. | |
| 2 | + | package domainsecrets | |
| 2 | 3 | ||
| 3 | 4 | import ( | |
| 4 | 5 | "bufio" | |
| @@ -6,46 +7,40 @@ import ( | |||
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "strings" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerowrap" | |
| 9 | 12 | ) | |
| 10 | 13 | ||
| 11 | - | // domainToEnvFile converts a domain to its env file path. | |
| 12 | - | // Example: app.mydomain.com -> app_mydomain_com.env | |
| 13 | - | // Must match the naming convention in envloader.FileLoader.getEnvFilePath | |
| 14 | - | // Returns an error if the domain contains path traversal attempts or is invalid. | |
| 15 | - | func (h *Handler) domainToEnvFile(domain string) (string, error) { | |
| 16 | - | // Reject path traversal attempts | |
| 17 | - | if strings.Contains(domain, "..") { | |
| 18 | - | return "", fmt.Errorf("invalid domain: path traversal not allowed") | |
| 19 | - | } | |
| 14 | + | // FileStore implements the DomainSecretStore interface using filesystem-based env files. | |
| 15 | + | // This adapter is responsible only for file I/O operations; domain validation | |
| 16 | + | // should be performed by the use case layer before calling these methods. | |
| 17 | + | type FileStore struct { | |
| 18 | + | envDir string | |
| 19 | + | log zerowrap.Logger | |
| 20 | + | } | |
| 20 | 21 | ||
| 21 | - | // Validate domain length (max DNS name length is 253) | |
| 22 | - | if len(domain) == 0 || len(domain) > 253 { | |
| 23 | - | return "", fmt.Errorf("invalid domain length") | |
| 22 | + | // NewFileStore creates a new file-based domain secret store. | |
| 23 | + | func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) { | |
| 24 | + | // Ensure env directory exists | |
| 25 | + | if err := os.MkdirAll(envDir, 0700); err != nil { | |
| 26 | + | return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err) | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | - | // Replace special characters with underscores (matches envloader) | |
| 27 | - | filename := strings.ReplaceAll(domain, ".", "_") | |
| 28 | - | filename = strings.ReplaceAll(filename, ":", "_") | |
| 29 | - | filename = strings.ReplaceAll(filename, "/", "_") | |
| 30 | - | ||
| 31 | - | fullPath := filepath.Join(h.envDir, filename+".env") | |
| 29 | + | log.Debug(). | |
| 30 | + | Str(zerowrap.FieldLayer, "adapter"). | |
| 31 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). | |
| 32 | + | Str("env_dir", envDir). | |
| 33 | + | Msg("domain secret store initialized") | |
| 32 | 34 | ||
| 33 | - | // Verify path stays within envDir after cleaning | |
| 34 | - | cleanPath := filepath.Clean(fullPath) | |
| 35 | - | cleanEnvDir := filepath.Clean(h.envDir) | |
| 36 | - | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | |
| 37 | - | return "", fmt.Errorf("invalid domain: path escapes env directory") | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return cleanPath, nil | |
| 35 | + | return &FileStore{ | |
| 36 | + | envDir: envDir, | |
| 37 | + | log: log, | |
| 38 | + | }, nil | |
| 41 | 39 | } | |
| 42 | 40 | ||
| 43 | - | // listSecrets returns the list of secret keys for a domain (not values). | |
| 44 | - | func (h *Handler) listSecrets(domain string) ([]string, error) { | |
| 45 | - | envFile, err := h.domainToEnvFile(domain) | |
| 46 | - | if err != nil { | |
| 47 | - | return nil, err | |
| 48 | - | } | |
| 41 | + | // ListKeys returns the list of secret keys for a domain (not values). | |
| 42 | + | func (s *FileStore) ListKeys(domain string) ([]string, error) { | |
| 43 | + | envFile := s.getEnvFilePath(domain) | |
| 49 | 44 | ||
| 50 | 45 | file, err := os.Open(envFile) | |
| 51 | 46 | if err != nil { | |
| @@ -77,12 +72,9 @@ func (h *Handler) listSecrets(domain string) ([]string, error) { | |||
| 77 | 72 | return keys, nil | |
| 78 | 73 | } | |
| 79 | 74 | ||
| 80 | - | // getSecrets returns all secrets for a domain as a map. | |
| 81 | - | func (h *Handler) getSecrets(domain string) (map[string]string, error) { | |
| 82 | - | envFile, err := h.domainToEnvFile(domain) | |
| 83 | - | if err != nil { | |
| 84 | - | return nil, err | |
| 85 | - | } | |
| 75 | + | // GetAll returns all secrets for a domain as a key-value map. | |
| 76 | + | func (s *FileStore) GetAll(domain string) (map[string]string, error) { | |
| 77 | + | envFile := s.getEnvFilePath(domain) | |
| 86 | 78 | ||
| 87 | 79 | file, err := os.Open(envFile) | |
| 88 | 80 | if err != nil { | |
| @@ -116,32 +108,32 @@ func (h *Handler) getSecrets(domain string) (map[string]string, error) { | |||
| 116 | 108 | return secrets, nil | |
| 117 | 109 | } | |
| 118 | 110 | ||
| 119 | - | // setSecrets sets multiple secrets for a domain, merging with existing. | |
| 120 | - | func (h *Handler) setSecrets(domain string, newSecrets map[string]string) error { | |
| 111 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |
| 112 | + | func (s *FileStore) Set(domain string, secrets map[string]string) error { | |
| 121 | 113 | // Ensure env directory exists | |
| 122 | - | if err := os.MkdirAll(h.envDir, 0700); err != nil { | |
| 114 | + | if err := os.MkdirAll(s.envDir, 0700); err != nil { | |
| 123 | 115 | return fmt.Errorf("failed to create env directory: %w", err) | |
| 124 | 116 | } | |
| 125 | 117 | ||
| 126 | 118 | // Read existing secrets | |
| 127 | - | existing, err := h.getSecrets(domain) | |
| 119 | + | existing, err := s.GetAll(domain) | |
| 128 | 120 | if err != nil { | |
| 129 | 121 | return err | |
| 130 | 122 | } | |
| 131 | 123 | ||
| 132 | 124 | // Merge new secrets with existing | |
| 133 | - | for key, value := range newSecrets { | |
| 125 | + | for key, value := range secrets { | |
| 134 | 126 | existing[key] = value | |
| 135 | 127 | } | |
| 136 | 128 | ||
| 137 | - | // Write back | |
| 138 | - | return h.writeSecrets(domain, existing) | |
| 129 | + | // Write back atomically | |
| 130 | + | return s.writeSecretsAtomic(domain, existing) | |
| 139 | 131 | } | |
| 140 | 132 | ||
| 141 | - | // deleteSecret removes a secret from a domain's env file. | |
| 142 | - | func (h *Handler) deleteSecret(domain, key string) error { | |
| 133 | + | // Delete removes a specific secret key from a domain. | |
| 134 | + | func (s *FileStore) Delete(domain, key string) error { | |
| 143 | 135 | // Read existing secrets | |
| 144 | - | existing, err := h.getSecrets(domain) | |
| 136 | + | existing, err := s.GetAll(domain) | |
| 145 | 137 | if err != nil { | |
| 146 | 138 | return err | |
| 147 | 139 | } | |
| @@ -149,19 +141,14 @@ func (h *Handler) deleteSecret(domain, key string) error { | |||
| 149 | 141 | // Remove the key | |
| 150 | 142 | delete(existing, key) | |
| 151 | 143 | ||
| 152 | - | // Write back | |
| 153 | - | return h.writeSecrets(domain, existing) | |
| 144 | + | // Write back atomically | |
| 145 | + | return s.writeSecretsAtomic(domain, existing) | |
| 154 | 146 | } | |
| 155 | 147 | ||
| 156 | - | // writeSecrets writes all secrets to the domain's env file atomically. | |
| 148 | + | // writeSecretsAtomic writes all secrets to the domain's env file atomically. | |
| 157 | 149 | // It writes to a temporary file first, syncs it, then renames to the final path. | |
| 158 | - | func (h *Handler) writeSecrets(domain string, secrets map[string]string) error { | |
| 159 | - | envFile, err := h.domainToEnvFile(domain) | |
| 160 | - | if err != nil { | |
| 161 | - | return err | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // Write to temp file for atomic operation | |
| 150 | + | func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error { | |
| 151 | + | envFile := s.getEnvFilePath(domain) | |
| 165 | 152 | tmpFile := envFile + ".tmp" | |
| 166 | 153 | ||
| 167 | 154 | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| @@ -210,3 +197,13 @@ func (h *Handler) writeSecrets(domain string, secrets map[string]string) error { | |||
| 210 | 197 | ||
| 211 | 198 | return nil | |
| 212 | 199 | } | |
| 200 | + | ||
| 201 | + | // getEnvFilePath converts a domain to its env file path. | |
| 202 | + | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | |
| 203 | + | func (s *FileStore) getEnvFilePath(domain string) string { | |
| 204 | + | // Create domain-safe filename (replace dots and other chars with underscores) | |
| 205 | + | safeDomain := strings.ReplaceAll(domain, ".", "_") | |
| 206 | + | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |
| 207 | + | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |
| 208 | + | return filepath.Join(s.envDir, safeDomain+".env") | |
| 209 | + | } | |
| @@ -1,4 +1,5 @@ | |||||
| 1 | - | package admin | 1 | + | // Package domainsecrets implements the DomainSecretStore adapter using filesystem-based env files. |
| 2 | + | package domainsecrets | |||
| 2 | 3 | ||||
| 3 | import ( | 4 | import ( | ||
| 4 | "bufio" | 5 | "bufio" | ||
| @@ -6,46 +7,40 @@ import ( | |||||
| 6 | "os" | 7 | "os" | ||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| 8 | "strings" | 9 | "strings" | ||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 9 | ) | 12 | ) | ||
| 10 | 13 | ||||
| 11 | - | // domainToEnvFile converts a domain to its env file path. | 14 | + | // FileStore implements the DomainSecretStore interface using filesystem-based env files. |
| 12 | - | // Example: app.mydomain.com -> app_mydomain_com.env | 15 | + | // This adapter is responsible only for file I/O operations; domain validation |
| 13 | - | // Must match the naming convention in envloader.FileLoader.getEnvFilePath | 16 | + | // should be performed by the use case layer before calling these methods. |
| 14 | - | // Returns an error if the domain contains path traversal attempts or is invalid. | 17 | + | type FileStore struct { |
| 15 | - | func (h *Handler) domainToEnvFile(domain string) (string, error) { | 18 | + | envDir string |
| 16 | - | // Reject path traversal attempts | 19 | + | log zerowrap.Logger |
| 17 | - | if strings.Contains(domain, "..") { | 20 | + | } |
| 18 | - | return "", fmt.Errorf("invalid domain: path traversal not allowed") | |||
| 19 | - | } | |||
| 20 | 21 | ||||
| 21 | - | // Validate domain length (max DNS name length is 253) | 22 | + | // NewFileStore creates a new file-based domain secret store. |
| 22 | - | if len(domain) == 0 || len(domain) > 253 { | 23 | + | func NewFileStore(envDir string, log zerowrap.Logger) (*FileStore, error) { |
| 23 | - | return "", fmt.Errorf("invalid domain length") | 24 | + | // Ensure env directory exists |
| 25 | + | if err := os.MkdirAll(envDir, 0700); err != nil { | |||
| 26 | + | return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err) | |||
| 24 | } | 27 | } | ||
| 25 | 28 | ||||
| 26 | - | // Replace special characters with underscores (matches envloader) | 29 | + | log.Debug(). |
| 27 | - | filename := strings.ReplaceAll(domain, ".", "_") | 30 | + | Str(zerowrap.FieldLayer, "adapter"). |
| 28 | - | filename = strings.ReplaceAll(filename, ":", "_") | 31 | + | Str(zerowrap.FieldAdapter, "domainsecrets"). |
| 29 | - | filename = strings.ReplaceAll(filename, "/", "_") | 32 | + | Str("env_dir", envDir). |
| 30 | - | 33 | + | Msg("domain secret store initialized") | |
| 31 | - | fullPath := filepath.Join(h.envDir, filename+".env") | |||
| 32 | 34 | ||||
| 33 | - | // Verify path stays within envDir after cleaning | 35 | + | return &FileStore{ |
| 34 | - | cleanPath := filepath.Clean(fullPath) | 36 | + | envDir: envDir, |
| 35 | - | cleanEnvDir := filepath.Clean(h.envDir) | 37 | + | log: log, |
| 36 | - | if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) { | 38 | + | }, nil |
| 37 | - | return "", fmt.Errorf("invalid domain: path escapes env directory") | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | return cleanPath, nil | |||
| 41 | } | 39 | } | ||
| 42 | 40 | ||||
| 43 | - | // listSecrets returns the list of secret keys for a domain (not values). | 41 | + | // ListKeys returns the list of secret keys for a domain (not values). |
| 44 | - | func (h *Handler) listSecrets(domain string) ([]string, error) { | 42 | + | func (s *FileStore) ListKeys(domain string) ([]string, error) { |
| 45 | - | envFile, err := h.domainToEnvFile(domain) | 43 | + | envFile := s.getEnvFilePath(domain) |
| 46 | - | if err != nil { | |||
| 47 | - | return nil, err | |||
| 48 | - | } | |||
| 49 | 44 | ||||
| 50 | file, err := os.Open(envFile) | 45 | file, err := os.Open(envFile) | ||
| 51 | if err != nil { | 46 | if err != nil { | ||
| @@ -77,12 +72,9 @@ func (h *Handler) listSecrets(domain string) ([]string, error) { | |||||
| 77 | return keys, nil | 72 | return keys, nil | ||
| 78 | } | 73 | } | ||
| 79 | 74 | ||||
| 80 | - | // getSecrets returns all secrets for a domain as a map. | 75 | + | // GetAll returns all secrets for a domain as a key-value map. |
| 81 | - | func (h *Handler) getSecrets(domain string) (map[string]string, error) { | 76 | + | func (s *FileStore) GetAll(domain string) (map[string]string, error) { |
| 82 | - | envFile, err := h.domainToEnvFile(domain) | 77 | + | envFile := s.getEnvFilePath(domain) |
| 83 | - | if err != nil { | |||
| 84 | - | return nil, err | |||
| 85 | - | } | |||
| 86 | 78 | ||||
| 87 | file, err := os.Open(envFile) | 79 | file, err := os.Open(envFile) | ||
| 88 | if err != nil { | 80 | if err != nil { | ||
| @@ -116,32 +108,32 @@ func (h *Handler) getSecrets(domain string) (map[string]string, error) { | |||||
| 116 | return secrets, nil | 108 | return secrets, nil | ||
| 117 | } | 109 | } | ||
| 118 | 110 | ||||
| 119 | - | // setSecrets sets multiple secrets for a domain, merging with existing. | 111 | + | // Set sets or updates multiple secrets for a domain, merging with existing. |
| 120 | - | func (h *Handler) setSecrets(domain string, newSecrets map[string]string) error { | 112 | + | func (s *FileStore) Set(domain string, secrets map[string]string) error { |
| 121 | // Ensure env directory exists | 113 | // Ensure env directory exists | ||
| 122 | - | if err := os.MkdirAll(h.envDir, 0700); err != nil { | 114 | + | if err := os.MkdirAll(s.envDir, 0700); err != nil { |
| 123 | return fmt.Errorf("failed to create env directory: %w", err) | 115 | return fmt.Errorf("failed to create env directory: %w", err) | ||
| 124 | } | 116 | } | ||
| 125 | 117 | ||||
| 126 | // Read existing secrets | 118 | // Read existing secrets | ||
| 127 | - | existing, err := h.getSecrets(domain) | 119 | + | existing, err := s.GetAll(domain) |
| 128 | if err != nil { | 120 | if err != nil { | ||
| 129 | return err | 121 | return err | ||
| 130 | } | 122 | } | ||
| 131 | 123 | ||||
| 132 | // Merge new secrets with existing | 124 | // Merge new secrets with existing | ||
| 133 | - | for key, value := range newSecrets { | 125 | + | for key, value := range secrets { |
| 134 | existing[key] = value | 126 | existing[key] = value | ||
| 135 | } | 127 | } | ||
| 136 | 128 | ||||
| 137 | - | // Write back | 129 | + | // Write back atomically |
| 138 | - | return h.writeSecrets(domain, existing) | 130 | + | return s.writeSecretsAtomic(domain, existing) |
| 139 | } | 131 | } | ||
| 140 | 132 | ||||
| 141 | - | // deleteSecret removes a secret from a domain's env file. | 133 | + | // Delete removes a specific secret key from a domain. |
| 142 | - | func (h *Handler) deleteSecret(domain, key string) error { | 134 | + | func (s *FileStore) Delete(domain, key string) error { |
| 143 | // Read existing secrets | 135 | // Read existing secrets | ||
| 144 | - | existing, err := h.getSecrets(domain) | 136 | + | existing, err := s.GetAll(domain) |
| 145 | if err != nil { | 137 | if err != nil { | ||
| 146 | return err | 138 | return err | ||
| 147 | } | 139 | } | ||
| @@ -149,19 +141,14 @@ func (h *Handler) deleteSecret(domain, key string) error { | |||||
| 149 | // Remove the key | 141 | // Remove the key | ||
| 150 | delete(existing, key) | 142 | delete(existing, key) | ||
| 151 | 143 | ||||
| 152 | - | // Write back | 144 | + | // Write back atomically |
| 153 | - | return h.writeSecrets(domain, existing) | 145 | + | return s.writeSecretsAtomic(domain, existing) |
| 154 | } | 146 | } | ||
| 155 | 147 | ||||
| 156 | - | // writeSecrets writes all secrets to the domain's env file atomically. | 148 | + | // writeSecretsAtomic writes all secrets to the domain's env file atomically. |
| 157 | // It writes to a temporary file first, syncs it, then renames to the final path. | 149 | // It writes to a temporary file first, syncs it, then renames to the final path. | ||
| 158 | - | func (h *Handler) writeSecrets(domain string, secrets map[string]string) error { | 150 | + | func (s *FileStore) writeSecretsAtomic(domain string, secrets map[string]string) error { |
| 159 | - | envFile, err := h.domainToEnvFile(domain) | 151 | + | envFile := s.getEnvFilePath(domain) |
| 160 | - | if err != nil { | |||
| 161 | - | return err | |||
| 162 | - | } | |||
| 163 | - | ||||
| 164 | - | // Write to temp file for atomic operation | |||
| 165 | tmpFile := envFile + ".tmp" | 152 | tmpFile := envFile + ".tmp" | ||
| 166 | 153 | ||||
| 167 | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | 154 | file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | ||
| @@ -210,3 +197,13 @@ func (h *Handler) writeSecrets(domain string, secrets map[string]string) error { | |||||
| 210 | 197 | ||||
| 211 | return nil | 198 | return nil | ||
| 212 | } | 199 | } | ||
| 200 | + | ||||
| 201 | + | // getEnvFilePath converts a domain to its env file path. | |||
| 202 | + | // This must match the naming convention in envloader.FileLoader.getEnvFilePath. | |||
| 203 | + | func (s *FileStore) getEnvFilePath(domain string) string { | |||
| 204 | + | // Create domain-safe filename (replace dots and other chars with underscores) | |||
| 205 | + | safeDomain := strings.ReplaceAll(domain, ".", "_") | |||
| 206 | + | safeDomain = strings.ReplaceAll(safeDomain, ":", "_") | |||
| 207 | + | safeDomain = strings.ReplaceAll(safeDomain, "/", "_") | |||
| 208 | + | return filepath.Join(s.envDir, safeDomain+".env") | |||
| 209 | + | } | |||
M internal/app/run.go +10 -1
| @@ -21,6 +21,7 @@ import ( | |||
| 21 | 21 | ||
| 22 | 22 | // Adapters - Output | |
| 23 | 23 | "gordon/internal/adapters/out/docker" | |
| 24 | + | "gordon/internal/adapters/out/domainsecrets" | |
| 24 | 25 | "gordon/internal/adapters/out/envloader" | |
| 25 | 26 | "gordon/internal/adapters/out/eventbus" | |
| 26 | 27 | "gordon/internal/adapters/out/filesystem" | |
| @@ -49,6 +50,7 @@ import ( | |||
| 49 | 50 | "gordon/internal/usecase/health" | |
| 50 | 51 | "gordon/internal/usecase/proxy" | |
| 51 | 52 | registrySvc "gordon/internal/usecase/registry" | |
| 53 | + | secretsSvc "gordon/internal/usecase/secrets" | |
| 52 | 54 | ) | |
| 53 | 55 | ||
| 54 | 56 | // Config holds the application configuration. | |
| @@ -298,12 +300,19 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 298 | 300 | svc.envDir = filepath.Join(dataDir, "env") | |
| 299 | 301 | } | |
| 300 | 302 | ||
| 303 | + | // Create domain secret store and service | |
| 304 | + | domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log) | |
| 305 | + | if err != nil { | |
| 306 | + | return nil, log.WrapErr(err, "failed to create domain secret store") | |
| 307 | + | } | |
| 308 | + | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |
| 309 | + | ||
| 301 | 310 | // Create health service for route health checking | |
| 302 | 311 | prober := httpprober.New() | |
| 303 | 312 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| 304 | 313 | ||
| 305 | 314 | // Create admin handler for admin API | |
| 306 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, svc.eventBus, svc.envDir, log) | |
| 315 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, svc.eventBus, log) | |
| 307 | 316 | ||
| 308 | 317 | return svc, nil | |
| 309 | 318 | } | |
| @@ -21,6 +21,7 @@ import ( | |||||
| 21 | 21 | ||||
| 22 | // Adapters - Output | 22 | // Adapters - Output | ||
| 23 | "gordon/internal/adapters/out/docker" | 23 | "gordon/internal/adapters/out/docker" | ||
| 24 | + | "gordon/internal/adapters/out/domainsecrets" | |||
| 24 | "gordon/internal/adapters/out/envloader" | 25 | "gordon/internal/adapters/out/envloader" | ||
| 25 | "gordon/internal/adapters/out/eventbus" | 26 | "gordon/internal/adapters/out/eventbus" | ||
| 26 | "gordon/internal/adapters/out/filesystem" | 27 | "gordon/internal/adapters/out/filesystem" | ||
| @@ -49,6 +50,7 @@ import ( | |||||
| 49 | "gordon/internal/usecase/health" | 50 | "gordon/internal/usecase/health" | ||
| 50 | "gordon/internal/usecase/proxy" | 51 | "gordon/internal/usecase/proxy" | ||
| 51 | registrySvc "gordon/internal/usecase/registry" | 52 | registrySvc "gordon/internal/usecase/registry" | ||
| 53 | + | secretsSvc "gordon/internal/usecase/secrets" | |||
| 52 | ) | 54 | ) | ||
| 53 | 55 | ||||
| 54 | // Config holds the application configuration. | 56 | // Config holds the application configuration. | ||
| @@ -298,12 +300,19 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 298 | svc.envDir = filepath.Join(dataDir, "env") | 300 | svc.envDir = filepath.Join(dataDir, "env") | ||
| 299 | } | 301 | } | ||
| 300 | 302 | ||||
| 303 | + | // Create domain secret store and service | |||
| 304 | + | domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log) | |||
| 305 | + | if err != nil { | |||
| 306 | + | return nil, log.WrapErr(err, "failed to create domain secret store") | |||
| 307 | + | } | |||
| 308 | + | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |||
| 309 | + | ||||
| 301 | // Create health service for route health checking | 310 | // Create health service for route health checking | ||
| 302 | prober := httpprober.New() | 311 | prober := httpprober.New() | ||
| 303 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | 312 | healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log) | ||
| 304 | 313 | ||||
| 305 | // Create admin handler for admin API | 314 | // Create admin handler for admin API | ||
| 306 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, svc.eventBus, svc.envDir, log) | 315 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, svc.eventBus, log) |
| 307 | 316 | ||||
| 308 | return svc, nil | 317 | return svc, nil | ||
| 309 | } | 318 | } | ||
A internal/boundaries/in/mocks/mock_secret_service.go +300 -0
| @@ -0,0 +1,300 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | mock "github.com/stretchr/testify/mock" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // NewMockSecretService creates a new instance of MockSecretService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 14 | + | // The first argument is typically a *testing.T value. | |
| 15 | + | func NewMockSecretService(t interface { | |
| 16 | + | mock.TestingT | |
| 17 | + | Cleanup(func()) | |
| 18 | + | }) *MockSecretService { | |
| 19 | + | mock := &MockSecretService{} | |
| 20 | + | mock.Mock.Test(t) | |
| 21 | + | ||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 23 | + | ||
| 24 | + | return mock | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // MockSecretService is an autogenerated mock type for the SecretService type | |
| 28 | + | type MockSecretService struct { | |
| 29 | + | mock.Mock | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type MockSecretService_Expecter struct { | |
| 33 | + | mock *mock.Mock | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (_m *MockSecretService) EXPECT() *MockSecretService_Expecter { | |
| 37 | + | return &MockSecretService_Expecter{mock: &_m.Mock} | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Delete provides a mock function for the type MockSecretService | |
| 41 | + | func (_mock *MockSecretService) Delete(ctx context.Context, domain string, key string) error { | |
| 42 | + | ret := _mock.Called(ctx, domain, key) | |
| 43 | + | ||
| 44 | + | if len(ret) == 0 { | |
| 45 | + | panic("no return value specified for Delete") | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | var r0 error | |
| 49 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 50 | + | r0 = returnFunc(ctx, domain, key) | |
| 51 | + | } else { | |
| 52 | + | r0 = ret.Error(0) | |
| 53 | + | } | |
| 54 | + | return r0 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // MockSecretService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 58 | + | type MockSecretService_Delete_Call struct { | |
| 59 | + | *mock.Call | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Delete is a helper method to define mock.On call | |
| 63 | + | // - ctx context.Context | |
| 64 | + | // - domain string | |
| 65 | + | // - key string | |
| 66 | + | func (_e *MockSecretService_Expecter) Delete(ctx interface{}, domain interface{}, key interface{}) *MockSecretService_Delete_Call { | |
| 67 | + | return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)} | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (_c *MockSecretService_Delete_Call) Run(run func(ctx context.Context, domain string, key string)) *MockSecretService_Delete_Call { | |
| 71 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 72 | + | var arg0 context.Context | |
| 73 | + | if args[0] != nil { | |
| 74 | + | arg0 = args[0].(context.Context) | |
| 75 | + | } | |
| 76 | + | var arg1 string | |
| 77 | + | if args[1] != nil { | |
| 78 | + | arg1 = args[1].(string) | |
| 79 | + | } | |
| 80 | + | var arg2 string | |
| 81 | + | if args[2] != nil { | |
| 82 | + | arg2 = args[2].(string) | |
| 83 | + | } | |
| 84 | + | run( | |
| 85 | + | arg0, | |
| 86 | + | arg1, | |
| 87 | + | arg2, | |
| 88 | + | ) | |
| 89 | + | }) | |
| 90 | + | return _c | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (_c *MockSecretService_Delete_Call) Return(err error) *MockSecretService_Delete_Call { | |
| 94 | + | _c.Call.Return(err) | |
| 95 | + | return _c | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Context, domain string, key string) error) *MockSecretService_Delete_Call { | |
| 99 | + | _c.Call.Return(run) | |
| 100 | + | return _c | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // GetAll provides a mock function for the type MockSecretService | |
| 104 | + | func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |
| 105 | + | ret := _mock.Called(ctx, domain) | |
| 106 | + | ||
| 107 | + | if len(ret) == 0 { | |
| 108 | + | panic("no return value specified for GetAll") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | var r0 map[string]string | |
| 112 | + | var r1 error | |
| 113 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[string]string, error)); ok { | |
| 114 | + | return returnFunc(ctx, domain) | |
| 115 | + | } | |
| 116 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[string]string); ok { | |
| 117 | + | r0 = returnFunc(ctx, domain) | |
| 118 | + | } else { | |
| 119 | + | if ret.Get(0) != nil { | |
| 120 | + | r0 = ret.Get(0).(map[string]string) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 124 | + | r1 = returnFunc(ctx, domain) | |
| 125 | + | } else { | |
| 126 | + | r1 = ret.Error(1) | |
| 127 | + | } | |
| 128 | + | return r0, r1 | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // MockSecretService_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |
| 132 | + | type MockSecretService_GetAll_Call struct { | |
| 133 | + | *mock.Call | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // GetAll is a helper method to define mock.On call | |
| 137 | + | // - ctx context.Context | |
| 138 | + | // - domain string | |
| 139 | + | func (_e *MockSecretService_Expecter) GetAll(ctx interface{}, domain interface{}) *MockSecretService_GetAll_Call { | |
| 140 | + | return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)} | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (_c *MockSecretService_GetAll_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_GetAll_Call { | |
| 144 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 145 | + | var arg0 context.Context | |
| 146 | + | if args[0] != nil { | |
| 147 | + | arg0 = args[0].(context.Context) | |
| 148 | + | } | |
| 149 | + | var arg1 string | |
| 150 | + | if args[1] != nil { | |
| 151 | + | arg1 = args[1].(string) | |
| 152 | + | } | |
| 153 | + | run( | |
| 154 | + | arg0, | |
| 155 | + | arg1, | |
| 156 | + | ) | |
| 157 | + | }) | |
| 158 | + | return _c | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (_c *MockSecretService_GetAll_Call) Return(stringToString map[string]string, err error) *MockSecretService_GetAll_Call { | |
| 162 | + | _c.Call.Return(stringToString, err) | |
| 163 | + | return _c | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (_c *MockSecretService_GetAll_Call) RunAndReturn(run func(ctx context.Context, domain string) (map[string]string, error)) *MockSecretService_GetAll_Call { | |
| 167 | + | _c.Call.Return(run) | |
| 168 | + | return _c | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // ListKeys provides a mock function for the type MockSecretService | |
| 172 | + | func (_mock *MockSecretService) ListKeys(ctx context.Context, domain string) ([]string, error) { | |
| 173 | + | ret := _mock.Called(ctx, domain) | |
| 174 | + | ||
| 175 | + | if len(ret) == 0 { | |
| 176 | + | panic("no return value specified for ListKeys") | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | var r0 []string | |
| 180 | + | var r1 error | |
| 181 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |
| 182 | + | return returnFunc(ctx, domain) | |
| 183 | + | } | |
| 184 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |
| 185 | + | r0 = returnFunc(ctx, domain) | |
| 186 | + | } else { | |
| 187 | + | if ret.Get(0) != nil { | |
| 188 | + | r0 = ret.Get(0).([]string) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 192 | + | r1 = returnFunc(ctx, domain) | |
| 193 | + | } else { | |
| 194 | + | r1 = ret.Error(1) | |
| 195 | + | } | |
| 196 | + | return r0, r1 | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | // MockSecretService_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |
| 200 | + | type MockSecretService_ListKeys_Call struct { | |
| 201 | + | *mock.Call | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // ListKeys is a helper method to define mock.On call | |
| 205 | + | // - ctx context.Context | |
| 206 | + | // - domain string | |
| 207 | + | func (_e *MockSecretService_Expecter) ListKeys(ctx interface{}, domain interface{}) *MockSecretService_ListKeys_Call { | |
| 208 | + | return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)} | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (_c *MockSecretService_ListKeys_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_ListKeys_Call { | |
| 212 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 213 | + | var arg0 context.Context | |
| 214 | + | if args[0] != nil { | |
| 215 | + | arg0 = args[0].(context.Context) | |
| 216 | + | } | |
| 217 | + | var arg1 string | |
| 218 | + | if args[1] != nil { | |
| 219 | + | arg1 = args[1].(string) | |
| 220 | + | } | |
| 221 | + | run( | |
| 222 | + | arg0, | |
| 223 | + | arg1, | |
| 224 | + | ) | |
| 225 | + | }) | |
| 226 | + | return _c | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | func (_c *MockSecretService_ListKeys_Call) Return(strings []string, err error) *MockSecretService_ListKeys_Call { | |
| 230 | + | _c.Call.Return(strings, err) | |
| 231 | + | return _c | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func (_c *MockSecretService_ListKeys_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockSecretService_ListKeys_Call { | |
| 235 | + | _c.Call.Return(run) | |
| 236 | + | return _c | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // Set provides a mock function for the type MockSecretService | |
| 240 | + | func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error { | |
| 241 | + | ret := _mock.Called(ctx, domain, secrets) | |
| 242 | + | ||
| 243 | + | if len(ret) == 0 { | |
| 244 | + | panic("no return value specified for Set") | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | var r0 error | |
| 248 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok { | |
| 249 | + | r0 = returnFunc(ctx, domain, secrets) | |
| 250 | + | } else { | |
| 251 | + | r0 = ret.Error(0) | |
| 252 | + | } | |
| 253 | + | return r0 | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // MockSecretService_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |
| 257 | + | type MockSecretService_Set_Call struct { | |
| 258 | + | *mock.Call | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | // Set is a helper method to define mock.On call | |
| 262 | + | // - ctx context.Context | |
| 263 | + | // - domain string | |
| 264 | + | // - secrets map[string]string | |
| 265 | + | func (_e *MockSecretService_Expecter) Set(ctx interface{}, domain interface{}, secrets interface{}) *MockSecretService_Set_Call { | |
| 266 | + | return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)} | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func (_c *MockSecretService_Set_Call) Run(run func(ctx context.Context, domain string, secrets map[string]string)) *MockSecretService_Set_Call { | |
| 270 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 271 | + | var arg0 context.Context | |
| 272 | + | if args[0] != nil { | |
| 273 | + | arg0 = args[0].(context.Context) | |
| 274 | + | } | |
| 275 | + | var arg1 string | |
| 276 | + | if args[1] != nil { | |
| 277 | + | arg1 = args[1].(string) | |
| 278 | + | } | |
| 279 | + | var arg2 map[string]string | |
| 280 | + | if args[2] != nil { | |
| 281 | + | arg2 = args[2].(map[string]string) | |
| 282 | + | } | |
| 283 | + | run( | |
| 284 | + | arg0, | |
| 285 | + | arg1, | |
| 286 | + | arg2, | |
| 287 | + | ) | |
| 288 | + | }) | |
| 289 | + | return _c | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (_c *MockSecretService_Set_Call) Return(err error) *MockSecretService_Set_Call { | |
| 293 | + | _c.Call.Return(err) | |
| 294 | + | return _c | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context, domain string, secrets map[string]string) error) *MockSecretService_Set_Call { | |
| 298 | + | _c.Call.Return(run) | |
| 299 | + | return _c | |
| 300 | + | } | |
| @@ -0,0 +1,300 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | ||||
| 10 | + | mock "github.com/stretchr/testify/mock" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // NewMockSecretService creates a new instance of MockSecretService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 14 | + | // The first argument is typically a *testing.T value. | |||
| 15 | + | func NewMockSecretService(t interface { | |||
| 16 | + | mock.TestingT | |||
| 17 | + | Cleanup(func()) | |||
| 18 | + | }) *MockSecretService { | |||
| 19 | + | mock := &MockSecretService{} | |||
| 20 | + | mock.Mock.Test(t) | |||
| 21 | + | ||||
| 22 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 23 | + | ||||
| 24 | + | return mock | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // MockSecretService is an autogenerated mock type for the SecretService type | |||
| 28 | + | type MockSecretService struct { | |||
| 29 | + | mock.Mock | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type MockSecretService_Expecter struct { | |||
| 33 | + | mock *mock.Mock | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func (_m *MockSecretService) EXPECT() *MockSecretService_Expecter { | |||
| 37 | + | return &MockSecretService_Expecter{mock: &_m.Mock} | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Delete provides a mock function for the type MockSecretService | |||
| 41 | + | func (_mock *MockSecretService) Delete(ctx context.Context, domain string, key string) error { | |||
| 42 | + | ret := _mock.Called(ctx, domain, key) | |||
| 43 | + | ||||
| 44 | + | if len(ret) == 0 { | |||
| 45 | + | panic("no return value specified for Delete") | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | var r0 error | |||
| 49 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |||
| 50 | + | r0 = returnFunc(ctx, domain, key) | |||
| 51 | + | } else { | |||
| 52 | + | r0 = ret.Error(0) | |||
| 53 | + | } | |||
| 54 | + | return r0 | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // MockSecretService_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 58 | + | type MockSecretService_Delete_Call struct { | |||
| 59 | + | *mock.Call | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Delete is a helper method to define mock.On call | |||
| 63 | + | // - ctx context.Context | |||
| 64 | + | // - domain string | |||
| 65 | + | // - key string | |||
| 66 | + | func (_e *MockSecretService_Expecter) Delete(ctx interface{}, domain interface{}, key interface{}) *MockSecretService_Delete_Call { | |||
| 67 | + | return &MockSecretService_Delete_Call{Call: _e.mock.On("Delete", ctx, domain, key)} | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (_c *MockSecretService_Delete_Call) Run(run func(ctx context.Context, domain string, key string)) *MockSecretService_Delete_Call { | |||
| 71 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 72 | + | var arg0 context.Context | |||
| 73 | + | if args[0] != nil { | |||
| 74 | + | arg0 = args[0].(context.Context) | |||
| 75 | + | } | |||
| 76 | + | var arg1 string | |||
| 77 | + | if args[1] != nil { | |||
| 78 | + | arg1 = args[1].(string) | |||
| 79 | + | } | |||
| 80 | + | var arg2 string | |||
| 81 | + | if args[2] != nil { | |||
| 82 | + | arg2 = args[2].(string) | |||
| 83 | + | } | |||
| 84 | + | run( | |||
| 85 | + | arg0, | |||
| 86 | + | arg1, | |||
| 87 | + | arg2, | |||
| 88 | + | ) | |||
| 89 | + | }) | |||
| 90 | + | return _c | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (_c *MockSecretService_Delete_Call) Return(err error) *MockSecretService_Delete_Call { | |||
| 94 | + | _c.Call.Return(err) | |||
| 95 | + | return _c | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (_c *MockSecretService_Delete_Call) RunAndReturn(run func(ctx context.Context, domain string, key string) error) *MockSecretService_Delete_Call { | |||
| 99 | + | _c.Call.Return(run) | |||
| 100 | + | return _c | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // GetAll provides a mock function for the type MockSecretService | |||
| 104 | + | func (_mock *MockSecretService) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |||
| 105 | + | ret := _mock.Called(ctx, domain) | |||
| 106 | + | ||||
| 107 | + | if len(ret) == 0 { | |||
| 108 | + | panic("no return value specified for GetAll") | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | var r0 map[string]string | |||
| 112 | + | var r1 error | |||
| 113 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (map[string]string, error)); ok { | |||
| 114 | + | return returnFunc(ctx, domain) | |||
| 115 | + | } | |||
| 116 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) map[string]string); ok { | |||
| 117 | + | r0 = returnFunc(ctx, domain) | |||
| 118 | + | } else { | |||
| 119 | + | if ret.Get(0) != nil { | |||
| 120 | + | r0 = ret.Get(0).(map[string]string) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 124 | + | r1 = returnFunc(ctx, domain) | |||
| 125 | + | } else { | |||
| 126 | + | r1 = ret.Error(1) | |||
| 127 | + | } | |||
| 128 | + | return r0, r1 | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // MockSecretService_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |||
| 132 | + | type MockSecretService_GetAll_Call struct { | |||
| 133 | + | *mock.Call | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // GetAll is a helper method to define mock.On call | |||
| 137 | + | // - ctx context.Context | |||
| 138 | + | // - domain string | |||
| 139 | + | func (_e *MockSecretService_Expecter) GetAll(ctx interface{}, domain interface{}) *MockSecretService_GetAll_Call { | |||
| 140 | + | return &MockSecretService_GetAll_Call{Call: _e.mock.On("GetAll", ctx, domain)} | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (_c *MockSecretService_GetAll_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_GetAll_Call { | |||
| 144 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 145 | + | var arg0 context.Context | |||
| 146 | + | if args[0] != nil { | |||
| 147 | + | arg0 = args[0].(context.Context) | |||
| 148 | + | } | |||
| 149 | + | var arg1 string | |||
| 150 | + | if args[1] != nil { | |||
| 151 | + | arg1 = args[1].(string) | |||
| 152 | + | } | |||
| 153 | + | run( | |||
| 154 | + | arg0, | |||
| 155 | + | arg1, | |||
| 156 | + | ) | |||
| 157 | + | }) | |||
| 158 | + | return _c | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (_c *MockSecretService_GetAll_Call) Return(stringToString map[string]string, err error) *MockSecretService_GetAll_Call { | |||
| 162 | + | _c.Call.Return(stringToString, err) | |||
| 163 | + | return _c | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (_c *MockSecretService_GetAll_Call) RunAndReturn(run func(ctx context.Context, domain string) (map[string]string, error)) *MockSecretService_GetAll_Call { | |||
| 167 | + | _c.Call.Return(run) | |||
| 168 | + | return _c | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | // ListKeys provides a mock function for the type MockSecretService | |||
| 172 | + | func (_mock *MockSecretService) ListKeys(ctx context.Context, domain string) ([]string, error) { | |||
| 173 | + | ret := _mock.Called(ctx, domain) | |||
| 174 | + | ||||
| 175 | + | if len(ret) == 0 { | |||
| 176 | + | panic("no return value specified for ListKeys") | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | var r0 []string | |||
| 180 | + | var r1 error | |||
| 181 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { | |||
| 182 | + | return returnFunc(ctx, domain) | |||
| 183 | + | } | |||
| 184 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { | |||
| 185 | + | r0 = returnFunc(ctx, domain) | |||
| 186 | + | } else { | |||
| 187 | + | if ret.Get(0) != nil { | |||
| 188 | + | r0 = ret.Get(0).([]string) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 192 | + | r1 = returnFunc(ctx, domain) | |||
| 193 | + | } else { | |||
| 194 | + | r1 = ret.Error(1) | |||
| 195 | + | } | |||
| 196 | + | return r0, r1 | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // MockSecretService_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |||
| 200 | + | type MockSecretService_ListKeys_Call struct { | |||
| 201 | + | *mock.Call | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // ListKeys is a helper method to define mock.On call | |||
| 205 | + | // - ctx context.Context | |||
| 206 | + | // - domain string | |||
| 207 | + | func (_e *MockSecretService_Expecter) ListKeys(ctx interface{}, domain interface{}) *MockSecretService_ListKeys_Call { | |||
| 208 | + | return &MockSecretService_ListKeys_Call{Call: _e.mock.On("ListKeys", ctx, domain)} | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (_c *MockSecretService_ListKeys_Call) Run(run func(ctx context.Context, domain string)) *MockSecretService_ListKeys_Call { | |||
| 212 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 213 | + | var arg0 context.Context | |||
| 214 | + | if args[0] != nil { | |||
| 215 | + | arg0 = args[0].(context.Context) | |||
| 216 | + | } | |||
| 217 | + | var arg1 string | |||
| 218 | + | if args[1] != nil { | |||
| 219 | + | arg1 = args[1].(string) | |||
| 220 | + | } | |||
| 221 | + | run( | |||
| 222 | + | arg0, | |||
| 223 | + | arg1, | |||
| 224 | + | ) | |||
| 225 | + | }) | |||
| 226 | + | return _c | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | func (_c *MockSecretService_ListKeys_Call) Return(strings []string, err error) *MockSecretService_ListKeys_Call { | |||
| 230 | + | _c.Call.Return(strings, err) | |||
| 231 | + | return _c | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func (_c *MockSecretService_ListKeys_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]string, error)) *MockSecretService_ListKeys_Call { | |||
| 235 | + | _c.Call.Return(run) | |||
| 236 | + | return _c | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | // Set provides a mock function for the type MockSecretService | |||
| 240 | + | func (_mock *MockSecretService) Set(ctx context.Context, domain string, secrets map[string]string) error { | |||
| 241 | + | ret := _mock.Called(ctx, domain, secrets) | |||
| 242 | + | ||||
| 243 | + | if len(ret) == 0 { | |||
| 244 | + | panic("no return value specified for Set") | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | var r0 error | |||
| 248 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, map[string]string) error); ok { | |||
| 249 | + | r0 = returnFunc(ctx, domain, secrets) | |||
| 250 | + | } else { | |||
| 251 | + | r0 = ret.Error(0) | |||
| 252 | + | } | |||
| 253 | + | return r0 | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // MockSecretService_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |||
| 257 | + | type MockSecretService_Set_Call struct { | |||
| 258 | + | *mock.Call | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // Set is a helper method to define mock.On call | |||
| 262 | + | // - ctx context.Context | |||
| 263 | + | // - domain string | |||
| 264 | + | // - secrets map[string]string | |||
| 265 | + | func (_e *MockSecretService_Expecter) Set(ctx interface{}, domain interface{}, secrets interface{}) *MockSecretService_Set_Call { | |||
| 266 | + | return &MockSecretService_Set_Call{Call: _e.mock.On("Set", ctx, domain, secrets)} | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func (_c *MockSecretService_Set_Call) Run(run func(ctx context.Context, domain string, secrets map[string]string)) *MockSecretService_Set_Call { | |||
| 270 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 271 | + | var arg0 context.Context | |||
| 272 | + | if args[0] != nil { | |||
| 273 | + | arg0 = args[0].(context.Context) | |||
| 274 | + | } | |||
| 275 | + | var arg1 string | |||
| 276 | + | if args[1] != nil { | |||
| 277 | + | arg1 = args[1].(string) | |||
| 278 | + | } | |||
| 279 | + | var arg2 map[string]string | |||
| 280 | + | if args[2] != nil { | |||
| 281 | + | arg2 = args[2].(map[string]string) | |||
| 282 | + | } | |||
| 283 | + | run( | |||
| 284 | + | arg0, | |||
| 285 | + | arg1, | |||
| 286 | + | arg2, | |||
| 287 | + | ) | |||
| 288 | + | }) | |||
| 289 | + | return _c | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func (_c *MockSecretService_Set_Call) Return(err error) *MockSecretService_Set_Call { | |||
| 293 | + | _c.Call.Return(err) | |||
| 294 | + | return _c | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func (_c *MockSecretService_Set_Call) RunAndReturn(run func(ctx context.Context, domain string, secrets map[string]string) error) *MockSecretService_Set_Call { | |||
| 298 | + | _c.Call.Return(run) | |||
| 299 | + | return _c | |||
| 300 | + | } | |||
A internal/boundaries/in/secrets.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package in | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // SecretService defines the contract for managing domain-scoped secrets. | |
| 6 | + | // These are environment variables stored per-domain for container injection. | |
| 7 | + | type SecretService interface { | |
| 8 | + | // ListKeys returns the list of secret keys for a domain (not values). | |
| 9 | + | // Returns an error if the domain is invalid. | |
| 10 | + | ListKeys(ctx context.Context, domain string) ([]string, error) | |
| 11 | + | ||
| 12 | + | // GetAll returns all secrets for a domain as a key-value map. | |
| 13 | + | // Returns an error if the domain is invalid. | |
| 14 | + | GetAll(ctx context.Context, domain string) (map[string]string, error) | |
| 15 | + | ||
| 16 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |
| 17 | + | // Returns an error if the domain is invalid. | |
| 18 | + | Set(ctx context.Context, domain string, secrets map[string]string) error | |
| 19 | + | ||
| 20 | + | // Delete removes a specific secret key from a domain. | |
| 21 | + | // Returns an error if the domain is invalid. | |
| 22 | + | Delete(ctx context.Context, domain, key string) error | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package in | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // SecretService defines the contract for managing domain-scoped secrets. | |||
| 6 | + | // These are environment variables stored per-domain for container injection. | |||
| 7 | + | type SecretService interface { | |||
| 8 | + | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 9 | + | // Returns an error if the domain is invalid. | |||
| 10 | + | ListKeys(ctx context.Context, domain string) ([]string, error) | |||
| 11 | + | ||||
| 12 | + | // GetAll returns all secrets for a domain as a key-value map. | |||
| 13 | + | // Returns an error if the domain is invalid. | |||
| 14 | + | GetAll(ctx context.Context, domain string) (map[string]string, error) | |||
| 15 | + | ||||
| 16 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 17 | + | // Returns an error if the domain is invalid. | |||
| 18 | + | Set(ctx context.Context, domain string, secrets map[string]string) error | |||
| 19 | + | ||||
| 20 | + | // Delete removes a specific secret key from a domain. | |||
| 21 | + | // Returns an error if the domain is invalid. | |||
| 22 | + | Delete(ctx context.Context, domain, key string) error | |||
| 23 | + | } | |||
A internal/boundaries/out/domain_secrets.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | // DomainSecretStore defines the contract for managing domain-scoped secrets. | |
| 4 | + | // These are environment variables stored per-domain for container injection. | |
| 5 | + | type DomainSecretStore interface { | |
| 6 | + | // ListKeys returns the list of secret keys for a domain (not values). | |
| 7 | + | ListKeys(domain string) ([]string, error) | |
| 8 | + | ||
| 9 | + | // GetAll returns all secrets for a domain as a key-value map. | |
| 10 | + | GetAll(domain string) (map[string]string, error) | |
| 11 | + | ||
| 12 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |
| 13 | + | Set(domain string, secrets map[string]string) error | |
| 14 | + | ||
| 15 | + | // Delete removes a specific secret key from a domain. | |
| 16 | + | Delete(domain, key string) error | |
| 17 | + | } | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | // DomainSecretStore defines the contract for managing domain-scoped secrets. | |||
| 4 | + | // These are environment variables stored per-domain for container injection. | |||
| 5 | + | type DomainSecretStore interface { | |||
| 6 | + | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 7 | + | ListKeys(domain string) ([]string, error) | |||
| 8 | + | ||||
| 9 | + | // GetAll returns all secrets for a domain as a key-value map. | |||
| 10 | + | GetAll(domain string) (map[string]string, error) | |||
| 11 | + | ||||
| 12 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 13 | + | Set(domain string, secrets map[string]string) error | |||
| 14 | + | ||||
| 15 | + | // Delete removes a specific secret key from a domain. | |||
| 16 | + | Delete(domain, key string) error | |||
| 17 | + | } | |||
A internal/boundaries/out/mocks/mock_domain_secret_store.go +274 -0
| @@ -0,0 +1,274 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | mock "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // NewMockDomainSecretStore creates a new instance of MockDomainSecretStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 12 | + | // The first argument is typically a *testing.T value. | |
| 13 | + | func NewMockDomainSecretStore(t interface { | |
| 14 | + | mock.TestingT | |
| 15 | + | Cleanup(func()) | |
| 16 | + | }) *MockDomainSecretStore { | |
| 17 | + | mock := &MockDomainSecretStore{} | |
| 18 | + | mock.Mock.Test(t) | |
| 19 | + | ||
| 20 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 21 | + | ||
| 22 | + | return mock | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // MockDomainSecretStore is an autogenerated mock type for the DomainSecretStore type | |
| 26 | + | type MockDomainSecretStore struct { | |
| 27 | + | mock.Mock | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type MockDomainSecretStore_Expecter struct { | |
| 31 | + | mock *mock.Mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (_m *MockDomainSecretStore) EXPECT() *MockDomainSecretStore_Expecter { | |
| 35 | + | return &MockDomainSecretStore_Expecter{mock: &_m.Mock} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Delete provides a mock function for the type MockDomainSecretStore | |
| 39 | + | func (_mock *MockDomainSecretStore) Delete(domain string, key string) error { | |
| 40 | + | ret := _mock.Called(domain, key) | |
| 41 | + | ||
| 42 | + | if len(ret) == 0 { | |
| 43 | + | panic("no return value specified for Delete") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | var r0 error | |
| 47 | + | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |
| 48 | + | r0 = returnFunc(domain, key) | |
| 49 | + | } else { | |
| 50 | + | r0 = ret.Error(0) | |
| 51 | + | } | |
| 52 | + | return r0 | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // MockDomainSecretStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 56 | + | type MockDomainSecretStore_Delete_Call struct { | |
| 57 | + | *mock.Call | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Delete is a helper method to define mock.On call | |
| 61 | + | // - domain string | |
| 62 | + | // - key string | |
| 63 | + | func (_e *MockDomainSecretStore_Expecter) Delete(domain interface{}, key interface{}) *MockDomainSecretStore_Delete_Call { | |
| 64 | + | return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)} | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (_c *MockDomainSecretStore_Delete_Call) Run(run func(domain string, key string)) *MockDomainSecretStore_Delete_Call { | |
| 68 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 69 | + | var arg0 string | |
| 70 | + | if args[0] != nil { | |
| 71 | + | arg0 = args[0].(string) | |
| 72 | + | } | |
| 73 | + | var arg1 string | |
| 74 | + | if args[1] != nil { | |
| 75 | + | arg1 = args[1].(string) | |
| 76 | + | } | |
| 77 | + | run( | |
| 78 | + | arg0, | |
| 79 | + | arg1, | |
| 80 | + | ) | |
| 81 | + | }) | |
| 82 | + | return _c | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (_c *MockDomainSecretStore_Delete_Call) Return(err error) *MockDomainSecretStore_Delete_Call { | |
| 86 | + | _c.Call.Return(err) | |
| 87 | + | return _c | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string, key string) error) *MockDomainSecretStore_Delete_Call { | |
| 91 | + | _c.Call.Return(run) | |
| 92 | + | return _c | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // GetAll provides a mock function for the type MockDomainSecretStore | |
| 96 | + | func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) { | |
| 97 | + | ret := _mock.Called(domain) | |
| 98 | + | ||
| 99 | + | if len(ret) == 0 { | |
| 100 | + | panic("no return value specified for GetAll") | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | var r0 map[string]string | |
| 104 | + | var r1 error | |
| 105 | + | if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok { | |
| 106 | + | return returnFunc(domain) | |
| 107 | + | } | |
| 108 | + | if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok { | |
| 109 | + | r0 = returnFunc(domain) | |
| 110 | + | } else { | |
| 111 | + | if ret.Get(0) != nil { | |
| 112 | + | r0 = ret.Get(0).(map[string]string) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 116 | + | r1 = returnFunc(domain) | |
| 117 | + | } else { | |
| 118 | + | r1 = ret.Error(1) | |
| 119 | + | } | |
| 120 | + | return r0, r1 | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // MockDomainSecretStore_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |
| 124 | + | type MockDomainSecretStore_GetAll_Call struct { | |
| 125 | + | *mock.Call | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // GetAll is a helper method to define mock.On call | |
| 129 | + | // - domain string | |
| 130 | + | func (_e *MockDomainSecretStore_Expecter) GetAll(domain interface{}) *MockDomainSecretStore_GetAll_Call { | |
| 131 | + | return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)} | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (_c *MockDomainSecretStore_GetAll_Call) Run(run func(domain string)) *MockDomainSecretStore_GetAll_Call { | |
| 135 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 136 | + | var arg0 string | |
| 137 | + | if args[0] != nil { | |
| 138 | + | arg0 = args[0].(string) | |
| 139 | + | } | |
| 140 | + | run( | |
| 141 | + | arg0, | |
| 142 | + | ) | |
| 143 | + | }) | |
| 144 | + | return _c | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (_c *MockDomainSecretStore_GetAll_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAll_Call { | |
| 148 | + | _c.Call.Return(stringToString, err) | |
| 149 | + | return _c | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string) (map[string]string, error)) *MockDomainSecretStore_GetAll_Call { | |
| 153 | + | _c.Call.Return(run) | |
| 154 | + | return _c | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // ListKeys provides a mock function for the type MockDomainSecretStore | |
| 158 | + | func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) { | |
| 159 | + | ret := _mock.Called(domain) | |
| 160 | + | ||
| 161 | + | if len(ret) == 0 { | |
| 162 | + | panic("no return value specified for ListKeys") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | var r0 []string | |
| 166 | + | var r1 error | |
| 167 | + | if returnFunc, ok := ret.Get(0).(func(string) ([]string, error)); ok { | |
| 168 | + | return returnFunc(domain) | |
| 169 | + | } | |
| 170 | + | if returnFunc, ok := ret.Get(0).(func(string) []string); ok { | |
| 171 | + | r0 = returnFunc(domain) | |
| 172 | + | } else { | |
| 173 | + | if ret.Get(0) != nil { | |
| 174 | + | r0 = ret.Get(0).([]string) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 178 | + | r1 = returnFunc(domain) | |
| 179 | + | } else { | |
| 180 | + | r1 = ret.Error(1) | |
| 181 | + | } | |
| 182 | + | return r0, r1 | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // MockDomainSecretStore_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |
| 186 | + | type MockDomainSecretStore_ListKeys_Call struct { | |
| 187 | + | *mock.Call | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // ListKeys is a helper method to define mock.On call | |
| 191 | + | // - domain string | |
| 192 | + | func (_e *MockDomainSecretStore_Expecter) ListKeys(domain interface{}) *MockDomainSecretStore_ListKeys_Call { | |
| 193 | + | return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)} | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (_c *MockDomainSecretStore_ListKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListKeys_Call { | |
| 197 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 198 | + | var arg0 string | |
| 199 | + | if args[0] != nil { | |
| 200 | + | arg0 = args[0].(string) | |
| 201 | + | } | |
| 202 | + | run( | |
| 203 | + | arg0, | |
| 204 | + | ) | |
| 205 | + | }) | |
| 206 | + | return _c | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (_c *MockDomainSecretStore_ListKeys_Call) Return(strings []string, err error) *MockDomainSecretStore_ListKeys_Call { | |
| 210 | + | _c.Call.Return(strings, err) | |
| 211 | + | return _c | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func (_c *MockDomainSecretStore_ListKeys_Call) RunAndReturn(run func(domain string) ([]string, error)) *MockDomainSecretStore_ListKeys_Call { | |
| 215 | + | _c.Call.Return(run) | |
| 216 | + | return _c | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // Set provides a mock function for the type MockDomainSecretStore | |
| 220 | + | func (_mock *MockDomainSecretStore) Set(domain string, secrets map[string]string) error { | |
| 221 | + | ret := _mock.Called(domain, secrets) | |
| 222 | + | ||
| 223 | + | if len(ret) == 0 { | |
| 224 | + | panic("no return value specified for Set") | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | var r0 error | |
| 228 | + | if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok { | |
| 229 | + | r0 = returnFunc(domain, secrets) | |
| 230 | + | } else { | |
| 231 | + | r0 = ret.Error(0) | |
| 232 | + | } | |
| 233 | + | return r0 | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // MockDomainSecretStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |
| 237 | + | type MockDomainSecretStore_Set_Call struct { | |
| 238 | + | *mock.Call | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // Set is a helper method to define mock.On call | |
| 242 | + | // - domain string | |
| 243 | + | // - secrets map[string]string | |
| 244 | + | func (_e *MockDomainSecretStore_Expecter) Set(domain interface{}, secrets interface{}) *MockDomainSecretStore_Set_Call { | |
| 245 | + | return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)} | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (_c *MockDomainSecretStore_Set_Call) Run(run func(domain string, secrets map[string]string)) *MockDomainSecretStore_Set_Call { | |
| 249 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 250 | + | var arg0 string | |
| 251 | + | if args[0] != nil { | |
| 252 | + | arg0 = args[0].(string) | |
| 253 | + | } | |
| 254 | + | var arg1 map[string]string | |
| 255 | + | if args[1] != nil { | |
| 256 | + | arg1 = args[1].(map[string]string) | |
| 257 | + | } | |
| 258 | + | run( | |
| 259 | + | arg0, | |
| 260 | + | arg1, | |
| 261 | + | ) | |
| 262 | + | }) | |
| 263 | + | return _c | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func (_c *MockDomainSecretStore_Set_Call) Return(err error) *MockDomainSecretStore_Set_Call { | |
| 267 | + | _c.Call.Return(err) | |
| 268 | + | return _c | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, secrets map[string]string) error) *MockDomainSecretStore_Set_Call { | |
| 272 | + | _c.Call.Return(run) | |
| 273 | + | return _c | |
| 274 | + | } | |
| @@ -0,0 +1,274 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | mock "github.com/stretchr/testify/mock" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // NewMockDomainSecretStore creates a new instance of MockDomainSecretStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 12 | + | // The first argument is typically a *testing.T value. | |||
| 13 | + | func NewMockDomainSecretStore(t interface { | |||
| 14 | + | mock.TestingT | |||
| 15 | + | Cleanup(func()) | |||
| 16 | + | }) *MockDomainSecretStore { | |||
| 17 | + | mock := &MockDomainSecretStore{} | |||
| 18 | + | mock.Mock.Test(t) | |||
| 19 | + | ||||
| 20 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 21 | + | ||||
| 22 | + | return mock | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // MockDomainSecretStore is an autogenerated mock type for the DomainSecretStore type | |||
| 26 | + | type MockDomainSecretStore struct { | |||
| 27 | + | mock.Mock | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type MockDomainSecretStore_Expecter struct { | |||
| 31 | + | mock *mock.Mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (_m *MockDomainSecretStore) EXPECT() *MockDomainSecretStore_Expecter { | |||
| 35 | + | return &MockDomainSecretStore_Expecter{mock: &_m.Mock} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Delete provides a mock function for the type MockDomainSecretStore | |||
| 39 | + | func (_mock *MockDomainSecretStore) Delete(domain string, key string) error { | |||
| 40 | + | ret := _mock.Called(domain, key) | |||
| 41 | + | ||||
| 42 | + | if len(ret) == 0 { | |||
| 43 | + | panic("no return value specified for Delete") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | var r0 error | |||
| 47 | + | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |||
| 48 | + | r0 = returnFunc(domain, key) | |||
| 49 | + | } else { | |||
| 50 | + | r0 = ret.Error(0) | |||
| 51 | + | } | |||
| 52 | + | return r0 | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // MockDomainSecretStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 56 | + | type MockDomainSecretStore_Delete_Call struct { | |||
| 57 | + | *mock.Call | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Delete is a helper method to define mock.On call | |||
| 61 | + | // - domain string | |||
| 62 | + | // - key string | |||
| 63 | + | func (_e *MockDomainSecretStore_Expecter) Delete(domain interface{}, key interface{}) *MockDomainSecretStore_Delete_Call { | |||
| 64 | + | return &MockDomainSecretStore_Delete_Call{Call: _e.mock.On("Delete", domain, key)} | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (_c *MockDomainSecretStore_Delete_Call) Run(run func(domain string, key string)) *MockDomainSecretStore_Delete_Call { | |||
| 68 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 69 | + | var arg0 string | |||
| 70 | + | if args[0] != nil { | |||
| 71 | + | arg0 = args[0].(string) | |||
| 72 | + | } | |||
| 73 | + | var arg1 string | |||
| 74 | + | if args[1] != nil { | |||
| 75 | + | arg1 = args[1].(string) | |||
| 76 | + | } | |||
| 77 | + | run( | |||
| 78 | + | arg0, | |||
| 79 | + | arg1, | |||
| 80 | + | ) | |||
| 81 | + | }) | |||
| 82 | + | return _c | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (_c *MockDomainSecretStore_Delete_Call) Return(err error) *MockDomainSecretStore_Delete_Call { | |||
| 86 | + | _c.Call.Return(err) | |||
| 87 | + | return _c | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (_c *MockDomainSecretStore_Delete_Call) RunAndReturn(run func(domain string, key string) error) *MockDomainSecretStore_Delete_Call { | |||
| 91 | + | _c.Call.Return(run) | |||
| 92 | + | return _c | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // GetAll provides a mock function for the type MockDomainSecretStore | |||
| 96 | + | func (_mock *MockDomainSecretStore) GetAll(domain string) (map[string]string, error) { | |||
| 97 | + | ret := _mock.Called(domain) | |||
| 98 | + | ||||
| 99 | + | if len(ret) == 0 { | |||
| 100 | + | panic("no return value specified for GetAll") | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | var r0 map[string]string | |||
| 104 | + | var r1 error | |||
| 105 | + | if returnFunc, ok := ret.Get(0).(func(string) (map[string]string, error)); ok { | |||
| 106 | + | return returnFunc(domain) | |||
| 107 | + | } | |||
| 108 | + | if returnFunc, ok := ret.Get(0).(func(string) map[string]string); ok { | |||
| 109 | + | r0 = returnFunc(domain) | |||
| 110 | + | } else { | |||
| 111 | + | if ret.Get(0) != nil { | |||
| 112 | + | r0 = ret.Get(0).(map[string]string) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 116 | + | r1 = returnFunc(domain) | |||
| 117 | + | } else { | |||
| 118 | + | r1 = ret.Error(1) | |||
| 119 | + | } | |||
| 120 | + | return r0, r1 | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // MockDomainSecretStore_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll' | |||
| 124 | + | type MockDomainSecretStore_GetAll_Call struct { | |||
| 125 | + | *mock.Call | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // GetAll is a helper method to define mock.On call | |||
| 129 | + | // - domain string | |||
| 130 | + | func (_e *MockDomainSecretStore_Expecter) GetAll(domain interface{}) *MockDomainSecretStore_GetAll_Call { | |||
| 131 | + | return &MockDomainSecretStore_GetAll_Call{Call: _e.mock.On("GetAll", domain)} | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (_c *MockDomainSecretStore_GetAll_Call) Run(run func(domain string)) *MockDomainSecretStore_GetAll_Call { | |||
| 135 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 136 | + | var arg0 string | |||
| 137 | + | if args[0] != nil { | |||
| 138 | + | arg0 = args[0].(string) | |||
| 139 | + | } | |||
| 140 | + | run( | |||
| 141 | + | arg0, | |||
| 142 | + | ) | |||
| 143 | + | }) | |||
| 144 | + | return _c | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func (_c *MockDomainSecretStore_GetAll_Call) Return(stringToString map[string]string, err error) *MockDomainSecretStore_GetAll_Call { | |||
| 148 | + | _c.Call.Return(stringToString, err) | |||
| 149 | + | return _c | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func (_c *MockDomainSecretStore_GetAll_Call) RunAndReturn(run func(domain string) (map[string]string, error)) *MockDomainSecretStore_GetAll_Call { | |||
| 153 | + | _c.Call.Return(run) | |||
| 154 | + | return _c | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // ListKeys provides a mock function for the type MockDomainSecretStore | |||
| 158 | + | func (_mock *MockDomainSecretStore) ListKeys(domain string) ([]string, error) { | |||
| 159 | + | ret := _mock.Called(domain) | |||
| 160 | + | ||||
| 161 | + | if len(ret) == 0 { | |||
| 162 | + | panic("no return value specified for ListKeys") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | var r0 []string | |||
| 166 | + | var r1 error | |||
| 167 | + | if returnFunc, ok := ret.Get(0).(func(string) ([]string, error)); ok { | |||
| 168 | + | return returnFunc(domain) | |||
| 169 | + | } | |||
| 170 | + | if returnFunc, ok := ret.Get(0).(func(string) []string); ok { | |||
| 171 | + | r0 = returnFunc(domain) | |||
| 172 | + | } else { | |||
| 173 | + | if ret.Get(0) != nil { | |||
| 174 | + | r0 = ret.Get(0).([]string) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |||
| 178 | + | r1 = returnFunc(domain) | |||
| 179 | + | } else { | |||
| 180 | + | r1 = ret.Error(1) | |||
| 181 | + | } | |||
| 182 | + | return r0, r1 | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // MockDomainSecretStore_ListKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListKeys' | |||
| 186 | + | type MockDomainSecretStore_ListKeys_Call struct { | |||
| 187 | + | *mock.Call | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // ListKeys is a helper method to define mock.On call | |||
| 191 | + | // - domain string | |||
| 192 | + | func (_e *MockDomainSecretStore_Expecter) ListKeys(domain interface{}) *MockDomainSecretStore_ListKeys_Call { | |||
| 193 | + | return &MockDomainSecretStore_ListKeys_Call{Call: _e.mock.On("ListKeys", domain)} | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (_c *MockDomainSecretStore_ListKeys_Call) Run(run func(domain string)) *MockDomainSecretStore_ListKeys_Call { | |||
| 197 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 198 | + | var arg0 string | |||
| 199 | + | if args[0] != nil { | |||
| 200 | + | arg0 = args[0].(string) | |||
| 201 | + | } | |||
| 202 | + | run( | |||
| 203 | + | arg0, | |||
| 204 | + | ) | |||
| 205 | + | }) | |||
| 206 | + | return _c | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (_c *MockDomainSecretStore_ListKeys_Call) Return(strings []string, err error) *MockDomainSecretStore_ListKeys_Call { | |||
| 210 | + | _c.Call.Return(strings, err) | |||
| 211 | + | return _c | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func (_c *MockDomainSecretStore_ListKeys_Call) RunAndReturn(run func(domain string) ([]string, error)) *MockDomainSecretStore_ListKeys_Call { | |||
| 215 | + | _c.Call.Return(run) | |||
| 216 | + | return _c | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // Set provides a mock function for the type MockDomainSecretStore | |||
| 220 | + | func (_mock *MockDomainSecretStore) Set(domain string, secrets map[string]string) error { | |||
| 221 | + | ret := _mock.Called(domain, secrets) | |||
| 222 | + | ||||
| 223 | + | if len(ret) == 0 { | |||
| 224 | + | panic("no return value specified for Set") | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | var r0 error | |||
| 228 | + | if returnFunc, ok := ret.Get(0).(func(string, map[string]string) error); ok { | |||
| 229 | + | r0 = returnFunc(domain, secrets) | |||
| 230 | + | } else { | |||
| 231 | + | r0 = ret.Error(0) | |||
| 232 | + | } | |||
| 233 | + | return r0 | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // MockDomainSecretStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set' | |||
| 237 | + | type MockDomainSecretStore_Set_Call struct { | |||
| 238 | + | *mock.Call | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // Set is a helper method to define mock.On call | |||
| 242 | + | // - domain string | |||
| 243 | + | // - secrets map[string]string | |||
| 244 | + | func (_e *MockDomainSecretStore_Expecter) Set(domain interface{}, secrets interface{}) *MockDomainSecretStore_Set_Call { | |||
| 245 | + | return &MockDomainSecretStore_Set_Call{Call: _e.mock.On("Set", domain, secrets)} | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (_c *MockDomainSecretStore_Set_Call) Run(run func(domain string, secrets map[string]string)) *MockDomainSecretStore_Set_Call { | |||
| 249 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 250 | + | var arg0 string | |||
| 251 | + | if args[0] != nil { | |||
| 252 | + | arg0 = args[0].(string) | |||
| 253 | + | } | |||
| 254 | + | var arg1 map[string]string | |||
| 255 | + | if args[1] != nil { | |||
| 256 | + | arg1 = args[1].(map[string]string) | |||
| 257 | + | } | |||
| 258 | + | run( | |||
| 259 | + | arg0, | |||
| 260 | + | arg1, | |||
| 261 | + | ) | |||
| 262 | + | }) | |||
| 263 | + | return _c | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func (_c *MockDomainSecretStore_Set_Call) Return(err error) *MockDomainSecretStore_Set_Call { | |||
| 267 | + | _c.Call.Return(err) | |||
| 268 | + | return _c | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (_c *MockDomainSecretStore_Set_Call) RunAndReturn(run func(domain string, secrets map[string]string) error) *MockDomainSecretStore_Set_Call { | |||
| 272 | + | _c.Call.Return(run) | |||
| 273 | + | return _c | |||
| 274 | + | } | |||
A internal/usecase/secrets/service.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 1 | + | // Package secrets implements the secret management use case. | |
| 2 | + | package secrets | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | ||
| 11 | + | "gordon/internal/boundaries/out" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Domain validation errors. | |
| 15 | + | var ( | |
| 16 | + | ErrDomainEmpty = errors.New("domain cannot be empty") | |
| 17 | + | ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters") | |
| 18 | + | ErrDomainPathTraversal = errors.New("domain contains path traversal sequence") | |
| 19 | + | ErrDomainInvalidChars = errors.New("domain contains invalid characters") | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // Service implements the SecretService interface. | |
| 23 | + | type Service struct { | |
| 24 | + | store out.DomainSecretStore | |
| 25 | + | log zerowrap.Logger | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // NewService creates a new secrets service. | |
| 29 | + | func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service { | |
| 30 | + | return &Service{ | |
| 31 | + | store: store, | |
| 32 | + | log: log, | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // ListKeys returns the list of secret keys for a domain (not values). | |
| 37 | + | func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) { | |
| 38 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 39 | + | zerowrap.FieldLayer: "usecase", | |
| 40 | + | zerowrap.FieldUseCase: "ListKeys", | |
| 41 | + | "domain": domain, | |
| 42 | + | }) | |
| 43 | + | log := zerowrap.FromCtx(ctx) | |
| 44 | + | ||
| 45 | + | if err := ValidateDomain(domain); err != nil { | |
| 46 | + | log.Warn().Err(err).Msg("domain validation failed") | |
| 47 | + | return nil, err | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | keys, err := s.store.ListKeys(domain) | |
| 51 | + | if err != nil { | |
| 52 | + | log.Error().Err(err).Msg("failed to list secret keys") | |
| 53 | + | return nil, err | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | log.Debug().Int("count", len(keys)).Msg("listed secret keys") | |
| 57 | + | return keys, nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // GetAll returns all secrets for a domain as a key-value map. | |
| 61 | + | func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |
| 62 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 63 | + | zerowrap.FieldLayer: "usecase", | |
| 64 | + | zerowrap.FieldUseCase: "GetAll", | |
| 65 | + | "domain": domain, | |
| 66 | + | }) | |
| 67 | + | log := zerowrap.FromCtx(ctx) | |
| 68 | + | ||
| 69 | + | if err := ValidateDomain(domain); err != nil { | |
| 70 | + | log.Warn().Err(err).Msg("domain validation failed") | |
| 71 | + | return nil, err | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | secrets, err := s.store.GetAll(domain) | |
| 75 | + | if err != nil { | |
| 76 | + | log.Error().Err(err).Msg("failed to get secrets") | |
| 77 | + | return nil, err | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | log.Debug().Int("count", len(secrets)).Msg("retrieved secrets") | |
| 81 | + | return secrets, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |
| 85 | + | func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error { | |
| 86 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 87 | + | zerowrap.FieldLayer: "usecase", | |
| 88 | + | zerowrap.FieldUseCase: "Set", | |
| 89 | + | "domain": domain, | |
| 90 | + | }) | |
| 91 | + | log := zerowrap.FromCtx(ctx) | |
| 92 | + | ||
| 93 | + | if err := ValidateDomain(domain); err != nil { | |
| 94 | + | log.Warn().Err(err).Msg("domain validation failed") | |
| 95 | + | return err | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | if err := s.store.Set(domain, secrets); err != nil { | |
| 99 | + | log.Error().Err(err).Msg("failed to set secrets") | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | log.Info().Int("count", len(secrets)).Msg("secrets set") | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Delete removes a specific secret key from a domain. | |
| 108 | + | func (s *Service) Delete(ctx context.Context, domain, key string) error { | |
| 109 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 110 | + | zerowrap.FieldLayer: "usecase", | |
| 111 | + | zerowrap.FieldUseCase: "Delete", | |
| 112 | + | "domain": domain, | |
| 113 | + | "key": key, | |
| 114 | + | }) | |
| 115 | + | log := zerowrap.FromCtx(ctx) | |
| 116 | + | ||
| 117 | + | if err := ValidateDomain(domain); err != nil { | |
| 118 | + | log.Warn().Err(err).Msg("domain validation failed") | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | if err := s.store.Delete(domain, key); err != nil { | |
| 123 | + | log.Error().Err(err).Msg("failed to delete secret") | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | log.Info().Msg("secret deleted") | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // ValidateDomain validates that a domain is safe to use for secret storage. | |
| 132 | + | // This prevents path traversal attacks and ensures the domain is valid. | |
| 133 | + | func ValidateDomain(domain string) error { | |
| 134 | + | // Check for empty domain | |
| 135 | + | if domain == "" { | |
| 136 | + | return ErrDomainEmpty | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Check domain length (max DNS name length is 253) | |
| 140 | + | if len(domain) > 253 { | |
| 141 | + | return ErrDomainTooLong | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Check for path traversal attempts | |
| 145 | + | if strings.Contains(domain, "..") { | |
| 146 | + | return ErrDomainPathTraversal | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Check for null bytes (can cause issues in file paths) | |
| 150 | + | if strings.ContainsRune(domain, '\x00') { | |
| 151 | + | return ErrDomainInvalidChars | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | return nil | |
| 155 | + | } | |
| @@ -0,0 +1,155 @@ | |||||
| 1 | + | // Package secrets implements the secret management use case. | |||
| 2 | + | package secrets | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | ||||
| 11 | + | "gordon/internal/boundaries/out" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Domain validation errors. | |||
| 15 | + | var ( | |||
| 16 | + | ErrDomainEmpty = errors.New("domain cannot be empty") | |||
| 17 | + | ErrDomainTooLong = errors.New("domain exceeds maximum length of 253 characters") | |||
| 18 | + | ErrDomainPathTraversal = errors.New("domain contains path traversal sequence") | |||
| 19 | + | ErrDomainInvalidChars = errors.New("domain contains invalid characters") | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // Service implements the SecretService interface. | |||
| 23 | + | type Service struct { | |||
| 24 | + | store out.DomainSecretStore | |||
| 25 | + | log zerowrap.Logger | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // NewService creates a new secrets service. | |||
| 29 | + | func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service { | |||
| 30 | + | return &Service{ | |||
| 31 | + | store: store, | |||
| 32 | + | log: log, | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // ListKeys returns the list of secret keys for a domain (not values). | |||
| 37 | + | func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) { | |||
| 38 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 39 | + | zerowrap.FieldLayer: "usecase", | |||
| 40 | + | zerowrap.FieldUseCase: "ListKeys", | |||
| 41 | + | "domain": domain, | |||
| 42 | + | }) | |||
| 43 | + | log := zerowrap.FromCtx(ctx) | |||
| 44 | + | ||||
| 45 | + | if err := ValidateDomain(domain); err != nil { | |||
| 46 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 47 | + | return nil, err | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | keys, err := s.store.ListKeys(domain) | |||
| 51 | + | if err != nil { | |||
| 52 | + | log.Error().Err(err).Msg("failed to list secret keys") | |||
| 53 | + | return nil, err | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | log.Debug().Int("count", len(keys)).Msg("listed secret keys") | |||
| 57 | + | return keys, nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // GetAll returns all secrets for a domain as a key-value map. | |||
| 61 | + | func (s *Service) GetAll(ctx context.Context, domain string) (map[string]string, error) { | |||
| 62 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 63 | + | zerowrap.FieldLayer: "usecase", | |||
| 64 | + | zerowrap.FieldUseCase: "GetAll", | |||
| 65 | + | "domain": domain, | |||
| 66 | + | }) | |||
| 67 | + | log := zerowrap.FromCtx(ctx) | |||
| 68 | + | ||||
| 69 | + | if err := ValidateDomain(domain); err != nil { | |||
| 70 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 71 | + | return nil, err | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | secrets, err := s.store.GetAll(domain) | |||
| 75 | + | if err != nil { | |||
| 76 | + | log.Error().Err(err).Msg("failed to get secrets") | |||
| 77 | + | return nil, err | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | log.Debug().Int("count", len(secrets)).Msg("retrieved secrets") | |||
| 81 | + | return secrets, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Set sets or updates multiple secrets for a domain, merging with existing. | |||
| 85 | + | func (s *Service) Set(ctx context.Context, domain string, secrets map[string]string) error { | |||
| 86 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 87 | + | zerowrap.FieldLayer: "usecase", | |||
| 88 | + | zerowrap.FieldUseCase: "Set", | |||
| 89 | + | "domain": domain, | |||
| 90 | + | }) | |||
| 91 | + | log := zerowrap.FromCtx(ctx) | |||
| 92 | + | ||||
| 93 | + | if err := ValidateDomain(domain); err != nil { | |||
| 94 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 95 | + | return err | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | if err := s.store.Set(domain, secrets); err != nil { | |||
| 99 | + | log.Error().Err(err).Msg("failed to set secrets") | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | log.Info().Int("count", len(secrets)).Msg("secrets set") | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Delete removes a specific secret key from a domain. | |||
| 108 | + | func (s *Service) Delete(ctx context.Context, domain, key string) error { | |||
| 109 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 110 | + | zerowrap.FieldLayer: "usecase", | |||
| 111 | + | zerowrap.FieldUseCase: "Delete", | |||
| 112 | + | "domain": domain, | |||
| 113 | + | "key": key, | |||
| 114 | + | }) | |||
| 115 | + | log := zerowrap.FromCtx(ctx) | |||
| 116 | + | ||||
| 117 | + | if err := ValidateDomain(domain); err != nil { | |||
| 118 | + | log.Warn().Err(err).Msg("domain validation failed") | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | if err := s.store.Delete(domain, key); err != nil { | |||
| 123 | + | log.Error().Err(err).Msg("failed to delete secret") | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | log.Info().Msg("secret deleted") | |||
| 128 | + | return nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | // ValidateDomain validates that a domain is safe to use for secret storage. | |||
| 132 | + | // This prevents path traversal attacks and ensures the domain is valid. | |||
| 133 | + | func ValidateDomain(domain string) error { | |||
| 134 | + | // Check for empty domain | |||
| 135 | + | if domain == "" { | |||
| 136 | + | return ErrDomainEmpty | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Check domain length (max DNS name length is 253) | |||
| 140 | + | if len(domain) > 253 { | |||
| 141 | + | return ErrDomainTooLong | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // Check for path traversal attempts | |||
| 145 | + | if strings.Contains(domain, "..") { | |||
| 146 | + | return ErrDomainPathTraversal | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Check for null bytes (can cause issues in file paths) | |||
| 150 | + | if strings.ContainsRune(domain, '\x00') { | |||
| 151 | + | return ErrDomainInvalidChars | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | return nil | |||
| 155 | + | } | |||
A internal/usecase/secrets/service_test.go +358 -0
| @@ -0,0 +1,358 @@ | |||
| 1 | + | package secrets | |
| 2 | + | ||
| 3 | + | import ( | |
| 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 | + | ||
| 15 | + | func testLogger() zerowrap.Logger { | |
| 16 | + | return zerowrap.Default() | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func 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 | + | ||
| 124 | + | func 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 | + | ||
| 159 | + | func 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 | + | ||
| 171 | + | func 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 | + | ||
| 201 | + | func 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 | + | ||
| 213 | + | func 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 | + | ||
| 244 | + | func 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 | + | ||
| 255 | + | func 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 | + | ||
| 284 | + | func 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 | + | ||
| 294 | + | func 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. | |
| 307 | + | func 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. | |
| 337 | + | func 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 | + | } | |
| @@ -0,0 +1,358 @@ | |||||
| 1 | + | package secrets | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 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 | + | ||||
| 15 | + | func testLogger() zerowrap.Logger { | |||
| 16 | + | return zerowrap.Default() | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func 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 | + | ||||
| 124 | + | func 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 | + | ||||
| 159 | + | func 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 | + | ||||
| 171 | + | func 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 | + | ||||
| 201 | + | func 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 | + | ||||
| 213 | + | func 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 | + | ||||
| 244 | + | func 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 | + | ||||
| 255 | + | func 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 | + | ||||
| 284 | + | func 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 | + | ||||
| 294 | + | func 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. | |||
| 307 | + | func 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. | |||
| 337 | + | func 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 | + | } | |||