| 1 | // SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | |
| 3 | package secretstore_test |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "sync" |
| 10 | "sync/atomic" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/netdata/netdata/go/plugins/plugin/agent/secrets/secretstore" |
| 14 | "github.com/stretchr/testify/assert" |
| 15 | "github.com/stretchr/testify/require" |
| 16 | ) |
| 17 | |
| 18 | type fakePublished struct { |
| 19 | blockOnCtx *atomic.Bool |
| 20 | requireNonNilCtx *atomic.Bool |
| 21 | } |
| 22 | |
| 23 | func (p *fakePublished) Resolve(ctx context.Context, req secretstore.ResolveRequest) (string, error) { |
| 24 | if p.requireNonNilCtx != nil { |
| 25 | p.requireNonNilCtx.Store(ctx != nil) |
| 26 | if ctx == nil { |
| 27 | return "", errors.New("nil context") |
| 28 | } |
| 29 | } |
| 30 | if p.blockOnCtx != nil && p.blockOnCtx.Load() { |
| 31 | <-ctx.Done() |
| 32 | return "", ctx.Err() |
| 33 | } |
| 34 | return req.Operand, nil |
| 35 | } |
| 36 | |
| 37 | type fakeConfig struct { |
| 38 | Auth map[string]any `json:"auth,omitempty" yaml:"auth,omitempty"` |
| 39 | } |
| 40 | |
| 41 | type fakeStore struct { |
| 42 | cfg fakeConfig |
| 43 | failInit *atomic.Bool |
| 44 | blockOnCtx *atomic.Bool |
| 45 | requireNonNilCtx *atomic.Bool |
| 46 | published secretstore.PublishedStore |
| 47 | } |
| 48 | |
| 49 | func (s *fakeStore) Configuration() any { return &s.cfg } |
| 50 | func (s *fakeStore) Publish() secretstore.PublishedStore { |
| 51 | return s.published |
| 52 | } |
| 53 | |
| 54 | func (s *fakeStore) Init(context.Context) error { |
| 55 | if s.failInit != nil && s.failInit.Load() { |
| 56 | return errors.New("simulated validation error") |
| 57 | } |
| 58 | if len(s.cfg.Auth) == 0 { |
| 59 | return errors.New("auth is required") |
| 60 | } |
| 61 | s.published = &fakePublished{ |
| 62 | blockOnCtx: s.blockOnCtx, |
| 63 | requireNonNilCtx: s.requireNonNilCtx, |
| 64 | } |
| 65 | return nil |
| 66 | } |
| 67 | |
| 68 | type validateRaceStore struct { |
| 69 | cfg fakeConfig |
| 70 | initCount *atomic.Int32 |
| 71 | validateStarted chan struct{} |
| 72 | validateRelease <-chan struct{} |
| 73 | published secretstore.PublishedStore |
| 74 | } |
| 75 | |
| 76 | func (s *validateRaceStore) Configuration() any { return &s.cfg } |
| 77 | func (s *validateRaceStore) Publish() secretstore.PublishedStore { |
| 78 | return s.published |
| 79 | } |
| 80 | |
| 81 | func (s *validateRaceStore) Init(context.Context) error { |
| 82 | if s.initCount != nil && s.initCount.Add(1) == 2 { |
| 83 | close(s.validateStarted) |
| 84 | <-s.validateRelease |
| 85 | } |
| 86 | if len(s.cfg.Auth) == 0 { |
| 87 | return errors.New("auth is required") |
| 88 | } |
| 89 | s.published = &fakePublished{} |
| 90 | return nil |
| 91 | } |
| 92 | |
| 93 | func newFakeCreator(kind secretstore.StoreKind, failInit, blockOnCtx *atomic.Bool) secretstore.Creator { |
| 94 | return newFakeCreatorWithCtxProbe(kind, failInit, blockOnCtx, nil) |
| 95 | } |
| 96 | |
| 97 | func newFakeCreatorWithCtxProbe(kind secretstore.StoreKind, failInit, blockOnCtx, requireNonNilCtx *atomic.Bool) secretstore.Creator { |
| 98 | schema := map[string]any{ |
| 99 | "jsonSchema": map[string]any{ |
| 100 | "type": "object", |
| 101 | "properties": map[string]any{ |
| 102 | "auth": map[string]any{"type": "object"}, |
| 103 | }, |
| 104 | "required": []any{"auth"}, |
| 105 | }, |
| 106 | "uiSchema": map[string]any{}, |
| 107 | } |
| 108 | bs, err := json.Marshal(schema) |
| 109 | if err != nil { |
| 110 | panic(err) |
| 111 | } |
| 112 | |
| 113 | return secretstore.Creator{ |
| 114 | Kind: kind, |
| 115 | DisplayName: "Fake Provider", |
| 116 | Schema: string(bs), |
| 117 | Create: func() secretstore.Store { |
| 118 | return &fakeStore{ |
| 119 | failInit: failInit, |
| 120 | blockOnCtx: blockOnCtx, |
| 121 | requireNonNilCtx: requireNonNilCtx, |
| 122 | } |
| 123 | }, |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | func newValidateRaceCreator(kind secretstore.StoreKind, validateStarted chan struct{}, validateRelease <-chan struct{}) secretstore.Creator { |
| 128 | schema := map[string]any{ |
| 129 | "jsonSchema": map[string]any{ |
| 130 | "type": "object", |
| 131 | "properties": map[string]any{ |
| 132 | "auth": map[string]any{"type": "object"}, |
| 133 | }, |
| 134 | "required": []any{"auth"}, |
| 135 | }, |
| 136 | "uiSchema": map[string]any{}, |
| 137 | } |
| 138 | bs, err := json.Marshal(schema) |
| 139 | if err != nil { |
| 140 | panic(err) |
| 141 | } |
| 142 | |
| 143 | var initCount atomic.Int32 |
| 144 | return secretstore.Creator{ |
| 145 | Kind: kind, |
| 146 | DisplayName: "Fake Provider", |
| 147 | Schema: string(bs), |
| 148 | Create: func() secretstore.Store { |
| 149 | return &validateRaceStore{ |
| 150 | initCount: &initCount, |
| 151 | validateStarted: validateStarted, |
| 152 | validateRelease: validateRelease, |
| 153 | } |
| 154 | }, |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | func newFakeStore(_ *testing.T, _ secretstore.Service, kind secretstore.StoreKind, cfg fakeConfig, name string) secretstore.Config { |
| 159 | bs, err := json.Marshal(cfg) |
| 160 | if err != nil { |
| 161 | panic(err) |
| 162 | } |
| 163 | var payload map[string]any |
| 164 | if err := json.Unmarshal(bs, &payload); err != nil { |
| 165 | panic(err) |
| 166 | } |
| 167 | out := secretstore.Config(payload) |
| 168 | out.SetName(name) |
| 169 | out.SetKind(kind) |
| 170 | out.SetSource("dyncfg") |
| 171 | out.SetSourceType("dyncfg") |
| 172 | return out |
| 173 | } |
| 174 | |
| 175 | func TestServiceStatusLifecycle(t *testing.T) { |
| 176 | var failInit atomic.Bool |
| 177 | svc := secretstore.NewService(newFakeCreator(secretstore.KindVault, &failInit, nil)) |
| 178 | |
| 179 | store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{ |
| 180 | Auth: map[string]any{"mode": "token_env"}, |
| 181 | }, "vault_prod") |
| 182 | |
| 183 | err := svc.Add(context.Background(), store) |
| 184 | require.NoError(t, err) |
| 185 | |
| 186 | failInit.Store(true) |
| 187 | storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod") |
| 188 | err = svc.ValidateStored(context.Background(), storeKey) |
| 189 | require.Error(t, err) |
| 190 | |
| 191 | status, ok := svc.GetStatus(storeKey) |
| 192 | require.True(t, ok) |
| 193 | require.NotNil(t, status.LastValidation) |
| 194 | assert.False(t, status.LastValidation.OK) |
| 195 | assert.Equal(t, "simulated validation error", status.LastErrorSummary) |
| 196 | |
| 197 | failInit.Store(false) |
| 198 | err = svc.ValidateStored(context.Background(), storeKey) |
| 199 | require.NoError(t, err) |
| 200 | |
| 201 | status, ok = svc.GetStatus(storeKey) |
| 202 | require.True(t, ok) |
| 203 | require.NotNil(t, status.LastValidation) |
| 204 | assert.True(t, status.LastValidation.OK) |
| 205 | assert.Empty(t, status.LastErrorSummary) |
| 206 | } |
| 207 | |
| 208 | func TestServiceResolveHonorsCanceledContext(t *testing.T) { |
| 209 | var blockOnCtx atomic.Bool |
| 210 | blockOnCtx.Store(true) |
| 211 | |
| 212 | svc := secretstore.NewService(newFakeCreator(secretstore.KindVault, nil, &blockOnCtx)) |
| 213 | store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{ |
| 214 | Auth: map[string]any{"mode": "token_env"}, |
| 215 | }, "vault_prod") |
| 216 | err := svc.Add(context.Background(), store) |
| 217 | require.NoError(t, err) |
| 218 | |
| 219 | ctx, cancel := context.WithCancel(context.Background()) |
| 220 | cancel() |
| 221 | |
| 222 | _, err = svc.Resolve(ctx, svc.Capture(), "vault:vault_prod:secret", "${store:vault:vault_prod:secret}") |
| 223 | require.ErrorIs(t, err, context.Canceled) |
| 224 | } |
| 225 | |
| 226 | func TestServiceResolve_NormalizesNilContext(t *testing.T) { |
| 227 | var requireNonNilCtx atomic.Bool |
| 228 | |
| 229 | svc := secretstore.NewService(newFakeCreatorWithCtxProbe(secretstore.KindVault, nil, nil, &requireNonNilCtx)) |
| 230 | store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{ |
| 231 | Auth: map[string]any{"mode": "token_env"}, |
| 232 | }, "vault_prod") |
| 233 | err := svc.Add(context.Background(), store) |
| 234 | require.NoError(t, err) |
| 235 | |
| 236 | val, err := svc.Resolve(nil, svc.Capture(), "vault:vault_prod:secret/data/app#key", "${store:vault:vault_prod:secret/data/app#key}") |
| 237 | require.NoError(t, err) |
| 238 | assert.Equal(t, "secret/data/app#key", val) |
| 239 | assert.True(t, requireNonNilCtx.Load()) |
| 240 | } |
| 241 | |
| 242 | func TestServiceConcurrentResolveAndMutation(t *testing.T) { |
| 243 | svc := secretstore.NewService(newFakeCreator(secretstore.KindVault, nil, nil)) |
| 244 | baseCfg := fakeConfig{ |
| 245 | Auth: map[string]any{"mode": "token_env"}, |
| 246 | } |
| 247 | |
| 248 | err := svc.Add(context.Background(), newFakeStore(t, svc, secretstore.KindVault, baseCfg, "vault_prod")) |
| 249 | require.NoError(t, err) |
| 250 | |
| 251 | var wg sync.WaitGroup |
| 252 | errCh := make(chan error, 32) |
| 253 | |
| 254 | wg.Go(func() { |
| 255 | for range 100 { |
| 256 | snapshot := svc.Capture() |
| 257 | val, err := svc.Resolve(context.Background(), snapshot, "vault:vault_prod:secret/data/app#key", "${store:vault:vault_prod:secret/data/app#key}") |
| 258 | if err != nil { |
| 259 | errCh <- err |
| 260 | return |
| 261 | } |
| 262 | if val != "secret/data/app#key" { |
| 263 | errCh <- errors.New("unexpected resolved value") |
| 264 | return |
| 265 | } |
| 266 | } |
| 267 | }) |
| 268 | |
| 269 | wg.Go(func() { |
| 270 | for i := range 100 { |
| 271 | updateCfg := baseCfg |
| 272 | if i%2 == 0 { |
| 273 | updateCfg.Auth = map[string]any{ |
| 274 | "mode": "token_env", |
| 275 | "tag": "alt", |
| 276 | } |
| 277 | } |
| 278 | if err := svc.Update(context.Background(), secretstore.StoreKey(secretstore.KindVault, "vault_prod"), newFakeStore(t, svc, secretstore.KindVault, updateCfg, "vault_prod")); err != nil { |
| 279 | errCh <- err |
| 280 | return |
| 281 | } |
| 282 | } |
| 283 | }) |
| 284 | |
| 285 | wg.Wait() |
| 286 | close(errCh) |
| 287 | |
| 288 | for err := range errCh { |
| 289 | require.NoError(t, err) |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | func TestServiceValidateStored_RemovedDuringValidationReturnsNotFound(t *testing.T) { |
| 294 | validateStarted := make(chan struct{}) |
| 295 | validateRelease := make(chan struct{}) |
| 296 | |
| 297 | svc := secretstore.NewService(newValidateRaceCreator(secretstore.KindVault, validateStarted, validateRelease)) |
| 298 | store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{ |
| 299 | Auth: map[string]any{"mode": "token_env"}, |
| 300 | }, "vault_prod") |
| 301 | require.NoError(t, svc.Add(context.Background(), store)) |
| 302 | |
| 303 | storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod") |
| 304 | errCh := make(chan error, 1) |
| 305 | |
| 306 | go func() { |
| 307 | errCh <- svc.ValidateStored(context.Background(), storeKey) |
| 308 | }() |
| 309 | |
| 310 | <-validateStarted |
| 311 | require.NoError(t, svc.Remove(storeKey)) |
| 312 | close(validateRelease) |
| 313 | |
| 314 | err := <-errCh |
| 315 | require.Error(t, err) |
| 316 | assert.ErrorIs(t, err, secretstore.ErrStoreNotFound) |
| 317 | } |
| 318 | |
| 319 | func TestServiceValidateStored_UpdatedDuringValidationReturnsRetryWithoutOverwritingStatus(t *testing.T) { |
| 320 | validateStarted := make(chan struct{}) |
| 321 | validateRelease := make(chan struct{}) |
| 322 | |
| 323 | svc := secretstore.NewService(newValidateRaceCreator(secretstore.KindVault, validateStarted, validateRelease)) |
| 324 | store := newFakeStore(t, svc, secretstore.KindVault, fakeConfig{ |
| 325 | Auth: map[string]any{"mode": "token_env"}, |
| 326 | }, "vault_prod") |
| 327 | require.NoError(t, svc.Add(context.Background(), store)) |
| 328 | |
| 329 | storeKey := secretstore.StoreKey(secretstore.KindVault, "vault_prod") |
| 330 | errCh := make(chan error, 1) |
| 331 | |
| 332 | go func() { |
| 333 | errCh <- svc.ValidateStored(context.Background(), storeKey) |
| 334 | }() |
| 335 | |
| 336 | <-validateStarted |
| 337 | require.NoError(t, svc.Update(context.Background(), storeKey, newFakeStore(t, svc, secretstore.KindVault, fakeConfig{ |
| 338 | Auth: map[string]any{"mode": "token_env", "tag": "new"}, |
| 339 | }, "vault_prod"))) |
| 340 | close(validateRelease) |
| 341 | |
| 342 | err := <-errCh |
| 343 | require.Error(t, err) |
| 344 | assert.Contains(t, err.Error(), "changed during validation") |
| 345 | |
| 346 | status, ok := svc.GetStatus(storeKey) |
| 347 | require.True(t, ok) |
| 348 | assert.Nil(t, status.LastValidation) |
| 349 | assert.Empty(t, status.LastErrorSummary) |
| 350 | } |