cache_test.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. /*
  2. Copyright © The ESO Authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package doppler
  14. import (
  15. "bytes"
  16. "context"
  17. "sync"
  18. "sync/atomic"
  19. "testing"
  20. "github.com/google/go-cmp/cmp"
  21. corev1 "k8s.io/api/core/v1"
  22. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  23. esv1alpha1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
  24. "github.com/external-secrets/external-secrets/providers/v1/doppler/client"
  25. "github.com/external-secrets/external-secrets/providers/v1/doppler/fake"
  26. "github.com/external-secrets/external-secrets/runtime/cache"
  27. )
  28. const testETagValue = "etag-123"
  29. // testCacheSize is used in tests to create caches with sufficient capacity.
  30. const testCacheSize = 100
  31. const testAPIKeyValue = "secret-value"
  32. const testDBPassValue = "password"
  33. // testStore is a default store identity used in tests.
  34. var testStore = storeIdentity{
  35. namespace: "test-namespace",
  36. name: "test-store",
  37. kind: "SecretStore",
  38. }
  39. func TestCacheKey(t *testing.T) {
  40. store := storeIdentity{namespace: "ns", name: "store", kind: "SecretStore"}
  41. tests := []struct {
  42. store storeIdentity
  43. secretName string
  44. expected cache.Key
  45. }{
  46. {store, "", cache.Key{Name: "store", Namespace: "ns", Kind: "SecretStore"}},
  47. {store, "API_KEY", cache.Key{Name: "store|API_KEY", Namespace: "ns", Kind: "SecretStore"}},
  48. {store, "DB_PASS", cache.Key{Name: "store|DB_PASS", Namespace: "ns", Kind: "SecretStore"}},
  49. }
  50. for _, tt := range tests {
  51. result := cacheKey(tt.store, tt.secretName)
  52. if result != tt.expected {
  53. t.Errorf("cacheKey(%v, %q) = %v, want %v", tt.store, tt.secretName, result, tt.expected)
  54. }
  55. }
  56. }
  57. func TestSecretsCacheGetSet(t *testing.T) {
  58. c := newSecretsCache(testCacheSize)
  59. entry, found := c.get(testStore, "")
  60. if found || entry != nil {
  61. t.Error("expected empty cache to return nil, false")
  62. }
  63. testEntry := &cacheEntry{
  64. etag: "test-etag",
  65. secrets: client.Secrets{"KEY": "value"},
  66. body: []byte("test body"),
  67. }
  68. c.set(testStore, "", testEntry)
  69. entry, found = c.get(testStore, "")
  70. if !found {
  71. t.Error("expected cache hit after set")
  72. }
  73. if entry.etag != testEntry.etag {
  74. t.Errorf("expected etag %q, got %q", testEntry.etag, entry.etag)
  75. }
  76. if !cmp.Equal(entry.secrets, testEntry.secrets) {
  77. t.Errorf("expected secrets %v, got %v", testEntry.secrets, entry.secrets)
  78. }
  79. // Different secret name should miss
  80. entry, found = c.get(testStore, "API_KEY")
  81. if found || entry != nil {
  82. t.Error("expected cache miss for different secret name")
  83. }
  84. // Different store should not see the entry
  85. otherStore := storeIdentity{namespace: "other-ns", name: "other-store", kind: "SecretStore"}
  86. entry, found = c.get(otherStore, "")
  87. if found || entry != nil {
  88. t.Error("expected cache miss for different store")
  89. }
  90. }
  91. func TestSecretsCacheInvalidate(t *testing.T) {
  92. c := newSecretsCache(testCacheSize)
  93. testEntry := &cacheEntry{
  94. etag: "test-etag",
  95. secrets: client.Secrets{"KEY": "value"},
  96. }
  97. c.set(testStore, "", testEntry)
  98. c.set(testStore, "API_KEY", testEntry)
  99. c.set(testStore, "DB_PASS", testEntry)
  100. _, found := c.get(testStore, "")
  101. if !found {
  102. t.Error("expected cache hit before invalidate")
  103. }
  104. _, found = c.get(testStore, "API_KEY")
  105. if !found {
  106. t.Error("expected cache hit for API_KEY before invalidate")
  107. }
  108. c.invalidate(testStore)
  109. _, found = c.get(testStore, "")
  110. if found {
  111. t.Error("expected cache miss after invalidate")
  112. }
  113. _, found = c.get(testStore, "API_KEY")
  114. if found {
  115. t.Error("expected cache miss for API_KEY after invalidate")
  116. }
  117. _, found = c.get(testStore, "DB_PASS")
  118. if found {
  119. t.Error("expected cache miss for DB_PASS after invalidate")
  120. }
  121. }
  122. func TestSecretsCacheConcurrency(t *testing.T) {
  123. c := newSecretsCache(testCacheSize)
  124. const numGoroutines = 100
  125. const numIterations = 100
  126. var wg sync.WaitGroup
  127. wg.Add(numGoroutines)
  128. for i := range numGoroutines {
  129. go func(id int) {
  130. defer wg.Done()
  131. for j := range numIterations {
  132. entry := &cacheEntry{
  133. etag: "etag",
  134. secrets: client.Secrets{"KEY": "value"},
  135. }
  136. c.set(testStore, "", entry)
  137. c.get(testStore, "")
  138. if j%10 == 0 {
  139. c.invalidate(testStore)
  140. }
  141. }
  142. }(i)
  143. }
  144. wg.Wait()
  145. }
  146. func TestGetAllSecretsUsesCache(t *testing.T) {
  147. etagCache = newSecretsCache(testCacheSize)
  148. fakeClient := &fake.DopplerClient{}
  149. var callCount atomic.Int32
  150. testSecrets := client.Secrets{"API_KEY": testAPIKeyValue, "DB_PASS": testDBPassValue}
  151. testETag := testETagValue
  152. fakeClient.WithSecretsFunc(func(request client.SecretsRequest) (*client.SecretsResponse, error) {
  153. count := callCount.Add(1)
  154. if request.ETag == "" {
  155. return &client.SecretsResponse{
  156. Modified: true,
  157. Secrets: testSecrets,
  158. ETag: testETag,
  159. }, nil
  160. }
  161. if request.ETag == testETag {
  162. return &client.SecretsResponse{
  163. Modified: false,
  164. Secrets: nil,
  165. ETag: testETag,
  166. }, nil
  167. }
  168. t.Errorf("unexpected call %d with ETag %q", count, request.ETag)
  169. return nil, nil
  170. })
  171. c := &Client{
  172. doppler: fakeClient,
  173. project: "test-project",
  174. config: "test-config",
  175. namespace: "test-namespace",
  176. storeName: "test-store",
  177. storeKind: "SecretStore",
  178. }
  179. secrets, err := c.secrets(context.Background())
  180. if err != nil {
  181. t.Fatalf("unexpected error: %v", err)
  182. }
  183. if len(secrets) != 2 {
  184. t.Errorf("expected 2 secrets, got %d", len(secrets))
  185. }
  186. if string(secrets["API_KEY"]) != testAPIKeyValue {
  187. t.Errorf("expected API_KEY=%s, got %s", testAPIKeyValue, secrets["API_KEY"])
  188. }
  189. secrets, err = c.secrets(context.Background())
  190. if err != nil {
  191. t.Fatalf("unexpected error on second call: %v", err)
  192. }
  193. if len(secrets) != 2 {
  194. t.Errorf("expected 2 secrets on second call, got %d", len(secrets))
  195. }
  196. if callCount.Load() != 2 {
  197. t.Errorf("expected 2 API calls, got %d", callCount.Load())
  198. }
  199. }
  200. func TestGetSecretUsesCache(t *testing.T) {
  201. etagCache = newSecretsCache(testCacheSize)
  202. fakeClient := &fake.DopplerClient{}
  203. var callCount atomic.Int32
  204. apiKeyETag := "etag-api-key"
  205. dbPassETag := "etag-db-pass"
  206. fakeClient.WithSecretFunc(func(request client.SecretRequest) (*client.SecretResponse, error) {
  207. callCount.Add(1)
  208. secretName := request.Name
  209. var expectedETag string
  210. var secretValue string
  211. switch secretName {
  212. case "API_KEY":
  213. expectedETag = apiKeyETag
  214. secretValue = testAPIKeyValue
  215. case "DB_PASS":
  216. expectedETag = dbPassETag
  217. secretValue = testDBPassValue
  218. default:
  219. t.Errorf("unexpected secret requested: %s", secretName)
  220. return nil, nil
  221. }
  222. if request.ETag == expectedETag {
  223. return &client.SecretResponse{Modified: false, ETag: expectedETag}, nil
  224. }
  225. return &client.SecretResponse{
  226. Name: secretName,
  227. Value: secretValue,
  228. Modified: true,
  229. ETag: expectedETag,
  230. }, nil
  231. })
  232. c := &Client{
  233. doppler: fakeClient,
  234. project: "test-project",
  235. config: "test-config",
  236. namespace: "test-namespace",
  237. storeName: "test-store",
  238. storeKind: "SecretStore",
  239. }
  240. secret, err := c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "API_KEY"})
  241. if err != nil {
  242. t.Fatalf("unexpected error: %v", err)
  243. }
  244. if string(secret) != testAPIKeyValue {
  245. t.Errorf("expected %s, got %s", testAPIKeyValue, secret)
  246. }
  247. secret, err = c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "API_KEY"})
  248. if err != nil {
  249. t.Fatalf("unexpected error on second call: %v", err)
  250. }
  251. if string(secret) != testAPIKeyValue {
  252. t.Errorf("expected %s on second call, got %s", testAPIKeyValue, secret)
  253. }
  254. secret, err = c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "DB_PASS"})
  255. if err != nil {
  256. t.Fatalf("unexpected error for DB_PASS: %v", err)
  257. }
  258. if string(secret) != testDBPassValue {
  259. t.Errorf("expected %s, got %s", testDBPassValue, secret)
  260. }
  261. secret, err = c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "DB_PASS"})
  262. if err != nil {
  263. t.Fatalf("unexpected error on second DB_PASS call: %v", err)
  264. }
  265. if string(secret) != testDBPassValue {
  266. t.Errorf("expected %s on second call, got %s", testDBPassValue, secret)
  267. }
  268. if callCount.Load() != 4 {
  269. t.Errorf("expected 4 API calls, got %d", callCount.Load())
  270. }
  271. }
  272. func TestCacheInvalidationOnPushSecret(t *testing.T) {
  273. etagCache = newSecretsCache(testCacheSize)
  274. fakeClient := &fake.DopplerClient{}
  275. var secretsCallCount atomic.Int32
  276. testSecrets := client.Secrets{"API_KEY": "original-value"}
  277. updatedSecrets := client.Secrets{"API_KEY": "updated-value"}
  278. testETag := testETagValue
  279. newETag := "etag-456"
  280. fakeClient.WithSecretsFunc(func(request client.SecretsRequest) (*client.SecretsResponse, error) {
  281. count := secretsCallCount.Add(1)
  282. switch count {
  283. case 1:
  284. return &client.SecretsResponse{
  285. Modified: true,
  286. Secrets: testSecrets,
  287. ETag: testETag,
  288. }, nil
  289. case 2:
  290. if request.ETag != "" {
  291. t.Errorf("expected no ETag after cache invalidation, got %q", request.ETag)
  292. }
  293. return &client.SecretsResponse{
  294. Modified: true,
  295. Secrets: updatedSecrets,
  296. ETag: newETag,
  297. }, nil
  298. default:
  299. t.Errorf("unexpected call %d", count)
  300. return nil, nil
  301. }
  302. })
  303. fakeClient.WithUpdateValue(client.UpdateSecretsRequest{
  304. Secrets: client.Secrets{validRemoteKey: validSecretValue},
  305. Project: "test-project",
  306. Config: "test-config",
  307. }, nil)
  308. c := &Client{
  309. doppler: fakeClient,
  310. project: "test-project",
  311. config: "test-config",
  312. namespace: "test-namespace",
  313. storeName: "test-store",
  314. storeKind: "SecretStore",
  315. }
  316. _, err := c.secrets(context.Background())
  317. if err != nil {
  318. t.Fatalf("unexpected error: %v", err)
  319. }
  320. storeID := c.storeIdentity()
  321. entry, found := etagCache.get(storeID, "")
  322. if !found {
  323. t.Error("expected cache to be populated after first call")
  324. }
  325. if entry.etag != testETag {
  326. t.Errorf("expected ETag %q, got %q", testETag, entry.etag)
  327. }
  328. secret := &corev1.Secret{
  329. Data: map[string][]byte{
  330. validSecretName: []byte(validSecretValue),
  331. },
  332. }
  333. secretData := esv1alpha1.PushSecretData{
  334. Match: esv1alpha1.PushSecretMatch{
  335. SecretKey: validSecretName,
  336. RemoteRef: esv1alpha1.PushSecretRemoteRef{
  337. RemoteKey: validRemoteKey,
  338. },
  339. },
  340. }
  341. err = c.PushSecret(context.Background(), secret, secretData)
  342. if err != nil {
  343. t.Fatalf("unexpected error pushing secret: %v", err)
  344. }
  345. _, found = etagCache.get(storeID, "")
  346. if found {
  347. t.Error("expected cache to be invalidated after push")
  348. }
  349. _, err = c.secrets(context.Background())
  350. if err != nil {
  351. t.Fatalf("unexpected error after push: %v", err)
  352. }
  353. if secretsCallCount.Load() != 2 {
  354. t.Errorf("expected 2 secrets API calls, got %d", secretsCallCount.Load())
  355. }
  356. }
  357. func TestCacheInvalidationOnDeleteSecret(t *testing.T) {
  358. etagCache = newSecretsCache(testCacheSize)
  359. fakeClient := &fake.DopplerClient{}
  360. testSecrets := client.Secrets{"API_KEY": "value"}
  361. testETag := testETagValue
  362. fakeClient.WithSecretsFunc(func(_ client.SecretsRequest) (*client.SecretsResponse, error) {
  363. return &client.SecretsResponse{
  364. Modified: true,
  365. Secrets: testSecrets,
  366. ETag: testETag,
  367. }, nil
  368. })
  369. fakeClient.WithUpdateValue(client.UpdateSecretsRequest{
  370. ChangeRequests: []client.Change{
  371. {
  372. Name: validRemoteKey,
  373. OriginalName: validRemoteKey,
  374. ShouldDelete: true,
  375. },
  376. },
  377. Project: "test-project",
  378. Config: "test-config",
  379. }, nil)
  380. c := &Client{
  381. doppler: fakeClient,
  382. project: "test-project",
  383. config: "test-config",
  384. namespace: "test-namespace",
  385. storeName: "test-store",
  386. storeKind: "SecretStore",
  387. }
  388. _, err := c.secrets(context.Background())
  389. if err != nil {
  390. t.Fatalf("unexpected error: %v", err)
  391. }
  392. storeID := c.storeIdentity()
  393. _, found := etagCache.get(storeID, "")
  394. if !found {
  395. t.Error("expected cache to be populated")
  396. }
  397. remoteRef := &esv1alpha1.PushSecretRemoteRef{RemoteKey: validRemoteKey}
  398. err = c.DeleteSecret(context.Background(), remoteRef)
  399. if err != nil {
  400. t.Fatalf("unexpected error deleting secret: %v", err)
  401. }
  402. _, found = etagCache.get(storeID, "")
  403. if found {
  404. t.Error("expected cache to be invalidated after delete")
  405. }
  406. }
  407. func TestCacheWithFormat(t *testing.T) {
  408. etagCache = newSecretsCache(testCacheSize)
  409. fakeClient := &fake.DopplerClient{}
  410. var callCount atomic.Int32
  411. testBody := []byte("KEY=value\nDB_PASS=password")
  412. testETag := "etag-format-123"
  413. fakeClient.WithSecretsFunc(func(request client.SecretsRequest) (*client.SecretsResponse, error) {
  414. count := callCount.Add(1)
  415. if request.ETag == "" {
  416. return &client.SecretsResponse{
  417. Modified: true,
  418. Body: testBody,
  419. ETag: testETag,
  420. }, nil
  421. }
  422. if request.ETag == testETag {
  423. return &client.SecretsResponse{
  424. Modified: false,
  425. Body: nil,
  426. ETag: testETag,
  427. }, nil
  428. }
  429. t.Errorf("unexpected call %d with ETag %q", count, request.ETag)
  430. return nil, nil
  431. })
  432. c := &Client{
  433. doppler: fakeClient,
  434. project: "test-project",
  435. config: "test-config",
  436. format: "env",
  437. namespace: "test-namespace",
  438. storeName: "test-store",
  439. storeKind: "SecretStore",
  440. }
  441. secrets, err := c.secrets(context.Background())
  442. if err != nil {
  443. t.Fatalf("unexpected error: %v", err)
  444. }
  445. if !bytes.Equal(secrets["DOPPLER_SECRETS_FILE"], testBody) {
  446. t.Errorf("expected body %q, got %q", testBody, secrets["DOPPLER_SECRETS_FILE"])
  447. }
  448. secrets, err = c.secrets(context.Background())
  449. if err != nil {
  450. t.Fatalf("unexpected error on second call: %v", err)
  451. }
  452. if !bytes.Equal(secrets["DOPPLER_SECRETS_FILE"], testBody) {
  453. t.Errorf("expected cached body %q, got %q", testBody, secrets["DOPPLER_SECRETS_FILE"])
  454. }
  455. if callCount.Load() != 2 {
  456. t.Errorf("expected 2 API calls, got %d", callCount.Load())
  457. }
  458. }
  459. func TestSecretsCacheDisabled(t *testing.T) {
  460. // When cache size is 0, caching should be disabled
  461. c := newSecretsCache(0)
  462. if c != nil {
  463. t.Error("expected nil cache when size is 0")
  464. }
  465. // Operations on nil cache should be no-ops (not panic)
  466. var nilCache *secretsCache
  467. entry, found := nilCache.get(testStore, "")
  468. if found || entry != nil {
  469. t.Error("expected nil cache get to return nil, false")
  470. }
  471. // set should be a no-op
  472. nilCache.set(testStore, "", &cacheEntry{etag: "test"})
  473. // invalidate should be a no-op
  474. nilCache.invalidate(testStore)
  475. }
  476. func TestDisabledCacheDoesNotCacheSecrets(t *testing.T) {
  477. // Test that when cache is disabled, secrets are fetched on every call
  478. etagCache = nil // Disabled cache
  479. fakeClient := &fake.DopplerClient{}
  480. var callCount atomic.Int32
  481. testSecrets := client.Secrets{"API_KEY": testAPIKeyValue}
  482. fakeClient.WithSecretsFunc(func(_ client.SecretsRequest) (*client.SecretsResponse, error) {
  483. callCount.Add(1)
  484. return &client.SecretsResponse{
  485. Modified: true,
  486. Secrets: testSecrets,
  487. ETag: "etag-123",
  488. }, nil
  489. })
  490. c := &Client{
  491. doppler: fakeClient,
  492. project: "test-project",
  493. config: "test-config",
  494. namespace: "test-namespace",
  495. storeName: "test-store",
  496. storeKind: "SecretStore",
  497. }
  498. // First call
  499. _, err := c.secrets(context.Background())
  500. if err != nil {
  501. t.Fatalf("unexpected error: %v", err)
  502. }
  503. // Second call - should still fetch because cache is disabled
  504. _, err = c.secrets(context.Background())
  505. if err != nil {
  506. t.Fatalf("unexpected error: %v", err)
  507. }
  508. // Third call
  509. _, err = c.secrets(context.Background())
  510. if err != nil {
  511. t.Fatalf("unexpected error: %v", err)
  512. }
  513. // All three calls should have been made to the API
  514. if callCount.Load() != 3 {
  515. t.Errorf("expected 3 API calls with disabled cache, got %d", callCount.Load())
  516. }
  517. }
  518. func TestCacheIsolationBetweenStores(t *testing.T) {
  519. // Test that different stores don't share cache entries even with same project/config
  520. c := newSecretsCache(testCacheSize)
  521. storeA := storeIdentity{namespace: "ns-a", name: "store-a", kind: "SecretStore"}
  522. storeB := storeIdentity{namespace: "ns-b", name: "store-b", kind: "SecretStore"}
  523. entryA := &cacheEntry{etag: "etag-a", secrets: client.Secrets{"KEY": "value-a"}}
  524. entryB := &cacheEntry{etag: "etag-b", secrets: client.Secrets{"KEY": "value-b"}}
  525. // Both stores use same project/config but should have separate cache entries
  526. c.set(storeA, "", entryA)
  527. c.set(storeB, "", entryB)
  528. // Each store should get its own entry
  529. gotA, foundA := c.get(storeA, "")
  530. gotB, foundB := c.get(storeB, "")
  531. if !foundA {
  532. t.Error("expected cache hit for store A")
  533. }
  534. if !foundB {
  535. t.Error("expected cache hit for store B")
  536. }
  537. if gotA.etag != "etag-a" {
  538. t.Errorf("store A got wrong etag: %q, want %q", gotA.etag, "etag-a")
  539. }
  540. if gotB.etag != "etag-b" {
  541. t.Errorf("store B got wrong etag: %q, want %q", gotB.etag, "etag-b")
  542. }
  543. // Invalidating store A should not affect store B
  544. c.invalidate(storeA)
  545. _, foundA = c.get(storeA, "")
  546. _, foundB = c.get(storeB, "")
  547. if foundA {
  548. t.Error("expected cache miss for store A after invalidation")
  549. }
  550. if !foundB {
  551. t.Error("expected cache hit for store B after store A invalidation")
  552. }
  553. }