|
@@ -0,0 +1,664 @@
|
|
|
|
|
+/*
|
|
|
|
|
+Copyright © The ESO Authors
|
|
|
|
|
+
|
|
|
|
|
+Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
+you may not use this file except in compliance with the License.
|
|
|
|
|
+You may obtain a copy of the License at
|
|
|
|
|
+
|
|
|
|
|
+ https://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
+
|
|
|
|
|
+Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
+distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
+See the License for the specific language governing permissions and
|
|
|
|
|
+limitations under the License.
|
|
|
|
|
+*/
|
|
|
|
|
+
|
|
|
|
|
+package doppler
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "bytes"
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "sync"
|
|
|
|
|
+ "sync/atomic"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/google/go-cmp/cmp"
|
|
|
|
|
+ corev1 "k8s.io/api/core/v1"
|
|
|
|
|
+
|
|
|
|
|
+ esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
|
|
|
|
|
+ esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
|
|
|
|
|
+ "github.com/external-secrets/external-secrets/providers/v1/doppler/client"
|
|
|
|
|
+ "github.com/external-secrets/external-secrets/providers/v1/doppler/fake"
|
|
|
|
|
+ "github.com/external-secrets/external-secrets/runtime/cache"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+const testETagValue = "etag-123"
|
|
|
|
|
+
|
|
|
|
|
+// testCacheSize is used in tests to create caches with sufficient capacity.
|
|
|
|
|
+const testCacheSize = 100
|
|
|
|
|
+
|
|
|
|
|
+const testAPIKeyValue = "secret-value"
|
|
|
|
|
+const testDBPassValue = "password"
|
|
|
|
|
+
|
|
|
|
|
+// testStore is a default store identity used in tests.
|
|
|
|
|
+var testStore = storeIdentity{
|
|
|
|
|
+ namespace: "test-namespace",
|
|
|
|
|
+ name: "test-store",
|
|
|
|
|
+ kind: "SecretStore",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestCacheKey(t *testing.T) {
|
|
|
|
|
+ store := storeIdentity{namespace: "ns", name: "store", kind: "SecretStore"}
|
|
|
|
|
+
|
|
|
|
|
+ tests := []struct {
|
|
|
|
|
+ store storeIdentity
|
|
|
|
|
+ secretName string
|
|
|
|
|
+ expected cache.Key
|
|
|
|
|
+ }{
|
|
|
|
|
+ {store, "", cache.Key{Name: "store", Namespace: "ns", Kind: "SecretStore"}},
|
|
|
|
|
+ {store, "API_KEY", cache.Key{Name: "store|API_KEY", Namespace: "ns", Kind: "SecretStore"}},
|
|
|
|
|
+ {store, "DB_PASS", cache.Key{Name: "store|DB_PASS", Namespace: "ns", Kind: "SecretStore"}},
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for _, tt := range tests {
|
|
|
|
|
+ result := cacheKey(tt.store, tt.secretName)
|
|
|
|
|
+ if result != tt.expected {
|
|
|
|
|
+ t.Errorf("cacheKey(%v, %q) = %v, want %v", tt.store, tt.secretName, result, tt.expected)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestSecretsCacheGetSet(t *testing.T) {
|
|
|
|
|
+ c := newSecretsCache(testCacheSize)
|
|
|
|
|
+
|
|
|
|
|
+ entry, found := c.get(testStore, "")
|
|
|
|
|
+ if found || entry != nil {
|
|
|
|
|
+ t.Error("expected empty cache to return nil, false")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ testEntry := &cacheEntry{
|
|
|
|
|
+ etag: "test-etag",
|
|
|
|
|
+ secrets: client.Secrets{"KEY": "value"},
|
|
|
|
|
+ body: []byte("test body"),
|
|
|
|
|
+ }
|
|
|
|
|
+ c.set(testStore, "", testEntry)
|
|
|
|
|
+
|
|
|
|
|
+ entry, found = c.get(testStore, "")
|
|
|
|
|
+ if !found {
|
|
|
|
|
+ t.Error("expected cache hit after set")
|
|
|
|
|
+ }
|
|
|
|
|
+ if entry.etag != testEntry.etag {
|
|
|
|
|
+ t.Errorf("expected etag %q, got %q", testEntry.etag, entry.etag)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !cmp.Equal(entry.secrets, testEntry.secrets) {
|
|
|
|
|
+ t.Errorf("expected secrets %v, got %v", testEntry.secrets, entry.secrets)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Different secret name should miss
|
|
|
|
|
+ entry, found = c.get(testStore, "API_KEY")
|
|
|
|
|
+ if found || entry != nil {
|
|
|
|
|
+ t.Error("expected cache miss for different secret name")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Different store should not see the entry
|
|
|
|
|
+ otherStore := storeIdentity{namespace: "other-ns", name: "other-store", kind: "SecretStore"}
|
|
|
|
|
+ entry, found = c.get(otherStore, "")
|
|
|
|
|
+ if found || entry != nil {
|
|
|
|
|
+ t.Error("expected cache miss for different store")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestSecretsCacheInvalidate(t *testing.T) {
|
|
|
|
|
+ c := newSecretsCache(testCacheSize)
|
|
|
|
|
+
|
|
|
|
|
+ testEntry := &cacheEntry{
|
|
|
|
|
+ etag: "test-etag",
|
|
|
|
|
+ secrets: client.Secrets{"KEY": "value"},
|
|
|
|
|
+ }
|
|
|
|
|
+ c.set(testStore, "", testEntry)
|
|
|
|
|
+ c.set(testStore, "API_KEY", testEntry)
|
|
|
|
|
+ c.set(testStore, "DB_PASS", testEntry)
|
|
|
|
|
+
|
|
|
|
|
+ _, found := c.get(testStore, "")
|
|
|
|
|
+ if !found {
|
|
|
|
|
+ t.Error("expected cache hit before invalidate")
|
|
|
|
|
+ }
|
|
|
|
|
+ _, found = c.get(testStore, "API_KEY")
|
|
|
|
|
+ if !found {
|
|
|
|
|
+ t.Error("expected cache hit for API_KEY before invalidate")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ c.invalidate(testStore)
|
|
|
|
|
+
|
|
|
|
|
+ _, found = c.get(testStore, "")
|
|
|
|
|
+ if found {
|
|
|
|
|
+ t.Error("expected cache miss after invalidate")
|
|
|
|
|
+ }
|
|
|
|
|
+ _, found = c.get(testStore, "API_KEY")
|
|
|
|
|
+ if found {
|
|
|
|
|
+ t.Error("expected cache miss for API_KEY after invalidate")
|
|
|
|
|
+ }
|
|
|
|
|
+ _, found = c.get(testStore, "DB_PASS")
|
|
|
|
|
+ if found {
|
|
|
|
|
+ t.Error("expected cache miss for DB_PASS after invalidate")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestSecretsCacheConcurrency(t *testing.T) {
|
|
|
|
|
+ c := newSecretsCache(testCacheSize)
|
|
|
|
|
+ const numGoroutines = 100
|
|
|
|
|
+ const numIterations = 100
|
|
|
|
|
+
|
|
|
|
|
+ var wg sync.WaitGroup
|
|
|
|
|
+ wg.Add(numGoroutines)
|
|
|
|
|
+
|
|
|
|
|
+ for i := range numGoroutines {
|
|
|
|
|
+ go func(id int) {
|
|
|
|
|
+ defer wg.Done()
|
|
|
|
|
+ for j := range numIterations {
|
|
|
|
|
+ entry := &cacheEntry{
|
|
|
|
|
+ etag: "etag",
|
|
|
|
|
+ secrets: client.Secrets{"KEY": "value"},
|
|
|
|
|
+ }
|
|
|
|
|
+ c.set(testStore, "", entry)
|
|
|
|
|
+ c.get(testStore, "")
|
|
|
|
|
+ if j%10 == 0 {
|
|
|
|
|
+ c.invalidate(testStore)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }(i)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ wg.Wait()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestGetAllSecretsUsesCache(t *testing.T) {
|
|
|
|
|
+ etagCache = newSecretsCache(testCacheSize)
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient := &fake.DopplerClient{}
|
|
|
|
|
+
|
|
|
|
|
+ var callCount atomic.Int32
|
|
|
|
|
+ testSecrets := client.Secrets{"API_KEY": testAPIKeyValue, "DB_PASS": testDBPassValue}
|
|
|
|
|
+ testETag := testETagValue
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient.WithSecretsFunc(func(request client.SecretsRequest) (*client.SecretsResponse, error) {
|
|
|
|
|
+ count := callCount.Add(1)
|
|
|
|
|
+
|
|
|
|
|
+ if request.ETag == "" {
|
|
|
|
|
+ return &client.SecretsResponse{
|
|
|
|
|
+ Modified: true,
|
|
|
|
|
+ Secrets: testSecrets,
|
|
|
|
|
+ ETag: testETag,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if request.ETag == testETag {
|
|
|
|
|
+ return &client.SecretsResponse{
|
|
|
|
|
+ Modified: false,
|
|
|
|
|
+ Secrets: nil,
|
|
|
|
|
+ ETag: testETag,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ t.Errorf("unexpected call %d with ETag %q", count, request.ETag)
|
|
|
|
|
+ return nil, nil
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ c := &Client{
|
|
|
|
|
+ doppler: fakeClient,
|
|
|
|
|
+ project: "test-project",
|
|
|
|
|
+ config: "test-config",
|
|
|
|
|
+ namespace: "test-namespace",
|
|
|
|
|
+ storeName: "test-store",
|
|
|
|
|
+ storeKind: "SecretStore",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secrets, err := c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(secrets) != 2 {
|
|
|
|
|
+ t.Errorf("expected 2 secrets, got %d", len(secrets))
|
|
|
|
|
+ }
|
|
|
|
|
+ if string(secrets["API_KEY"]) != testAPIKeyValue {
|
|
|
|
|
+ t.Errorf("expected API_KEY=%s, got %s", testAPIKeyValue, secrets["API_KEY"])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secrets, err = c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error on second call: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(secrets) != 2 {
|
|
|
|
|
+ t.Errorf("expected 2 secrets on second call, got %d", len(secrets))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if callCount.Load() != 2 {
|
|
|
|
|
+ t.Errorf("expected 2 API calls, got %d", callCount.Load())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestGetSecretUsesCache(t *testing.T) {
|
|
|
|
|
+ etagCache = newSecretsCache(testCacheSize)
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient := &fake.DopplerClient{}
|
|
|
|
|
+
|
|
|
|
|
+ var callCount atomic.Int32
|
|
|
|
|
+ apiKeyETag := "etag-api-key"
|
|
|
|
|
+ dbPassETag := "etag-db-pass"
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient.WithSecretFunc(func(request client.SecretRequest) (*client.SecretResponse, error) {
|
|
|
|
|
+ callCount.Add(1)
|
|
|
|
|
+
|
|
|
|
|
+ secretName := request.Name
|
|
|
|
|
+ var expectedETag string
|
|
|
|
|
+ var secretValue string
|
|
|
|
|
+
|
|
|
|
|
+ switch secretName {
|
|
|
|
|
+ case "API_KEY":
|
|
|
|
|
+ expectedETag = apiKeyETag
|
|
|
|
|
+ secretValue = testAPIKeyValue
|
|
|
|
|
+ case "DB_PASS":
|
|
|
|
|
+ expectedETag = dbPassETag
|
|
|
|
|
+ secretValue = testDBPassValue
|
|
|
|
|
+ default:
|
|
|
|
|
+ t.Errorf("unexpected secret requested: %s", secretName)
|
|
|
|
|
+ return nil, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if request.ETag == expectedETag {
|
|
|
|
|
+ return &client.SecretResponse{Modified: false, ETag: expectedETag}, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return &client.SecretResponse{
|
|
|
|
|
+ Name: secretName,
|
|
|
|
|
+ Value: secretValue,
|
|
|
|
|
+ Modified: true,
|
|
|
|
|
+ ETag: expectedETag,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ c := &Client{
|
|
|
|
|
+ doppler: fakeClient,
|
|
|
|
|
+ project: "test-project",
|
|
|
|
|
+ config: "test-config",
|
|
|
|
|
+ namespace: "test-namespace",
|
|
|
|
|
+ storeName: "test-store",
|
|
|
|
|
+ storeKind: "SecretStore",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secret, err := c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "API_KEY"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if string(secret) != testAPIKeyValue {
|
|
|
|
|
+ t.Errorf("expected %s, got %s", testAPIKeyValue, secret)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secret, err = c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "API_KEY"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error on second call: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if string(secret) != testAPIKeyValue {
|
|
|
|
|
+ t.Errorf("expected %s on second call, got %s", testAPIKeyValue, secret)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secret, err = c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "DB_PASS"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error for DB_PASS: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if string(secret) != testDBPassValue {
|
|
|
|
|
+ t.Errorf("expected %s, got %s", testDBPassValue, secret)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secret, err = c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "DB_PASS"})
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error on second DB_PASS call: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if string(secret) != testDBPassValue {
|
|
|
|
|
+ t.Errorf("expected %s on second call, got %s", testDBPassValue, secret)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if callCount.Load() != 4 {
|
|
|
|
|
+ t.Errorf("expected 4 API calls, got %d", callCount.Load())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestCacheInvalidationOnPushSecret(t *testing.T) {
|
|
|
|
|
+ etagCache = newSecretsCache(testCacheSize)
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient := &fake.DopplerClient{}
|
|
|
|
|
+
|
|
|
|
|
+ var secretsCallCount atomic.Int32
|
|
|
|
|
+ testSecrets := client.Secrets{"API_KEY": "original-value"}
|
|
|
|
|
+ updatedSecrets := client.Secrets{"API_KEY": "updated-value"}
|
|
|
|
|
+ testETag := testETagValue
|
|
|
|
|
+ newETag := "etag-456"
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient.WithSecretsFunc(func(request client.SecretsRequest) (*client.SecretsResponse, error) {
|
|
|
|
|
+ count := secretsCallCount.Add(1)
|
|
|
|
|
+
|
|
|
|
|
+ switch count {
|
|
|
|
|
+ case 1:
|
|
|
|
|
+ return &client.SecretsResponse{
|
|
|
|
|
+ Modified: true,
|
|
|
|
|
+ Secrets: testSecrets,
|
|
|
|
|
+ ETag: testETag,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ case 2:
|
|
|
|
|
+ if request.ETag != "" {
|
|
|
|
|
+ t.Errorf("expected no ETag after cache invalidation, got %q", request.ETag)
|
|
|
|
|
+ }
|
|
|
|
|
+ return &client.SecretsResponse{
|
|
|
|
|
+ Modified: true,
|
|
|
|
|
+ Secrets: updatedSecrets,
|
|
|
|
|
+ ETag: newETag,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ default:
|
|
|
|
|
+ t.Errorf("unexpected call %d", count)
|
|
|
|
|
+ return nil, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient.WithUpdateValue(client.UpdateSecretsRequest{
|
|
|
|
|
+ Secrets: client.Secrets{validRemoteKey: validSecretValue},
|
|
|
|
|
+ Project: "test-project",
|
|
|
|
|
+ Config: "test-config",
|
|
|
|
|
+ }, nil)
|
|
|
|
|
+
|
|
|
|
|
+ c := &Client{
|
|
|
|
|
+ doppler: fakeClient,
|
|
|
|
|
+ project: "test-project",
|
|
|
|
|
+ config: "test-config",
|
|
|
|
|
+ namespace: "test-namespace",
|
|
|
|
|
+ storeName: "test-store",
|
|
|
|
|
+ storeKind: "SecretStore",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _, err := c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ storeID := c.storeIdentity()
|
|
|
|
|
+ entry, found := etagCache.get(storeID, "")
|
|
|
|
|
+ if !found {
|
|
|
|
|
+ t.Error("expected cache to be populated after first call")
|
|
|
|
|
+ }
|
|
|
|
|
+ if entry.etag != testETag {
|
|
|
|
|
+ t.Errorf("expected ETag %q, got %q", testETag, entry.etag)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secret := &corev1.Secret{
|
|
|
|
|
+ Data: map[string][]byte{
|
|
|
|
|
+ validSecretName: []byte(validSecretValue),
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ secretData := esv1alpha1.PushSecretData{
|
|
|
|
|
+ Match: esv1alpha1.PushSecretMatch{
|
|
|
|
|
+ SecretKey: validSecretName,
|
|
|
|
|
+ RemoteRef: esv1alpha1.PushSecretRemoteRef{
|
|
|
|
|
+ RemoteKey: validRemoteKey,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ err = c.PushSecret(context.Background(), secret, secretData)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error pushing secret: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _, found = etagCache.get(storeID, "")
|
|
|
|
|
+ if found {
|
|
|
|
|
+ t.Error("expected cache to be invalidated after push")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _, err = c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error after push: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if secretsCallCount.Load() != 2 {
|
|
|
|
|
+ t.Errorf("expected 2 secrets API calls, got %d", secretsCallCount.Load())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestCacheInvalidationOnDeleteSecret(t *testing.T) {
|
|
|
|
|
+ etagCache = newSecretsCache(testCacheSize)
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient := &fake.DopplerClient{}
|
|
|
|
|
+
|
|
|
|
|
+ testSecrets := client.Secrets{"API_KEY": "value"}
|
|
|
|
|
+ testETag := testETagValue
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient.WithSecretsFunc(func(_ client.SecretsRequest) (*client.SecretsResponse, error) {
|
|
|
|
|
+ return &client.SecretsResponse{
|
|
|
|
|
+ Modified: true,
|
|
|
|
|
+ Secrets: testSecrets,
|
|
|
|
|
+ ETag: testETag,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient.WithUpdateValue(client.UpdateSecretsRequest{
|
|
|
|
|
+ ChangeRequests: []client.Change{
|
|
|
|
|
+ {
|
|
|
|
|
+ Name: validRemoteKey,
|
|
|
|
|
+ OriginalName: validRemoteKey,
|
|
|
|
|
+ ShouldDelete: true,
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ Project: "test-project",
|
|
|
|
|
+ Config: "test-config",
|
|
|
|
|
+ }, nil)
|
|
|
|
|
+
|
|
|
|
|
+ c := &Client{
|
|
|
|
|
+ doppler: fakeClient,
|
|
|
|
|
+ project: "test-project",
|
|
|
|
|
+ config: "test-config",
|
|
|
|
|
+ namespace: "test-namespace",
|
|
|
|
|
+ storeName: "test-store",
|
|
|
|
|
+ storeKind: "SecretStore",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _, err := c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ storeID := c.storeIdentity()
|
|
|
|
|
+ _, found := etagCache.get(storeID, "")
|
|
|
|
|
+ if !found {
|
|
|
|
|
+ t.Error("expected cache to be populated")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ remoteRef := &esv1alpha1.PushSecretRemoteRef{RemoteKey: validRemoteKey}
|
|
|
|
|
+ err = c.DeleteSecret(context.Background(), remoteRef)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error deleting secret: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ _, found = etagCache.get(storeID, "")
|
|
|
|
|
+ if found {
|
|
|
|
|
+ t.Error("expected cache to be invalidated after delete")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestCacheWithFormat(t *testing.T) {
|
|
|
|
|
+ etagCache = newSecretsCache(testCacheSize)
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient := &fake.DopplerClient{}
|
|
|
|
|
+
|
|
|
|
|
+ var callCount atomic.Int32
|
|
|
|
|
+ testBody := []byte("KEY=value\nDB_PASS=password")
|
|
|
|
|
+ testETag := "etag-format-123"
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient.WithSecretsFunc(func(request client.SecretsRequest) (*client.SecretsResponse, error) {
|
|
|
|
|
+ count := callCount.Add(1)
|
|
|
|
|
+
|
|
|
|
|
+ if request.ETag == "" {
|
|
|
|
|
+ return &client.SecretsResponse{
|
|
|
|
|
+ Modified: true,
|
|
|
|
|
+ Body: testBody,
|
|
|
|
|
+ ETag: testETag,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if request.ETag == testETag {
|
|
|
|
|
+ return &client.SecretsResponse{
|
|
|
|
|
+ Modified: false,
|
|
|
|
|
+ Body: nil,
|
|
|
|
|
+ ETag: testETag,
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ t.Errorf("unexpected call %d with ETag %q", count, request.ETag)
|
|
|
|
|
+ return nil, nil
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ c := &Client{
|
|
|
|
|
+ doppler: fakeClient,
|
|
|
|
|
+ project: "test-project",
|
|
|
|
|
+ config: "test-config",
|
|
|
|
|
+ format: "env",
|
|
|
|
|
+ namespace: "test-namespace",
|
|
|
|
|
+ storeName: "test-store",
|
|
|
|
|
+ storeKind: "SecretStore",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secrets, err := c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !bytes.Equal(secrets["DOPPLER_SECRETS_FILE"], testBody) {
|
|
|
|
|
+ t.Errorf("expected body %q, got %q", testBody, secrets["DOPPLER_SECRETS_FILE"])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ secrets, err = c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error on second call: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !bytes.Equal(secrets["DOPPLER_SECRETS_FILE"], testBody) {
|
|
|
|
|
+ t.Errorf("expected cached body %q, got %q", testBody, secrets["DOPPLER_SECRETS_FILE"])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if callCount.Load() != 2 {
|
|
|
|
|
+ t.Errorf("expected 2 API calls, got %d", callCount.Load())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestSecretsCacheDisabled(t *testing.T) {
|
|
|
|
|
+ // When cache size is 0, caching should be disabled
|
|
|
|
|
+ c := newSecretsCache(0)
|
|
|
|
|
+ if c != nil {
|
|
|
|
|
+ t.Error("expected nil cache when size is 0")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Operations on nil cache should be no-ops (not panic)
|
|
|
|
|
+ var nilCache *secretsCache
|
|
|
|
|
+ entry, found := nilCache.get(testStore, "")
|
|
|
|
|
+ if found || entry != nil {
|
|
|
|
|
+ t.Error("expected nil cache get to return nil, false")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // set should be a no-op
|
|
|
|
|
+ nilCache.set(testStore, "", &cacheEntry{etag: "test"})
|
|
|
|
|
+
|
|
|
|
|
+ // invalidate should be a no-op
|
|
|
|
|
+ nilCache.invalidate(testStore)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestDisabledCacheDoesNotCacheSecrets(t *testing.T) {
|
|
|
|
|
+ // Test that when cache is disabled, secrets are fetched on every call
|
|
|
|
|
+ etagCache = nil // Disabled cache
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient := &fake.DopplerClient{}
|
|
|
|
|
+
|
|
|
|
|
+ var callCount atomic.Int32
|
|
|
|
|
+ testSecrets := client.Secrets{"API_KEY": testAPIKeyValue}
|
|
|
|
|
+
|
|
|
|
|
+ fakeClient.WithSecretsFunc(func(_ client.SecretsRequest) (*client.SecretsResponse, error) {
|
|
|
|
|
+ callCount.Add(1)
|
|
|
|
|
+ return &client.SecretsResponse{
|
|
|
|
|
+ Modified: true,
|
|
|
|
|
+ Secrets: testSecrets,
|
|
|
|
|
+ ETag: "etag-123",
|
|
|
|
|
+ }, nil
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ c := &Client{
|
|
|
|
|
+ doppler: fakeClient,
|
|
|
|
|
+ project: "test-project",
|
|
|
|
|
+ config: "test-config",
|
|
|
|
|
+ namespace: "test-namespace",
|
|
|
|
|
+ storeName: "test-store",
|
|
|
|
|
+ storeKind: "SecretStore",
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // First call
|
|
|
|
|
+ _, err := c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Second call - should still fetch because cache is disabled
|
|
|
|
|
+ _, err = c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Third call
|
|
|
|
|
+ _, err = c.secrets(context.Background())
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // All three calls should have been made to the API
|
|
|
|
|
+ if callCount.Load() != 3 {
|
|
|
|
|
+ t.Errorf("expected 3 API calls with disabled cache, got %d", callCount.Load())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestCacheIsolationBetweenStores(t *testing.T) {
|
|
|
|
|
+ // Test that different stores don't share cache entries even with same project/config
|
|
|
|
|
+ c := newSecretsCache(testCacheSize)
|
|
|
|
|
+
|
|
|
|
|
+ storeA := storeIdentity{namespace: "ns-a", name: "store-a", kind: "SecretStore"}
|
|
|
|
|
+ storeB := storeIdentity{namespace: "ns-b", name: "store-b", kind: "SecretStore"}
|
|
|
|
|
+
|
|
|
|
|
+ entryA := &cacheEntry{etag: "etag-a", secrets: client.Secrets{"KEY": "value-a"}}
|
|
|
|
|
+ entryB := &cacheEntry{etag: "etag-b", secrets: client.Secrets{"KEY": "value-b"}}
|
|
|
|
|
+
|
|
|
|
|
+ // Both stores use same project/config but should have separate cache entries
|
|
|
|
|
+ c.set(storeA, "", entryA)
|
|
|
|
|
+ c.set(storeB, "", entryB)
|
|
|
|
|
+
|
|
|
|
|
+ // Each store should get its own entry
|
|
|
|
|
+ gotA, foundA := c.get(storeA, "")
|
|
|
|
|
+ gotB, foundB := c.get(storeB, "")
|
|
|
|
|
+
|
|
|
|
|
+ if !foundA {
|
|
|
|
|
+ t.Error("expected cache hit for store A")
|
|
|
|
|
+ }
|
|
|
|
|
+ if !foundB {
|
|
|
|
|
+ t.Error("expected cache hit for store B")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if gotA.etag != "etag-a" {
|
|
|
|
|
+ t.Errorf("store A got wrong etag: %q, want %q", gotA.etag, "etag-a")
|
|
|
|
|
+ }
|
|
|
|
|
+ if gotB.etag != "etag-b" {
|
|
|
|
|
+ t.Errorf("store B got wrong etag: %q, want %q", gotB.etag, "etag-b")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Invalidating store A should not affect store B
|
|
|
|
|
+ c.invalidate(storeA)
|
|
|
|
|
+
|
|
|
|
|
+ _, foundA = c.get(storeA, "")
|
|
|
|
|
+ _, foundB = c.get(storeB, "")
|
|
|
|
|
+
|
|
|
|
|
+ if foundA {
|
|
|
|
|
+ t.Error("expected cache miss for store A after invalidation")
|
|
|
|
|
+ }
|
|
|
|
|
+ if !foundB {
|
|
|
|
|
+ t.Error("expected cache hit for store B after store A invalidation")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|