detached 6084b30

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
6084b307452f6875f53abb8dc7f54f3338554669
author
bnema · · 2026-01-16 22:54 UTC
parent
2d10d35
signature
unsigned
11 files · +1257 -103
M .mockery.yaml +2 -0
@@ -20,6 +20,7 @@ packages:
2020 EnvLoader:
2121 ContainerLogWriter:
2222 TokenStore:
23+ DomainSecretStore:
2324 gordon/internal/boundaries/in:
2425 interfaces:
2526 ContainerService:
@@ -29,3 +30,4 @@ packages:
2930 AuthService:
3031 HealthService:
3132 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 {
2222 authSvc in.AuthService
2323 containerSvc in.ContainerService
2424 healthSvc in.HealthService
25+ secretSvc in.SecretService
2526 eventBus out.EventPublisher
26- envDir string
2727 log zerowrap.Logger
2828 }
2929
@@ -33,8 +33,8 @@ func NewHandler(
3333 authSvc in.AuthService,
3434 containerSvc in.ContainerService,
3535 healthSvc in.HealthService,
36+ secretSvc in.SecretService,
3637 eventBus out.EventPublisher,
37- envDir string,
3838 log zerowrap.Logger,
3939 ) *Handler {
4040 return &Handler{
@@ -42,8 +42,8 @@ func NewHandler(
4242 authSvc: authSvc,
4343 containerSvc: containerSvc,
4444 healthSvc: healthSvc,
45+ secretSvc: secretSvc,
4546 eventBus: eventBus,
46- envDir: envDir,
4747 log: log,
4848 }
4949 }
@@ -273,13 +273,13 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
273273 return
274274 }
275275 // List secrets for domain (names only, not values)
276- secrets, err := h.listSecrets(secretDomain)
276+ keys, err := h.secretSvc.ListKeys(ctx, secretDomain)
277277 if err != nil {
278278 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")
280280 return
281281 }
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})
283283
284284 case http.MethodPost:
285285 // Check write permission
@@ -299,9 +299,9 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
299299 return
300300 }
301301
302- if err := h.setSecrets(secretDomain, data); err != nil {
302+ if err := h.secretSvc.Set(ctx, secretDomain, data); err != nil {
303303 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")
305305 return
306306 }
307307
@@ -319,9 +319,9 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
319319 return
320320 }
321321
322- if err := h.deleteSecret(secretDomain, secretKey); err != nil {
322+ if err := h.secretSvc.Delete(ctx, secretDomain, secretKey); err != nil {
323323 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")
325325 return
326326 }
327327
@@ -22,8 +22,8 @@ type Handler struct {
22 authSvc in.AuthService22 authSvc in.AuthService
23 containerSvc in.ContainerService23 containerSvc in.ContainerService
24 healthSvc in.HealthService24 healthSvc in.HealthService
25+ secretSvc in.SecretService
25 eventBus out.EventPublisher26 eventBus out.EventPublisher
26- envDir string
27 log zerowrap.Logger27 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 return273 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 return280 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 permission285 // Check write permission
@@ -299,9 +299,9 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
299 return299 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 return305 return
306 }306 }
307 307
@@ -319,9 +319,9 @@ func (h *Handler) handleSecrets(w http.ResponseWriter, r *http.Request, path str
319 return319 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 return325 return
326 }326 }
327 327
M internal/adapters/in/http/admin/handler_test.go +52 -33
@@ -6,8 +6,6 @@ import (
66 "encoding/json"
77 "net/http"
88 "net/http/httptest"
9- "os"
10- "path/filepath"
119 "testing"
1210
1311 "github.com/bnema/zerowrap"
@@ -33,8 +31,9 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
3331 configSvc := inmocks.NewMockConfigService(t)
3432 authSvc := inmocks.NewMockAuthService(t)
3533 containerSvc := inmocks.NewMockContainerService(t)
34+ secretSvc := inmocks.NewMockSecretService(t)
3635
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())
3837
3938 tests := []struct {
4039 name string
@@ -94,8 +93,9 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
9493 configSvc := inmocks.NewMockConfigService(t)
9594 authSvc := inmocks.NewMockAuthService(t)
9695 containerSvc := inmocks.NewMockContainerService(t)
96+ secretSvc := inmocks.NewMockSecretService(t)
9797
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())
9999
100100 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101101
@@ -152,8 +152,9 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
152152 configSvc := inmocks.NewMockConfigService(t)
153153 authSvc := inmocks.NewMockAuthService(t)
154154 containerSvc := inmocks.NewMockContainerService(t)
155+ secretSvc := inmocks.NewMockSecretService(t)
155156
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())
157158
158159 routeJSON := `{"image": "myapp:v2"}`
159160
@@ -195,8 +196,9 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
195196 configSvc := inmocks.NewMockConfigService(t)
196197 authSvc := inmocks.NewMockAuthService(t)
197198 containerSvc := inmocks.NewMockContainerService(t)
199+ secretSvc := inmocks.NewMockSecretService(t)
198200
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())
200202
201203 tests := []struct {
202204 name string
@@ -238,11 +240,9 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
238240 configSvc := inmocks.NewMockConfigService(t)
239241 authSvc := inmocks.NewMockAuthService(t)
240242 containerSvc := inmocks.NewMockContainerService(t)
243+ secretSvc := inmocks.NewMockSecretService(t)
241244
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())
246246
247247 tests := []struct {
248248 name string
@@ -278,6 +278,10 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
278278
279279 for _, tt := range tests {
280280 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+
281285 req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
282286 req = req.WithContext(ctxWithScopes(tt.scopes...))
283287 rec := httptest.NewRecorder()
@@ -293,11 +297,9 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
293297 configSvc := inmocks.NewMockConfigService(t)
294298 authSvc := inmocks.NewMockAuthService(t)
295299 containerSvc := inmocks.NewMockContainerService(t)
300+ secretSvc := inmocks.NewMockSecretService(t)
296301
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())
301303
302304 secretsJSON := `{"API_KEY": "secret123"}`
303305
@@ -325,6 +327,10 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
325327
326328 for _, tt := range tests {
327329 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+
328334 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
329335 req = req.WithContext(ctxWithScopes(tt.scopes...))
330336 rec := httptest.NewRecorder()
@@ -340,13 +346,9 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
340346 configSvc := inmocks.NewMockConfigService(t)
341347 authSvc := inmocks.NewMockAuthService(t)
342348 containerSvc := inmocks.NewMockContainerService(t)
349+ secretSvc := inmocks.NewMockSecretService(t)
343350
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())
350352
351353 tests := []struct {
352354 name string
@@ -367,6 +369,10 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
367369
368370 for _, tt := range tests {
369371 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+
370376 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
371377 req = req.WithContext(ctxWithScopes(tt.scopes...))
372378 rec := httptest.NewRecorder()
@@ -384,8 +390,9 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
384390 configSvc := inmocks.NewMockConfigService(t)
385391 authSvc := inmocks.NewMockAuthService(t)
386392 containerSvc := inmocks.NewMockContainerService(t)
393+ secretSvc := inmocks.NewMockSecretService(t)
387394
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())
389396
390397 tests := []struct {
391398 name string
@@ -437,8 +444,9 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
437444 configSvc := inmocks.NewMockConfigService(t)
438445 authSvc := inmocks.NewMockAuthService(t)
439446 containerSvc := inmocks.NewMockContainerService(t)
447+ secretSvc := inmocks.NewMockSecretService(t)
440448
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())
442450
443451 tests := []struct {
444452 name string
@@ -493,8 +501,9 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
493501 configSvc := inmocks.NewMockConfigService(t)
494502 authSvc := inmocks.NewMockAuthService(t)
495503 containerSvc := inmocks.NewMockContainerService(t)
504+ secretSvc := inmocks.NewMockSecretService(t)
496505
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())
498507
499508 tests := []struct {
500509 name string
@@ -546,8 +555,9 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
546555 configSvc := inmocks.NewMockConfigService(t)
547556 authSvc := inmocks.NewMockAuthService(t)
548557 containerSvc := inmocks.NewMockContainerService(t)
558+ secretSvc := inmocks.NewMockSecretService(t)
549559
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())
551561
552562 expectedRoutes := []domain.Route{
553563 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -576,8 +586,9 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
576586 configSvc := inmocks.NewMockConfigService(t)
577587 authSvc := inmocks.NewMockAuthService(t)
578588 containerSvc := inmocks.NewMockContainerService(t)
589+ secretSvc := inmocks.NewMockSecretService(t)
579590
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())
581592
582593 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
583594 {Domain: "app.example.com", Image: "app:latest"},
@@ -602,8 +613,9 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
602613 configSvc := inmocks.NewMockConfigService(t)
603614 authSvc := inmocks.NewMockAuthService(t)
604615 containerSvc := inmocks.NewMockContainerService(t)
616+ secretSvc := inmocks.NewMockSecretService(t)
605617
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())
607619
608620 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{})
609621
@@ -620,8 +632,9 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
620632 configSvc := inmocks.NewMockConfigService(t)
621633 authSvc := inmocks.NewMockAuthService(t)
622634 containerSvc := inmocks.NewMockContainerService(t)
635+ secretSvc := inmocks.NewMockSecretService(t)
623636
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())
625638
626639 tests := []struct {
627640 name string
@@ -658,8 +671,9 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
658671 configSvc := inmocks.NewMockConfigService(t)
659672 authSvc := inmocks.NewMockAuthService(t)
660673 containerSvc := inmocks.NewMockContainerService(t)
674+ secretSvc := inmocks.NewMockSecretService(t)
661675
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())
663677
664678 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
665679 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -674,8 +688,9 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
674688 configSvc := inmocks.NewMockConfigService(t)
675689 authSvc := inmocks.NewMockAuthService(t)
676690 containerSvc := inmocks.NewMockContainerService(t)
691+ secretSvc := inmocks.NewMockSecretService(t)
677692
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())
679694
680695 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
681696 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -690,8 +705,9 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
690705 configSvc := inmocks.NewMockConfigService(t)
691706 authSvc := inmocks.NewMockAuthService(t)
692707 containerSvc := inmocks.NewMockContainerService(t)
708+ secretSvc := inmocks.NewMockSecretService(t)
693709
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())
695711
696712 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
697713 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -706,8 +722,9 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
706722 configSvc := inmocks.NewMockConfigService(t)
707723 authSvc := inmocks.NewMockAuthService(t)
708724 containerSvc := inmocks.NewMockContainerService(t)
725+ secretSvc := inmocks.NewMockSecretService(t)
709726
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())
711728
712729 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
713730 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -722,8 +739,9 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
722739 configSvc := inmocks.NewMockConfigService(t)
723740 authSvc := inmocks.NewMockAuthService(t)
724741 containerSvc := inmocks.NewMockContainerService(t)
742+ secretSvc := inmocks.NewMockSecretService(t)
725743
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())
727745
728746 tests := []struct {
729747 method string
@@ -754,8 +772,9 @@ func TestHandler_NotFound(t *testing.T) {
754772 configSvc := inmocks.NewMockConfigService(t)
755773 authSvc := inmocks.NewMockAuthService(t)
756774 containerSvc := inmocks.NewMockContainerService(t)
775+ secretSvc := inmocks.NewMockSecretService(t)
757776
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())
759778
760779 req := httptest.NewRequest("GET", "/admin/unknown", nil)
761780 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 string39 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 string204 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 directory245+ 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 string248 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 directory302+ 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 secret351+ 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 string354 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 string398 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 string452 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 string509 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 string640 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 string747 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
23
34 import (
45 "bufio"
@@ -6,46 +7,40 @@ import (
67 "os"
78 "path/filepath"
89 "strings"
10+
11+ "github.com/bnema/zerowrap"
912 )
1013
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+}
2021
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)
2427 }
2528
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")
3234
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
4139 }
4240
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)
4944
5045 file, err := os.Open(envFile)
5146 if err != nil {
@@ -77,12 +72,9 @@ func (h *Handler) listSecrets(domain string) ([]string, error) {
7772 return keys, nil
7873 }
7974
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)
8678
8779 file, err := os.Open(envFile)
8880 if err != nil {
@@ -116,32 +108,32 @@ func (h *Handler) getSecrets(domain string) (map[string]string, error) {
116108 return secrets, nil
117109 }
118110
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 {
121113 // Ensure env directory exists
122- if err := os.MkdirAll(h.envDir, 0700); err != nil {
114+ if err := os.MkdirAll(s.envDir, 0700); err != nil {
123115 return fmt.Errorf("failed to create env directory: %w", err)
124116 }
125117
126118 // Read existing secrets
127- existing, err := h.getSecrets(domain)
119+ existing, err := s.GetAll(domain)
128120 if err != nil {
129121 return err
130122 }
131123
132124 // Merge new secrets with existing
133- for key, value := range newSecrets {
125+ for key, value := range secrets {
134126 existing[key] = value
135127 }
136128
137- // Write back
138- return h.writeSecrets(domain, existing)
129+ // Write back atomically
130+ return s.writeSecretsAtomic(domain, existing)
139131 }
140132
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 {
143135 // Read existing secrets
144- existing, err := h.getSecrets(domain)
136+ existing, err := s.GetAll(domain)
145137 if err != nil {
146138 return err
147139 }
@@ -149,19 +141,14 @@ func (h *Handler) deleteSecret(domain, key string) error {
149141 // Remove the key
150142 delete(existing, key)
151143
152- // Write back
153- return h.writeSecrets(domain, existing)
144+ // Write back atomically
145+ return s.writeSecretsAtomic(domain, existing)
154146 }
155147
156-// writeSecrets writes all secrets to the domain's env file atomically.
148+// writeSecretsAtomic writes all secrets to the domain's env file atomically.
157149 // 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)
165152 tmpFile := envFile + ".tmp"
166153
167154 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 {
210197
211198 return nil
212199 }
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 admin1+// 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.env15+// This adapter is responsible only for file I/O operations; domain validation
13-// Must match the naming convention in envloader.FileLoader.getEnvFilePath16+// 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 attempts19+ 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 cleaning35+ 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, nil72 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, nil108 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 exists113 // 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 secrets118 // 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 err121 return err
130 }122 }
131 123
132 // Merge new secrets with existing124 // Merge new secrets with existing
133- for key, value := range newSecrets {125+ for key, value := range secrets {
134 existing[key] = value126 existing[key] = value
135 }127 }
136 128
137- // Write back129+ // 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 secrets135 // 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 err138 return err
147 }139 }
@@ -149,19 +141,14 @@ func (h *Handler) deleteSecret(domain, key string) error {
149 // Remove the key141 // Remove the key
150 delete(existing, key)142 delete(existing, key)
151 143
152- // Write back144+ // 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 nil198 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 (
2121
2222 // Adapters - Output
2323 "gordon/internal/adapters/out/docker"
24+ "gordon/internal/adapters/out/domainsecrets"
2425 "gordon/internal/adapters/out/envloader"
2526 "gordon/internal/adapters/out/eventbus"
2627 "gordon/internal/adapters/out/filesystem"
@@ -49,6 +50,7 @@ import (
4950 "gordon/internal/usecase/health"
5051 "gordon/internal/usecase/proxy"
5152 registrySvc "gordon/internal/usecase/registry"
53+ secretsSvc "gordon/internal/usecase/secrets"
5254 )
5355
5456 // Config holds the application configuration.
@@ -298,12 +300,19 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
298300 svc.envDir = filepath.Join(dataDir, "env")
299301 }
300302
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+
301310 // Create health service for route health checking
302311 prober := httpprober.New()
303312 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
304313
305314 // 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)
307316
308317 return svc, nil
309318 }
@@ -21,6 +21,7 @@ import (
21 21
22 // Adapters - Output22 // 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 checking310 // 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 API314 // 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, nil317 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close