| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package secretstore_test |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/netdata/netdata/go/plugins/logger" |
| 14 | "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore" |
| 15 | "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore/backends" |
| 16 | "github.com/stretchr/testify/assert" |
| 17 | "github.com/stretchr/testify/require" |
| 18 | "gopkg.in/yaml.v2" |
| 19 | ) |
| 20 | |
| 21 | func TestServiceRegistryMetadata(t *testing.T) { |
| 22 | svc := secretstore.NewService(backends.Creators()...) |
| 23 | |
| 24 | assert.Equal(t, []secretstore.StoreKind{secretstore.KindAWSSM, secretstore.KindAzureKV, secretstore.KindGCPSM, secretstore.KindVault}, svc.Kinds()) |
| 25 | |
| 26 | name, ok := svc.DisplayName(secretstore.KindVault) |
| 27 | require.True(t, ok) |
| 28 | assert.Equal(t, "Vault", name) |
| 29 | |
| 30 | schema, ok := svc.Schema(secretstore.KindVault) |
| 31 | require.True(t, ok) |
| 32 | schemaObject := decodeSchema(t, schema) |
| 33 | jsonSchema, ok := schemaObject["jsonSchema"].(map[string]any) |
| 34 | require.True(t, ok) |
| 35 | _, ok = jsonSchema["properties"].(map[string]any)["kind"] |
| 36 | assert.False(t, ok) |
| 37 | _, ok = schemaObject["uiSchema"].(map[string]any) |
| 38 | assert.True(t, ok) |
| 39 | } |
| 40 | |
| 41 | func TestServiceStatusAndGenerationLifecycle(t *testing.T) { |
| 42 | svc := secretstore.NewService(backends.Creators()...) |
| 43 | |
| 44 | config := testSingleVaultConfig() |
| 45 | err := svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, config)) |
| 46 | require.NoError(t, err) |
| 47 | assert.Equal(t, uint64(1), svc.Capture().Generation()) |
| 48 | |
| 49 | storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod") |
| 50 | status, ok := svc.GetStatus(storeKey) |
| 51 | require.True(t, ok) |
| 52 | assert.Equal(t, "vault_prod", status.Name) |
| 53 | assert.Equal(t, secretstore.KindVault, status.Kind) |
| 54 | assert.Nil(t, status.LastValidation) |
| 55 | |
| 56 | runtimeUpdate := testSingleVaultConfig() |
| 57 | runtimeUpdate["mode"] = "token_file" |
| 58 | runtimeUpdate["mode_token"] = nil |
| 59 | runtimeUpdate["mode_token_file"] = map[string]any{ |
| 60 | "path": "/var/lib/netdata/vault.token", |
| 61 | } |
| 62 | err = svc.Update(context.Background(), storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, runtimeUpdate)) |
| 63 | require.NoError(t, err) |
| 64 | assert.Equal(t, uint64(2), svc.Capture().Generation()) |
| 65 | |
| 66 | err = svc.Update(context.Background(), storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, runtimeUpdate)) |
| 67 | require.NoError(t, err) |
| 68 | assert.Equal(t, uint64(2), svc.Capture().Generation()) |
| 69 | |
| 70 | err = svc.ValidateStored(context.Background(), storeKey) |
| 71 | require.NoError(t, err) |
| 72 | |
| 73 | status, ok = svc.GetStatus(storeKey) |
| 74 | require.True(t, ok) |
| 75 | require.NotNil(t, status.LastValidation) |
| 76 | assert.True(t, status.LastValidation.OK) |
| 77 | } |
| 78 | |
| 79 | func TestServiceUpdate_UnknownFieldOnlyChangeCountsAsChange(t *testing.T) { |
| 80 | svc := secretstore.NewService(backends.Creators()...) |
| 81 | |
| 82 | base := testSingleVaultConfig() |
| 83 | err := svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, base)) |
| 84 | require.NoError(t, err) |
| 85 | assert.Equal(t, uint64(1), svc.Capture().Generation()) |
| 86 | |
| 87 | changed := testSingleVaultConfig() |
| 88 | changed["ui_note"] = "kept" |
| 89 | err = svc.Update(context.Background(), secretstore.StoreKey(secretstore.KindVault, "vault_prod"), newStoreFromConfig(t, svc, secretstore.KindVault, changed)) |
| 90 | require.NoError(t, err) |
| 91 | assert.Equal(t, uint64(2), svc.Capture().Generation()) |
| 92 | } |
| 93 | |
| 94 | func TestServiceValidateAcceptsYAMLDecodedNestedMaps(t *testing.T) { |
| 95 | svc := secretstore.NewService(backends.Creators()...) |
| 96 | |
| 97 | var cfg secretstore.Config |
| 98 | require.NoError(t, yaml.Unmarshal([]byte(` |
| 99 | mode: token |
| 100 | mode_token: |
| 101 | token: vault-token |
| 102 | addr: https://vault.example |
| 103 | `), &cfg)) |
| 104 | cfg.SetName("vault_prod") |
| 105 | cfg.SetKind(secretstore.KindVault) |
| 106 | cfg.SetSource("dyncfg") |
| 107 | cfg.SetSourceType("dyncfg") |
| 108 | |
| 109 | require.NoError(t, svc.Validate(context.Background(), cfg)) |
| 110 | } |
| 111 | |
| 112 | func TestServiceUsesSentinelErrors(t *testing.T) { |
| 113 | svc := secretstore.NewService(backends.Creators()...) |
| 114 | |
| 115 | err := svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, testSingleVaultConfig())) |
| 116 | require.NoError(t, err) |
| 117 | |
| 118 | err = svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, testSingleVaultConfig())) |
| 119 | require.Error(t, err) |
| 120 | assert.ErrorIs(t, err, secretstore.ErrStoreExists) |
| 121 | |
| 122 | missing := testSingleVaultConfig() |
| 123 | missing["name"] = "missing" |
| 124 | err = svc.Update(context.Background(), secretstore.StoreKey(secretstore.KindVault, "missing"), newStoreFromConfig(t, svc, secretstore.KindVault, missing)) |
| 125 | require.Error(t, err) |
| 126 | assert.ErrorIs(t, err, secretstore.ErrStoreNotFound) |
| 127 | |
| 128 | err = svc.Remove(secretstore.StoreKey(secretstore.KindVault, "missing")) |
| 129 | require.Error(t, err) |
| 130 | assert.True(t, errors.Is(err, secretstore.ErrStoreNotFound)) |
| 131 | } |
| 132 | |
| 133 | func TestProviderBackedValidationContracts(t *testing.T) { |
| 134 | svc := secretstore.NewService(backends.Creators()...) |
| 135 | |
| 136 | err := svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindAWSSM, map[string]any{ |
| 137 | "name": "aws_prod", |
| 138 | "auth_mode": "env", |
| 139 | })) |
| 140 | require.Error(t, err) |
| 141 | assert.ErrorContains(t, err, "region is required") |
| 142 | |
| 143 | err = svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, map[string]any{ |
| 144 | "name": "vault_prod", |
| 145 | "mode": "token", |
| 146 | "mode_token": map[string]any{ |
| 147 | "token": "vault-token", |
| 148 | }, |
| 149 | })) |
| 150 | require.Error(t, err) |
| 151 | assert.ErrorContains(t, err, "addr is required") |
| 152 | |
| 153 | awsSchema, ok := svc.Schema(secretstore.KindAWSSM) |
| 154 | require.True(t, ok) |
| 155 | awsSchemaObject := decodeSchema(t, awsSchema) |
| 156 | awsJSONSchema, ok := awsSchemaObject["jsonSchema"].(map[string]any) |
| 157 | require.True(t, ok) |
| 158 | assert.Contains(t, awsJSONSchema["required"], "auth_mode") |
| 159 | assert.NotContains(t, awsJSONSchema["required"], "kind") |
| 160 | assert.Contains(t, awsJSONSchema["required"], "region") |
| 161 | |
| 162 | vaultSchema, ok := svc.Schema(secretstore.KindVault) |
| 163 | require.True(t, ok) |
| 164 | vaultSchemaObject := decodeSchema(t, vaultSchema) |
| 165 | vaultJSONSchema, ok := vaultSchemaObject["jsonSchema"].(map[string]any) |
| 166 | require.True(t, ok) |
| 167 | assert.Contains(t, vaultJSONSchema["required"], "addr") |
| 168 | } |
| 169 | |
| 170 | func TestProviderBackedAddAcrossKinds(t *testing.T) { |
| 171 | svc := secretstore.NewService(backends.Creators()...) |
| 172 | |
| 173 | for _, entry := range providerBackedConfigs() { |
| 174 | t.Run(string(entry.kind), func(t *testing.T) { |
| 175 | err := svc.Add(context.Background(), newStoreFromConfig(t, svc, entry.kind, entry.config)) |
| 176 | require.NoError(t, err) |
| 177 | |
| 178 | status, ok := svc.GetStatus(secretstore.StoreKey(entry.kind, entry.name)) |
| 179 | require.True(t, ok) |
| 180 | assert.Equal(t, entry.kind, status.Kind) |
| 181 | |
| 182 | err = svc.ValidateStored(context.Background(), secretstore.StoreKey(entry.kind, entry.name)) |
| 183 | require.NoError(t, err) |
| 184 | }) |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | func TestServiceValidate_ResolvesBuiltinSecretsInProviderPayload(t *testing.T) { |
| 189 | t.Setenv("TEST_VAULT_MODE", "token") |
| 190 | |
| 191 | modeFile := filepath.Join(t.TempDir(), "vault-mode") |
| 192 | require.NoError(t, os.WriteFile(modeFile, []byte("token\n"), 0o644)) |
| 193 | |
| 194 | tests := map[string]struct { |
| 195 | modeRef string |
| 196 | onWindowsSkip bool |
| 197 | }{ |
| 198 | "env": { |
| 199 | modeRef: "${env:TEST_VAULT_MODE}", |
| 200 | }, |
| 201 | "file": { |
| 202 | modeRef: "${file:" + modeFile + "}", |
| 203 | }, |
| 204 | "cmd": { |
| 205 | modeRef: "${cmd:/bin/echo token}", |
| 206 | onWindowsSkip: true, |
| 207 | }, |
| 208 | } |
| 209 | |
| 210 | for name, tc := range tests { |
| 211 | t.Run(name, func(t *testing.T) { |
| 212 | if tc.onWindowsSkip && runtime.GOOS == "windows" { |
| 213 | t.Skip("skipping on windows") |
| 214 | } |
| 215 | |
| 216 | svc := secretstore.NewService(backends.Creators()...) |
| 217 | |
| 218 | cfg := testSingleVaultConfig() |
| 219 | cfg["mode"] = tc.modeRef |
| 220 | |
| 221 | require.NoError(t, svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, cfg))) |
| 222 | }) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | func TestServiceValidate_ResolvesBuiltinSecretsInNestedProviderPayload(t *testing.T) { |
| 227 | t.Setenv("TEST_VAULT_TOKEN", "vault-token") |
| 228 | |
| 229 | svc := secretstore.NewService(backends.Creators()...) |
| 230 | |
| 231 | cfg := testSingleVaultConfig() |
| 232 | cfg["mode_token"] = map[string]any{ |
| 233 | "token": "${env:TEST_VAULT_TOKEN}", |
| 234 | } |
| 235 | |
| 236 | require.NoError(t, svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, cfg))) |
| 237 | } |
| 238 | |
| 239 | func TestServiceAddUpdate_ResolvesBuiltinSecretsInProviderPayload(t *testing.T) { |
| 240 | t.Setenv("TEST_VAULT_MODE", "token") |
| 241 | |
| 242 | modeFile := filepath.Join(t.TempDir(), "vault-mode") |
| 243 | require.NoError(t, os.WriteFile(modeFile, []byte("token\n"), 0o644)) |
| 244 | |
| 245 | svc := secretstore.NewService(backends.Creators()...) |
| 246 | storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod") |
| 247 | |
| 248 | envCfg := testSingleVaultConfig() |
| 249 | envCfg["mode"] = "${env:TEST_VAULT_MODE}" |
| 250 | require.NoError(t, svc.Add(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, envCfg))) |
| 251 | assert.Equal(t, uint64(1), svc.Capture().Generation()) |
| 252 | |
| 253 | fileCfg := testSingleVaultConfig() |
| 254 | fileCfg["mode"] = "${file:" + modeFile + "}" |
| 255 | require.NoError(t, svc.Update(context.Background(), storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, fileCfg))) |
| 256 | assert.Equal(t, uint64(2), svc.Capture().Generation()) |
| 257 | |
| 258 | t.Run("cmd", func(t *testing.T) { |
| 259 | if runtime.GOOS == "windows" { |
| 260 | t.Skip("skipping on windows") |
| 261 | } |
| 262 | |
| 263 | cmdCfg := testSingleVaultConfig() |
| 264 | cmdCfg["mode"] = "${cmd:/bin/echo token}" |
| 265 | require.NoError(t, svc.Update(context.Background(), storeKey, newStoreFromConfig(t, svc, secretstore.KindVault, cmdCfg))) |
| 266 | assert.Equal(t, uint64(3), svc.Capture().Generation()) |
| 267 | }) |
| 268 | } |
| 269 | |
| 270 | func TestServiceValidate_RejectsStoreRefsInProviderPayload(t *testing.T) { |
| 271 | svc := secretstore.NewService(backends.Creators()...) |
| 272 | |
| 273 | cfg := testSingleVaultConfig() |
| 274 | cfg["mode"] = "${store:vault:vault_prod:value}" |
| 275 | |
| 276 | err := svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, cfg)) |
| 277 | require.Error(t, err) |
| 278 | assert.ErrorContains(t, err, "secretstore resolver is not configured") |
| 279 | } |
| 280 | |
| 281 | func TestServiceValidate_KeepsMetadataStatic(t *testing.T) { |
| 282 | t.Setenv("TEST_STORE_NAME", "vault_prod") |
| 283 | |
| 284 | svc := secretstore.NewService(backends.Creators()...) |
| 285 | |
| 286 | cfg := testSingleVaultConfig() |
| 287 | cfg["name"] = "${env:TEST_STORE_NAME}" |
| 288 | |
| 289 | err := svc.Validate(context.Background(), newStoreFromConfig(t, svc, secretstore.KindVault, cfg)) |
| 290 | require.Error(t, err) |
| 291 | assert.ErrorContains(t, err, "invalid store name") |
| 292 | } |
| 293 | |
| 294 | func TestServiceValidate_LogsBuiltinResolutionWithContext(t *testing.T) { |
| 295 | t.Setenv("TEST_VAULT_MODE", "token") |
| 296 | |
| 297 | svc := secretstore.NewService(backends.Creators()...) |
| 298 | cfg := testSingleVaultConfig() |
| 299 | cfg["mode"] = "${env:TEST_VAULT_MODE}" |
| 300 | |
| 301 | out := captureLoggerOutput(t, func(log *logger.Logger) { |
| 302 | ctx := logger.ContextWithLogger(context.Background(), log) |
| 303 | require.NoError(t, svc.Validate(ctx, newStoreFromConfig(t, svc, secretstore.KindVault, cfg))) |
| 304 | }) |
| 305 | |
| 306 | assert.Contains(t, out, "resolved secret via env variable 'TEST_VAULT_MODE'") |
| 307 | assert.NotContains(t, out, "token") |
| 308 | } |