diff --git a/secret/cache.go b/secret/cache.go index ae46700..d08a14c 100644 --- a/secret/cache.go +++ b/secret/cache.go @@ -80,6 +80,17 @@ func (c *Cache) GetSecretValue(ctx context.Context, name string, options ...GetO return entry.value, entry.version, nil } +// Invalidate drops the cached current-version entry for name, so the next +// GetSecretValue(name) without an explicit version re-fetches from the +// underlying provider. Call it after rotating or deleting a secret to keep a +// stale value from being served for the rest of the TTL. +// +// Version-pinned entries are left in place: a specific secret version is +// immutable, so its cached bytes never go stale. +func (c *Cache) Invalidate(name string) { + c.cache.Drop(cacheKey{name: name}) +} + func (c *Cache) expireAt() time.Time { if c.ttl > 0 { return time.Now().Add(c.ttl) diff --git a/secret/cache_test.go b/secret/cache_test.go index e80324b..3e9ce65 100644 --- a/secret/cache_test.go +++ b/secret/cache_test.go @@ -203,6 +203,68 @@ func TestCache(t *testing.T) { t.Error("expiration time is too late") } }) + + t.Run("invalidate forces re-fetch of current version", func(t *testing.T) { + var callCount atomic.Int32 + provider := ProviderFunc(func(ctx context.Context, name string, options ...GetOption) (Value, string, error) { + if callCount.Add(1) == 1 { + return Value("stale"), "v1", nil + } + return Value("fresh"), "v2", nil + }) + + cache := NewCache(provider) + + if v, _, _ := cache.GetSecretValue(ctx, "secret"); string(v) != "stale" { + t.Errorf("expected 'stale' on first read, got %q", v) + } + cache.GetSecretValue(ctx, "secret") // cache hit, no new provider call + if callCount.Load() != 1 { + t.Errorf("expected 1 provider call before invalidate, got %d", callCount.Load()) + } + + cache.Invalidate("secret") + + if v, _, _ := cache.GetSecretValue(ctx, "secret"); string(v) != "fresh" { + t.Errorf("expected 'fresh' after invalidate, got %q", v) + } + if callCount.Load() != 2 { + t.Errorf("expected 2 provider calls after invalidate, got %d", callCount.Load()) + } + }) + + t.Run("invalidate leaves version-pinned entries intact", func(t *testing.T) { + var callCount atomic.Int32 + provider := ProviderFunc(func(ctx context.Context, name string, options ...GetOption) (Value, string, error) { + callCount.Add(1) + if v := NewGetOptions(options...).Version(); v != "" { + return Value("pinned-" + v), v, nil + } + return Value("current"), "current", nil + }) + + cache := NewCache(provider) + + cache.GetSecretValue(ctx, "secret") // current + cache.GetSecretValue(ctx, "secret", WithVersion("v1")) // pinned + if callCount.Load() != 2 { + t.Fatalf("expected 2 provider calls priming the cache, got %d", callCount.Load()) + } + + cache.Invalidate("secret") + + // The pinned version is immutable, so it stays cached; only the current + // entry was dropped. + cache.GetSecretValue(ctx, "secret", WithVersion("v1")) + if callCount.Load() != 2 { + t.Errorf("expected version-pinned read to stay cached after invalidate, got %d calls", callCount.Load()) + } + + cache.GetSecretValue(ctx, "secret") + if callCount.Load() != 3 { + t.Errorf("expected current read to re-fetch after invalidate, got %d calls", callCount.Load()) + } + }) } func TestCacheImplementsProvider(t *testing.T) {