fix(secrets): convert dot notation to sops extract path syntax
The sops CLI requires Python dictionary syntax for --extract (e.g., ["app"]["secret"]), but the provider was passing dot notation as a single key (["app.secret"]). Added convertToSopsExtractPath() to properly convert: - app.secret -> ["app"]["secret"] - items[0].name -> ["items"][0]["name"] Also adds unit tests for the sops path conversion and FileLoader secret resolution using MockSecretProvider.
- commit
- 7aa0326575e7d67981dc596a370ebf9137a93bf4
- author
- brice · · 2026-01-13 16:36 UTC
- parent
- 620c0ba
- signature
- unsigned
3 files · +345 -1
A internal/adapters/out/envloader/file_test.go +219 -0
| @@ -0,0 +1,219 @@ | |||
| 1 | + | package envloader | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "gordon/internal/boundaries/out/mocks" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestFileLoader_ResolveSecrets(t *testing.T) { | |
| 17 | + | // Create a temp directory for tests | |
| 18 | + | tmpDir := t.TempDir() | |
| 19 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 20 | + | ||
| 21 | + | loader, err := NewFileLoader(tmpDir, log) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | ||
| 24 | + | ctx := context.Background() | |
| 25 | + | ||
| 26 | + | t.Run("no secret syntax returns value unchanged", func(t *testing.T) { | |
| 27 | + | result, err := loader.resolveSecrets(ctx, "plain-value") | |
| 28 | + | assert.NoError(t, err) | |
| 29 | + | assert.Equal(t, "plain-value", result) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | t.Run("unclosed secret syntax returns error", func(t *testing.T) { | |
| 33 | + | _, err := loader.resolveSecrets(ctx, "${pass:secret") | |
| 34 | + | assert.Error(t, err) | |
| 35 | + | assert.Contains(t, err.Error(), "unclosed secret syntax") | |
| 36 | + | }) | |
| 37 | + | ||
| 38 | + | t.Run("invalid secret syntax without colon returns error", func(t *testing.T) { | |
| 39 | + | _, err := loader.resolveSecrets(ctx, "${invalid}") | |
| 40 | + | assert.Error(t, err) | |
| 41 | + | assert.Contains(t, err.Error(), "invalid secret syntax") | |
| 42 | + | }) | |
| 43 | + | ||
| 44 | + | t.Run("unknown provider returns error", func(t *testing.T) { | |
| 45 | + | _, err := loader.resolveSecrets(ctx, "${unknown:path}") | |
| 46 | + | assert.Error(t, err) | |
| 47 | + | assert.Contains(t, err.Error(), "unknown secret provider") | |
| 48 | + | }) | |
| 49 | + | ||
| 50 | + | t.Run("pass provider resolves secret", func(t *testing.T) { | |
| 51 | + | mockPass := mocks.NewMockSecretProvider(t) | |
| 52 | + | mockPass.EXPECT().Name().Return("pass") | |
| 53 | + | mockPass.EXPECT().GetSecret(ctx, "company/api-key").Return("secret-value-123", nil) | |
| 54 | + | ||
| 55 | + | loader.RegisterSecretProvider(mockPass) | |
| 56 | + | ||
| 57 | + | result, err := loader.resolveSecrets(ctx, "${pass:company/api-key}") | |
| 58 | + | assert.NoError(t, err) | |
| 59 | + | assert.Equal(t, "secret-value-123", result) | |
| 60 | + | }) | |
| 61 | + | ||
| 62 | + | t.Run("sops provider resolves secret", func(t *testing.T) { | |
| 63 | + | mockSops := mocks.NewMockSecretProvider(t) | |
| 64 | + | mockSops.EXPECT().Name().Return("sops") | |
| 65 | + | mockSops.EXPECT().GetSecret(ctx, "secrets.yaml:app.password").Return("sops-secret", nil) | |
| 66 | + | ||
| 67 | + | loader.RegisterSecretProvider(mockSops) | |
| 68 | + | ||
| 69 | + | result, err := loader.resolveSecrets(ctx, "${sops:secrets.yaml:app.password}") | |
| 70 | + | assert.NoError(t, err) | |
| 71 | + | assert.Equal(t, "sops-secret", result) | |
| 72 | + | }) | |
| 73 | + | ||
| 74 | + | t.Run("multiple secrets in one value", func(t *testing.T) { | |
| 75 | + | mockPass := mocks.NewMockSecretProvider(t) | |
| 76 | + | mockPass.EXPECT().Name().Return("pass") | |
| 77 | + | mockPass.EXPECT().GetSecret(ctx, "db/user").Return("admin", nil) | |
| 78 | + | mockPass.EXPECT().GetSecret(ctx, "db/pass").Return("secret123", nil) | |
| 79 | + | ||
| 80 | + | loader.RegisterSecretProvider(mockPass) | |
| 81 | + | ||
| 82 | + | result, err := loader.resolveSecrets(ctx, "postgresql://${pass:db/user}:${pass:db/pass}@localhost:5432/db") | |
| 83 | + | assert.NoError(t, err) | |
| 84 | + | assert.Equal(t, "postgresql://admin:secret123@localhost:5432/db", result) | |
| 85 | + | }) | |
| 86 | + | ||
| 87 | + | t.Run("provider error is propagated", func(t *testing.T) { | |
| 88 | + | mockPass := mocks.NewMockSecretProvider(t) | |
| 89 | + | mockPass.EXPECT().Name().Return("pass") | |
| 90 | + | mockPass.EXPECT().GetSecret(ctx, "missing/secret").Return("", assert.AnError) | |
| 91 | + | ||
| 92 | + | loader.RegisterSecretProvider(mockPass) | |
| 93 | + | ||
| 94 | + | _, err := loader.resolveSecrets(ctx, "${pass:missing/secret}") | |
| 95 | + | assert.Error(t, err) | |
| 96 | + | assert.Contains(t, err.Error(), "failed to get secret from provider") | |
| 97 | + | }) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestFileLoader_LoadEnv(t *testing.T) { | |
| 101 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 102 | + | ctx := context.Background() | |
| 103 | + | ||
| 104 | + | t.Run("missing env file returns empty slice", func(t *testing.T) { | |
| 105 | + | tmpDir := t.TempDir() | |
| 106 | + | loader, err := NewFileLoader(tmpDir, log) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | ||
| 109 | + | envVars, err := loader.LoadEnv(ctx, "nonexistent.example.com") | |
| 110 | + | assert.NoError(t, err) | |
| 111 | + | assert.Empty(t, envVars) | |
| 112 | + | }) | |
| 113 | + | ||
| 114 | + | t.Run("loads simple env file", func(t *testing.T) { | |
| 115 | + | tmpDir := t.TempDir() | |
| 116 | + | loader, err := NewFileLoader(tmpDir, log) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | ||
| 119 | + | // Create env file | |
| 120 | + | envContent := `# Comment line | |
| 121 | + | NODE_ENV=production | |
| 122 | + | API_URL=https://api.example.com | |
| 123 | + | ` | |
| 124 | + | envFile := filepath.Join(tmpDir, "app_example_com.env") | |
| 125 | + | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 126 | + | ||
| 127 | + | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| 128 | + | assert.NoError(t, err) | |
| 129 | + | assert.Len(t, envVars, 2) | |
| 130 | + | assert.Contains(t, envVars, "NODE_ENV=production") | |
| 131 | + | assert.Contains(t, envVars, "API_URL=https://api.example.com") | |
| 132 | + | }) | |
| 133 | + | ||
| 134 | + | t.Run("loads env file with secrets", func(t *testing.T) { | |
| 135 | + | tmpDir := t.TempDir() | |
| 136 | + | loader, err := NewFileLoader(tmpDir, log) | |
| 137 | + | require.NoError(t, err) | |
| 138 | + | ||
| 139 | + | // Register mock provider | |
| 140 | + | mockPass := mocks.NewMockSecretProvider(t) | |
| 141 | + | mockPass.EXPECT().Name().Return("pass") | |
| 142 | + | mockPass.EXPECT().GetSecret(ctx, "app/api-key").Return("super-secret-key", nil) | |
| 143 | + | loader.RegisterSecretProvider(mockPass) | |
| 144 | + | ||
| 145 | + | // Create env file with secret reference | |
| 146 | + | envContent := `NODE_ENV=production | |
| 147 | + | API_KEY=${pass:app/api-key} | |
| 148 | + | ` | |
| 149 | + | envFile := filepath.Join(tmpDir, "app_example_com.env") | |
| 150 | + | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 151 | + | ||
| 152 | + | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| 153 | + | assert.NoError(t, err) | |
| 154 | + | assert.Len(t, envVars, 2) | |
| 155 | + | assert.Contains(t, envVars, "NODE_ENV=production") | |
| 156 | + | assert.Contains(t, envVars, "API_KEY=super-secret-key") | |
| 157 | + | }) | |
| 158 | + | ||
| 159 | + | t.Run("handles quoted values", func(t *testing.T) { | |
| 160 | + | tmpDir := t.TempDir() | |
| 161 | + | loader, err := NewFileLoader(tmpDir, log) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | ||
| 164 | + | envContent := `DOUBLE_QUOTED="hello world" | |
| 165 | + | SINGLE_QUOTED='hello world' | |
| 166 | + | ` | |
| 167 | + | envFile := filepath.Join(tmpDir, "app_example_com.env") | |
| 168 | + | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 169 | + | ||
| 170 | + | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| 171 | + | assert.NoError(t, err) | |
| 172 | + | assert.Len(t, envVars, 2) | |
| 173 | + | assert.Contains(t, envVars, "DOUBLE_QUOTED=hello world") | |
| 174 | + | assert.Contains(t, envVars, "SINGLE_QUOTED=hello world") | |
| 175 | + | }) | |
| 176 | + | ||
| 177 | + | t.Run("skips invalid lines", func(t *testing.T) { | |
| 178 | + | tmpDir := t.TempDir() | |
| 179 | + | loader, err := NewFileLoader(tmpDir, log) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | ||
| 182 | + | envContent := `VALID=value | |
| 183 | + | invalid line without equals | |
| 184 | + | ALSO_VALID=another | |
| 185 | + | ` | |
| 186 | + | envFile := filepath.Join(tmpDir, "app_example_com.env") | |
| 187 | + | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |
| 188 | + | ||
| 189 | + | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |
| 190 | + | assert.NoError(t, err) | |
| 191 | + | assert.Len(t, envVars, 2) | |
| 192 | + | assert.Contains(t, envVars, "VALID=value") | |
| 193 | + | assert.Contains(t, envVars, "ALSO_VALID=another") | |
| 194 | + | }) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestFileLoader_GetEnvFilePath(t *testing.T) { | |
| 198 | + | tmpDir := t.TempDir() | |
| 199 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |
| 200 | + | ||
| 201 | + | loader, err := NewFileLoader(tmpDir, log) | |
| 202 | + | require.NoError(t, err) | |
| 203 | + | ||
| 204 | + | tests := []struct { | |
| 205 | + | domain string | |
| 206 | + | expected string | |
| 207 | + | }{ | |
| 208 | + | {"app.example.com", "app_example_com.env"}, | |
| 209 | + | {"api.sub.example.com", "api_sub_example_com.env"}, | |
| 210 | + | {"localhost:8080", "localhost_8080.env"}, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | for _, tt := range tests { | |
| 214 | + | t.Run(tt.domain, func(t *testing.T) { | |
| 215 | + | result := loader.getEnvFilePath(tt.domain) | |
| 216 | + | assert.Equal(t, filepath.Join(tmpDir, tt.expected), result) | |
| 217 | + | }) | |
| 218 | + | } | |
| 219 | + | } | |
| @@ -0,0 +1,219 @@ | |||||
| 1 | + | package envloader | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "gordon/internal/boundaries/out/mocks" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestFileLoader_ResolveSecrets(t *testing.T) { | |||
| 17 | + | // Create a temp directory for tests | |||
| 18 | + | tmpDir := t.TempDir() | |||
| 19 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 20 | + | ||||
| 21 | + | loader, err := NewFileLoader(tmpDir, log) | |||
| 22 | + | require.NoError(t, err) | |||
| 23 | + | ||||
| 24 | + | ctx := context.Background() | |||
| 25 | + | ||||
| 26 | + | t.Run("no secret syntax returns value unchanged", func(t *testing.T) { | |||
| 27 | + | result, err := loader.resolveSecrets(ctx, "plain-value") | |||
| 28 | + | assert.NoError(t, err) | |||
| 29 | + | assert.Equal(t, "plain-value", result) | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | t.Run("unclosed secret syntax returns error", func(t *testing.T) { | |||
| 33 | + | _, err := loader.resolveSecrets(ctx, "${pass:secret") | |||
| 34 | + | assert.Error(t, err) | |||
| 35 | + | assert.Contains(t, err.Error(), "unclosed secret syntax") | |||
| 36 | + | }) | |||
| 37 | + | ||||
| 38 | + | t.Run("invalid secret syntax without colon returns error", func(t *testing.T) { | |||
| 39 | + | _, err := loader.resolveSecrets(ctx, "${invalid}") | |||
| 40 | + | assert.Error(t, err) | |||
| 41 | + | assert.Contains(t, err.Error(), "invalid secret syntax") | |||
| 42 | + | }) | |||
| 43 | + | ||||
| 44 | + | t.Run("unknown provider returns error", func(t *testing.T) { | |||
| 45 | + | _, err := loader.resolveSecrets(ctx, "${unknown:path}") | |||
| 46 | + | assert.Error(t, err) | |||
| 47 | + | assert.Contains(t, err.Error(), "unknown secret provider") | |||
| 48 | + | }) | |||
| 49 | + | ||||
| 50 | + | t.Run("pass provider resolves secret", func(t *testing.T) { | |||
| 51 | + | mockPass := mocks.NewMockSecretProvider(t) | |||
| 52 | + | mockPass.EXPECT().Name().Return("pass") | |||
| 53 | + | mockPass.EXPECT().GetSecret(ctx, "company/api-key").Return("secret-value-123", nil) | |||
| 54 | + | ||||
| 55 | + | loader.RegisterSecretProvider(mockPass) | |||
| 56 | + | ||||
| 57 | + | result, err := loader.resolveSecrets(ctx, "${pass:company/api-key}") | |||
| 58 | + | assert.NoError(t, err) | |||
| 59 | + | assert.Equal(t, "secret-value-123", result) | |||
| 60 | + | }) | |||
| 61 | + | ||||
| 62 | + | t.Run("sops provider resolves secret", func(t *testing.T) { | |||
| 63 | + | mockSops := mocks.NewMockSecretProvider(t) | |||
| 64 | + | mockSops.EXPECT().Name().Return("sops") | |||
| 65 | + | mockSops.EXPECT().GetSecret(ctx, "secrets.yaml:app.password").Return("sops-secret", nil) | |||
| 66 | + | ||||
| 67 | + | loader.RegisterSecretProvider(mockSops) | |||
| 68 | + | ||||
| 69 | + | result, err := loader.resolveSecrets(ctx, "${sops:secrets.yaml:app.password}") | |||
| 70 | + | assert.NoError(t, err) | |||
| 71 | + | assert.Equal(t, "sops-secret", result) | |||
| 72 | + | }) | |||
| 73 | + | ||||
| 74 | + | t.Run("multiple secrets in one value", func(t *testing.T) { | |||
| 75 | + | mockPass := mocks.NewMockSecretProvider(t) | |||
| 76 | + | mockPass.EXPECT().Name().Return("pass") | |||
| 77 | + | mockPass.EXPECT().GetSecret(ctx, "db/user").Return("admin", nil) | |||
| 78 | + | mockPass.EXPECT().GetSecret(ctx, "db/pass").Return("secret123", nil) | |||
| 79 | + | ||||
| 80 | + | loader.RegisterSecretProvider(mockPass) | |||
| 81 | + | ||||
| 82 | + | result, err := loader.resolveSecrets(ctx, "postgresql://${pass:db/user}:${pass:db/pass}@localhost:5432/db") | |||
| 83 | + | assert.NoError(t, err) | |||
| 84 | + | assert.Equal(t, "postgresql://admin:secret123@localhost:5432/db", result) | |||
| 85 | + | }) | |||
| 86 | + | ||||
| 87 | + | t.Run("provider error is propagated", func(t *testing.T) { | |||
| 88 | + | mockPass := mocks.NewMockSecretProvider(t) | |||
| 89 | + | mockPass.EXPECT().Name().Return("pass") | |||
| 90 | + | mockPass.EXPECT().GetSecret(ctx, "missing/secret").Return("", assert.AnError) | |||
| 91 | + | ||||
| 92 | + | loader.RegisterSecretProvider(mockPass) | |||
| 93 | + | ||||
| 94 | + | _, err := loader.resolveSecrets(ctx, "${pass:missing/secret}") | |||
| 95 | + | assert.Error(t, err) | |||
| 96 | + | assert.Contains(t, err.Error(), "failed to get secret from provider") | |||
| 97 | + | }) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestFileLoader_LoadEnv(t *testing.T) { | |||
| 101 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 102 | + | ctx := context.Background() | |||
| 103 | + | ||||
| 104 | + | t.Run("missing env file returns empty slice", func(t *testing.T) { | |||
| 105 | + | tmpDir := t.TempDir() | |||
| 106 | + | loader, err := NewFileLoader(tmpDir, log) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | ||||
| 109 | + | envVars, err := loader.LoadEnv(ctx, "nonexistent.example.com") | |||
| 110 | + | assert.NoError(t, err) | |||
| 111 | + | assert.Empty(t, envVars) | |||
| 112 | + | }) | |||
| 113 | + | ||||
| 114 | + | t.Run("loads simple env file", func(t *testing.T) { | |||
| 115 | + | tmpDir := t.TempDir() | |||
| 116 | + | loader, err := NewFileLoader(tmpDir, log) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | ||||
| 119 | + | // Create env file | |||
| 120 | + | envContent := `# Comment line | |||
| 121 | + | NODE_ENV=production | |||
| 122 | + | API_URL=https://api.example.com | |||
| 123 | + | ` | |||
| 124 | + | envFile := filepath.Join(tmpDir, "app_example_com.env") | |||
| 125 | + | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |||
| 126 | + | ||||
| 127 | + | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |||
| 128 | + | assert.NoError(t, err) | |||
| 129 | + | assert.Len(t, envVars, 2) | |||
| 130 | + | assert.Contains(t, envVars, "NODE_ENV=production") | |||
| 131 | + | assert.Contains(t, envVars, "API_URL=https://api.example.com") | |||
| 132 | + | }) | |||
| 133 | + | ||||
| 134 | + | t.Run("loads env file with secrets", func(t *testing.T) { | |||
| 135 | + | tmpDir := t.TempDir() | |||
| 136 | + | loader, err := NewFileLoader(tmpDir, log) | |||
| 137 | + | require.NoError(t, err) | |||
| 138 | + | ||||
| 139 | + | // Register mock provider | |||
| 140 | + | mockPass := mocks.NewMockSecretProvider(t) | |||
| 141 | + | mockPass.EXPECT().Name().Return("pass") | |||
| 142 | + | mockPass.EXPECT().GetSecret(ctx, "app/api-key").Return("super-secret-key", nil) | |||
| 143 | + | loader.RegisterSecretProvider(mockPass) | |||
| 144 | + | ||||
| 145 | + | // Create env file with secret reference | |||
| 146 | + | envContent := `NODE_ENV=production | |||
| 147 | + | API_KEY=${pass:app/api-key} | |||
| 148 | + | ` | |||
| 149 | + | envFile := filepath.Join(tmpDir, "app_example_com.env") | |||
| 150 | + | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |||
| 151 | + | ||||
| 152 | + | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |||
| 153 | + | assert.NoError(t, err) | |||
| 154 | + | assert.Len(t, envVars, 2) | |||
| 155 | + | assert.Contains(t, envVars, "NODE_ENV=production") | |||
| 156 | + | assert.Contains(t, envVars, "API_KEY=super-secret-key") | |||
| 157 | + | }) | |||
| 158 | + | ||||
| 159 | + | t.Run("handles quoted values", func(t *testing.T) { | |||
| 160 | + | tmpDir := t.TempDir() | |||
| 161 | + | loader, err := NewFileLoader(tmpDir, log) | |||
| 162 | + | require.NoError(t, err) | |||
| 163 | + | ||||
| 164 | + | envContent := `DOUBLE_QUOTED="hello world" | |||
| 165 | + | SINGLE_QUOTED='hello world' | |||
| 166 | + | ` | |||
| 167 | + | envFile := filepath.Join(tmpDir, "app_example_com.env") | |||
| 168 | + | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |||
| 169 | + | ||||
| 170 | + | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |||
| 171 | + | assert.NoError(t, err) | |||
| 172 | + | assert.Len(t, envVars, 2) | |||
| 173 | + | assert.Contains(t, envVars, "DOUBLE_QUOTED=hello world") | |||
| 174 | + | assert.Contains(t, envVars, "SINGLE_QUOTED=hello world") | |||
| 175 | + | }) | |||
| 176 | + | ||||
| 177 | + | t.Run("skips invalid lines", func(t *testing.T) { | |||
| 178 | + | tmpDir := t.TempDir() | |||
| 179 | + | loader, err := NewFileLoader(tmpDir, log) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | ||||
| 182 | + | envContent := `VALID=value | |||
| 183 | + | invalid line without equals | |||
| 184 | + | ALSO_VALID=another | |||
| 185 | + | ` | |||
| 186 | + | envFile := filepath.Join(tmpDir, "app_example_com.env") | |||
| 187 | + | require.NoError(t, os.WriteFile(envFile, []byte(envContent), 0600)) | |||
| 188 | + | ||||
| 189 | + | envVars, err := loader.LoadEnv(ctx, "app.example.com") | |||
| 190 | + | assert.NoError(t, err) | |||
| 191 | + | assert.Len(t, envVars, 2) | |||
| 192 | + | assert.Contains(t, envVars, "VALID=value") | |||
| 193 | + | assert.Contains(t, envVars, "ALSO_VALID=another") | |||
| 194 | + | }) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestFileLoader_GetEnvFilePath(t *testing.T) { | |||
| 198 | + | tmpDir := t.TempDir() | |||
| 199 | + | log := zerowrap.New(zerowrap.Config{Level: "fatal"}) | |||
| 200 | + | ||||
| 201 | + | loader, err := NewFileLoader(tmpDir, log) | |||
| 202 | + | require.NoError(t, err) | |||
| 203 | + | ||||
| 204 | + | tests := []struct { | |||
| 205 | + | domain string | |||
| 206 | + | expected string | |||
| 207 | + | }{ | |||
| 208 | + | {"app.example.com", "app_example_com.env"}, | |||
| 209 | + | {"api.sub.example.com", "api_sub_example_com.env"}, | |||
| 210 | + | {"localhost:8080", "localhost_8080.env"}, | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | for _, tt := range tests { | |||
| 214 | + | t.Run(tt.domain, func(t *testing.T) { | |||
| 215 | + | result := loader.getEnvFilePath(tt.domain) | |||
| 216 | + | assert.Equal(t, filepath.Join(tmpDir, tt.expected), result) | |||
| 217 | + | }) | |||
| 218 | + | } | |||
| 219 | + | } | |||
M internal/adapters/out/secrets/sops.go +43 -1
| @@ -15,6 +15,45 @@ import ( | |||
| 15 | 15 | // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets). | |
| 16 | 16 | var sopsKeyPathRegex = regexp.MustCompile(`^[a-zA-Z0-9._\-\[\]]+$`) | |
| 17 | 17 | ||
| 18 | + | // convertToSopsExtractPath converts dot notation (app.database.password) to | |
| 19 | + | // sops --extract Python dictionary syntax (["app"]["database"]["password"]). | |
| 20 | + | // Array indices like [0] are preserved as-is. | |
| 21 | + | func convertToSopsExtractPath(keyPath string) string { | |
| 22 | + | if keyPath == "" { | |
| 23 | + | return "" | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | var result strings.Builder | |
| 27 | + | parts := strings.Split(keyPath, ".") | |
| 28 | + | ||
| 29 | + | for _, part := range parts { | |
| 30 | + | if part == "" { | |
| 31 | + | continue | |
| 32 | + | } | |
| 33 | + | // Check if the part already contains array access like "items[0]" | |
| 34 | + | // Split on '[' to handle cases like "items[0]" -> ["items"] + [0] | |
| 35 | + | if idx := strings.Index(part, "["); idx > 0 { | |
| 36 | + | // Has both key and array index, e.g., "items[0]" | |
| 37 | + | key := part[:idx] | |
| 38 | + | arrayPart := part[idx:] // includes "[0]" or "[0][1]" etc. | |
| 39 | + | result.WriteString(`["`) | |
| 40 | + | result.WriteString(key) | |
| 41 | + | result.WriteString(`"]`) | |
| 42 | + | result.WriteString(arrayPart) | |
| 43 | + | } else if strings.HasPrefix(part, "[") { | |
| 44 | + | // Pure array index like "[0]" | |
| 45 | + | result.WriteString(part) | |
| 46 | + | } else { | |
| 47 | + | // Regular key | |
| 48 | + | result.WriteString(`["`) | |
| 49 | + | result.WriteString(part) | |
| 50 | + | result.WriteString(`"]`) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | return result.String() | |
| 55 | + | } | |
| 56 | + | ||
| 18 | 57 | // SopsProvider implements the SecretProvider interface using sops. | |
| 19 | 58 | type SopsProvider struct { | |
| 20 | 59 | timeout time.Duration | |
| @@ -60,9 +99,12 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro | |||
| 60 | 99 | return "", fmt.Errorf("invalid key path: must contain only alphanumeric characters, dots, underscores, hyphens, and brackets") | |
| 61 | 100 | } | |
| 62 | 101 | ||
| 102 | + | // Convert dot notation to sops --extract Python dictionary syntax | |
| 103 | + | extractPath := convertToSopsExtractPath(keyPath) | |
| 104 | + | ||
| 63 | 105 | // Use sops -d --extract to get specific key | |
| 64 | 106 | // #nosec G204 - filePath and keyPath are validated above | |
| 65 | - | cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", fmt.Sprintf("[\"%s\"]", keyPath), cleanPath) | |
| 107 | + | cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath) | |
| 66 | 108 | output, err := cmd.Output() | |
| 67 | 109 | if err != nil { | |
| 68 | 110 | if exitError, ok := err.(*exec.ExitError); ok { | |
| @@ -15,6 +15,45 @@ import ( | |||||
| 15 | // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets). | 15 | // sopsKeyPathRegex validates sops key paths (alphanumeric, dots, underscores, hyphens, brackets). | ||
| 16 | var sopsKeyPathRegex = regexp.MustCompile(`^[a-zA-Z0-9._\-\[\]]+$`) | 16 | var sopsKeyPathRegex = regexp.MustCompile(`^[a-zA-Z0-9._\-\[\]]+$`) | ||
| 17 | 17 | ||||
| 18 | + | // convertToSopsExtractPath converts dot notation (app.database.password) to | |||
| 19 | + | // sops --extract Python dictionary syntax (["app"]["database"]["password"]). | |||
| 20 | + | // Array indices like [0] are preserved as-is. | |||
| 21 | + | func convertToSopsExtractPath(keyPath string) string { | |||
| 22 | + | if keyPath == "" { | |||
| 23 | + | return "" | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | var result strings.Builder | |||
| 27 | + | parts := strings.Split(keyPath, ".") | |||
| 28 | + | ||||
| 29 | + | for _, part := range parts { | |||
| 30 | + | if part == "" { | |||
| 31 | + | continue | |||
| 32 | + | } | |||
| 33 | + | // Check if the part already contains array access like "items[0]" | |||
| 34 | + | // Split on '[' to handle cases like "items[0]" -> ["items"] + [0] | |||
| 35 | + | if idx := strings.Index(part, "["); idx > 0 { | |||
| 36 | + | // Has both key and array index, e.g., "items[0]" | |||
| 37 | + | key := part[:idx] | |||
| 38 | + | arrayPart := part[idx:] // includes "[0]" or "[0][1]" etc. | |||
| 39 | + | result.WriteString(`["`) | |||
| 40 | + | result.WriteString(key) | |||
| 41 | + | result.WriteString(`"]`) | |||
| 42 | + | result.WriteString(arrayPart) | |||
| 43 | + | } else if strings.HasPrefix(part, "[") { | |||
| 44 | + | // Pure array index like "[0]" | |||
| 45 | + | result.WriteString(part) | |||
| 46 | + | } else { | |||
| 47 | + | // Regular key | |||
| 48 | + | result.WriteString(`["`) | |||
| 49 | + | result.WriteString(part) | |||
| 50 | + | result.WriteString(`"]`) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | return result.String() | |||
| 55 | + | } | |||
| 56 | + | ||||
| 18 | // SopsProvider implements the SecretProvider interface using sops. | 57 | // SopsProvider implements the SecretProvider interface using sops. | ||
| 19 | type SopsProvider struct { | 58 | type SopsProvider struct { | ||
| 20 | timeout time.Duration | 59 | timeout time.Duration | ||
| @@ -60,9 +99,12 @@ func (s *SopsProvider) GetSecret(ctx context.Context, path string) (string, erro | |||||
| 60 | return "", fmt.Errorf("invalid key path: must contain only alphanumeric characters, dots, underscores, hyphens, and brackets") | 99 | return "", fmt.Errorf("invalid key path: must contain only alphanumeric characters, dots, underscores, hyphens, and brackets") | ||
| 61 | } | 100 | } | ||
| 62 | 101 | ||||
| 102 | + | // Convert dot notation to sops --extract Python dictionary syntax | |||
| 103 | + | extractPath := convertToSopsExtractPath(keyPath) | |||
| 104 | + | ||||
| 63 | // Use sops -d --extract to get specific key | 105 | // Use sops -d --extract to get specific key | ||
| 64 | // #nosec G204 - filePath and keyPath are validated above | 106 | // #nosec G204 - filePath and keyPath are validated above | ||
| 65 | - | cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", fmt.Sprintf("[\"%s\"]", keyPath), cleanPath) | 107 | + | cmd := exec.CommandContext(ctx, "sops", "-d", "--extract", extractPath, cleanPath) |
| 66 | output, err := cmd.Output() | 108 | output, err := cmd.Output() | ||
| 67 | if err != nil { | 109 | if err != nil { | ||
| 68 | if exitError, ok := err.(*exec.ExitError); ok { | 110 | if exitError, ok := err.(*exec.ExitError); ok { | ||
A internal/adapters/out/secrets/sops_test.go +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | package secrets | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestConvertToSopsExtractPath(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | input string | |
| 13 | + | expected string | |
| 14 | + | }{ | |
| 15 | + | { | |
| 16 | + | name: "empty string", | |
| 17 | + | input: "", | |
| 18 | + | expected: "", | |
| 19 | + | }, | |
| 20 | + | { | |
| 21 | + | name: "single key", | |
| 22 | + | input: "password", | |
| 23 | + | expected: `["password"]`, | |
| 24 | + | }, | |
| 25 | + | { | |
| 26 | + | name: "two level nested", | |
| 27 | + | input: "app.secret", | |
| 28 | + | expected: `["app"]["secret"]`, | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "three level nested", | |
| 32 | + | input: "app.database.password", | |
| 33 | + | expected: `["app"]["database"]["password"]`, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "key with underscores", | |
| 37 | + | input: "app.database_password", | |
| 38 | + | expected: `["app"]["database_password"]`, | |
| 39 | + | }, | |
| 40 | + | { | |
| 41 | + | name: "key with hyphens", | |
| 42 | + | input: "app.api-key", | |
| 43 | + | expected: `["app"]["api-key"]`, | |
| 44 | + | }, | |
| 45 | + | { | |
| 46 | + | name: "array index only", | |
| 47 | + | input: "[0]", | |
| 48 | + | expected: `[0]`, | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "key with array index", | |
| 52 | + | input: "items[0]", | |
| 53 | + | expected: `["items"][0]`, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "nested key with array index", | |
| 57 | + | input: "app.items[0]", | |
| 58 | + | expected: `["app"]["items"][0]`, | |
| 59 | + | }, | |
| 60 | + | { | |
| 61 | + | name: "nested key with array then key", | |
| 62 | + | input: "app.items[0].name", | |
| 63 | + | expected: `["app"]["items"][0]["name"]`, | |
| 64 | + | }, | |
| 65 | + | { | |
| 66 | + | name: "multiple array indices", | |
| 67 | + | input: "matrix[0][1]", | |
| 68 | + | expected: `["matrix"][0][1]`, | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | name: "deeply nested with array", | |
| 72 | + | input: "config.databases[0].credentials.password", | |
| 73 | + | expected: `["config"]["databases"][0]["credentials"]["password"]`, | |
| 74 | + | }, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | for _, tt := range tests { | |
| 78 | + | t.Run(tt.name, func(t *testing.T) { | |
| 79 | + | result := convertToSopsExtractPath(tt.input) | |
| 80 | + | assert.Equal(t, tt.expected, result) | |
| 81 | + | }) | |
| 82 | + | } | |
| 83 | + | } | |
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | package secrets | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestConvertToSopsExtractPath(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | input string | |||
| 13 | + | expected string | |||
| 14 | + | }{ | |||
| 15 | + | { | |||
| 16 | + | name: "empty string", | |||
| 17 | + | input: "", | |||
| 18 | + | expected: "", | |||
| 19 | + | }, | |||
| 20 | + | { | |||
| 21 | + | name: "single key", | |||
| 22 | + | input: "password", | |||
| 23 | + | expected: `["password"]`, | |||
| 24 | + | }, | |||
| 25 | + | { | |||
| 26 | + | name: "two level nested", | |||
| 27 | + | input: "app.secret", | |||
| 28 | + | expected: `["app"]["secret"]`, | |||
| 29 | + | }, | |||
| 30 | + | { | |||
| 31 | + | name: "three level nested", | |||
| 32 | + | input: "app.database.password", | |||
| 33 | + | expected: `["app"]["database"]["password"]`, | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "key with underscores", | |||
| 37 | + | input: "app.database_password", | |||
| 38 | + | expected: `["app"]["database_password"]`, | |||
| 39 | + | }, | |||
| 40 | + | { | |||
| 41 | + | name: "key with hyphens", | |||
| 42 | + | input: "app.api-key", | |||
| 43 | + | expected: `["app"]["api-key"]`, | |||
| 44 | + | }, | |||
| 45 | + | { | |||
| 46 | + | name: "array index only", | |||
| 47 | + | input: "[0]", | |||
| 48 | + | expected: `[0]`, | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "key with array index", | |||
| 52 | + | input: "items[0]", | |||
| 53 | + | expected: `["items"][0]`, | |||
| 54 | + | }, | |||
| 55 | + | { | |||
| 56 | + | name: "nested key with array index", | |||
| 57 | + | input: "app.items[0]", | |||
| 58 | + | expected: `["app"]["items"][0]`, | |||
| 59 | + | }, | |||
| 60 | + | { | |||
| 61 | + | name: "nested key with array then key", | |||
| 62 | + | input: "app.items[0].name", | |||
| 63 | + | expected: `["app"]["items"][0]["name"]`, | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "multiple array indices", | |||
| 67 | + | input: "matrix[0][1]", | |||
| 68 | + | expected: `["matrix"][0][1]`, | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | name: "deeply nested with array", | |||
| 72 | + | input: "config.databases[0].credentials.password", | |||
| 73 | + | expected: `["config"]["databases"][0]["credentials"]["password"]`, | |||
| 74 | + | }, | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | for _, tt := range tests { | |||
| 78 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 79 | + | result := convertToSopsExtractPath(tt.input) | |||
| 80 | + | assert.Equal(t, tt.expected, result) | |||
| 81 | + | }) | |||
| 82 | + | } | |||
| 83 | + | } | |||