client_test.go 27 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046
  1. /*
  2. Copyright © 2025 ESO Maintainer Team
  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 onepasswordsdk
  14. import (
  15. "context"
  16. "errors"
  17. "testing"
  18. "time"
  19. "github.com/1password/onepassword-sdk-go"
  20. "github.com/hashicorp/golang-lru/v2/expirable"
  21. "github.com/stretchr/testify/assert"
  22. "github.com/stretchr/testify/require"
  23. corev1 "k8s.io/api/core/v1"
  24. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  25. v1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  26. "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
  27. )
  28. func TestProviderGetSecret(t *testing.T) {
  29. tests := []struct {
  30. name string
  31. ref v1.ExternalSecretDataRemoteRef
  32. want []byte
  33. assertError func(t *testing.T, err error)
  34. client func() *onepassword.Client
  35. }{
  36. {
  37. name: "get secret successfully",
  38. client: func() *onepassword.Client {
  39. fc := &fakeClient{
  40. resolveResult: "secret",
  41. }
  42. return &onepassword.Client{
  43. SecretsAPI: fc,
  44. VaultsAPI: fc,
  45. }
  46. },
  47. assertError: func(t *testing.T, err error) {
  48. require.NoError(t, err)
  49. },
  50. ref: v1.ExternalSecretDataRemoteRef{
  51. Key: "secret",
  52. },
  53. want: []byte("secret"),
  54. },
  55. {
  56. name: "get secret with error",
  57. client: func() *onepassword.Client {
  58. fc := &fakeClient{
  59. resolveError: errors.New("fobar"),
  60. }
  61. return &onepassword.Client{
  62. SecretsAPI: fc,
  63. VaultsAPI: fc,
  64. }
  65. },
  66. assertError: func(t *testing.T, err error) {
  67. require.ErrorContains(t, err, "fobar")
  68. },
  69. ref: v1.ExternalSecretDataRemoteRef{
  70. Key: "secret",
  71. },
  72. },
  73. {
  74. name: "get secret version not implemented",
  75. client: func() *onepassword.Client {
  76. fc := &fakeClient{
  77. resolveResult: "secret",
  78. }
  79. return &onepassword.Client{
  80. SecretsAPI: fc,
  81. VaultsAPI: fc,
  82. }
  83. },
  84. ref: v1.ExternalSecretDataRemoteRef{
  85. Key: "secret",
  86. Version: "1",
  87. },
  88. assertError: func(t *testing.T, err error) {
  89. require.ErrorContains(t, err, "is not implemented in the 1Password SDK provider")
  90. },
  91. },
  92. }
  93. for _, tt := range tests {
  94. t.Run(tt.name, func(t *testing.T) {
  95. p := &Provider{
  96. client: tt.client(),
  97. vaultPrefix: "op://vault/",
  98. }
  99. got, err := p.GetSecret(t.Context(), tt.ref)
  100. tt.assertError(t, err)
  101. require.Equal(t, string(got), string(tt.want))
  102. })
  103. }
  104. }
  105. func TestProviderGetSecretMap(t *testing.T) {
  106. tests := []struct {
  107. name string
  108. ref v1.ExternalSecretDataRemoteRef
  109. want map[string][]byte
  110. assertError func(t *testing.T, err error)
  111. client func() *onepassword.Client
  112. }{
  113. {
  114. name: "get secret successfully for files",
  115. client: func() *onepassword.Client {
  116. fc := &fakeClient{}
  117. fl := &fakeLister{
  118. listAllResult: []onepassword.ItemOverview{
  119. {
  120. ID: "test-item-id",
  121. Title: "key",
  122. Category: "login",
  123. VaultID: "vault-id",
  124. },
  125. },
  126. getResult: onepassword.Item{
  127. ID: "test-item-id",
  128. Title: "key",
  129. Category: "login",
  130. VaultID: "vault-id",
  131. Files: []onepassword.ItemFile{
  132. {
  133. Attributes: onepassword.FileAttributes{
  134. Name: "name",
  135. ID: "id",
  136. },
  137. FieldID: "field-id",
  138. },
  139. },
  140. },
  141. fileLister: &fakeFileLister{
  142. readContent: []byte("content"),
  143. },
  144. }
  145. return &onepassword.Client{
  146. SecretsAPI: fc,
  147. ItemsAPI: fl,
  148. VaultsAPI: fc,
  149. }
  150. },
  151. assertError: func(t *testing.T, err error) {
  152. require.NoError(t, err)
  153. },
  154. ref: v1.ExternalSecretDataRemoteRef{
  155. Key: "key",
  156. Property: "file/name",
  157. },
  158. want: map[string][]byte{
  159. "name": []byte("content"),
  160. },
  161. },
  162. {
  163. name: "get secret successfully for fields",
  164. client: func() *onepassword.Client {
  165. fc := &fakeClient{}
  166. fl := &fakeLister{
  167. listAllResult: []onepassword.ItemOverview{
  168. {
  169. ID: "test-item-id",
  170. Title: "key",
  171. Category: "login",
  172. VaultID: "vault-id",
  173. },
  174. },
  175. getResult: onepassword.Item{
  176. ID: "test-item-id",
  177. Title: "key",
  178. Category: "login",
  179. VaultID: "vault-id",
  180. Fields: []onepassword.ItemField{
  181. {
  182. ID: "field-id",
  183. Title: "name",
  184. FieldType: onepassword.ItemFieldTypeConcealed,
  185. Value: "value",
  186. },
  187. },
  188. },
  189. fileLister: &fakeFileLister{
  190. readContent: []byte("content"),
  191. },
  192. }
  193. return &onepassword.Client{
  194. SecretsAPI: fc,
  195. ItemsAPI: fl,
  196. VaultsAPI: fc,
  197. }
  198. },
  199. assertError: func(t *testing.T, err error) {
  200. require.NoError(t, err)
  201. },
  202. ref: v1.ExternalSecretDataRemoteRef{
  203. Key: "key",
  204. Property: "field/name",
  205. },
  206. want: map[string][]byte{
  207. "name": []byte("value"),
  208. },
  209. },
  210. {
  211. name: "get secret fails with fields with same title",
  212. client: func() *onepassword.Client {
  213. fc := &fakeClient{}
  214. fl := &fakeLister{
  215. listAllResult: []onepassword.ItemOverview{
  216. {
  217. ID: "test-item-id",
  218. Title: "key",
  219. Category: "login",
  220. VaultID: "vault-id",
  221. },
  222. },
  223. getResult: onepassword.Item{
  224. ID: "test-item-id",
  225. Title: "key",
  226. Category: "login",
  227. VaultID: "vault-id",
  228. Fields: []onepassword.ItemField{
  229. {
  230. ID: "field-id",
  231. Title: "name",
  232. FieldType: onepassword.ItemFieldTypeConcealed,
  233. Value: "value",
  234. },
  235. {
  236. ID: "field-id",
  237. Title: "name",
  238. FieldType: onepassword.ItemFieldTypeConcealed,
  239. Value: "value",
  240. },
  241. },
  242. },
  243. fileLister: &fakeFileLister{
  244. readContent: []byte("content"),
  245. },
  246. }
  247. return &onepassword.Client{
  248. SecretsAPI: fc,
  249. ItemsAPI: fl,
  250. VaultsAPI: fc,
  251. }
  252. },
  253. assertError: func(t *testing.T, err error) {
  254. require.ErrorContains(t, err, "found more than 1 fields with title 'name' in 'key', got 2")
  255. },
  256. ref: v1.ExternalSecretDataRemoteRef{
  257. Key: "key",
  258. Property: "field/name",
  259. },
  260. },
  261. }
  262. for _, tt := range tests {
  263. t.Run(tt.name, func(t *testing.T) {
  264. p := &Provider{
  265. client: tt.client(),
  266. vaultPrefix: "op://vault/",
  267. }
  268. got, err := p.GetSecretMap(t.Context(), tt.ref)
  269. tt.assertError(t, err)
  270. require.Equal(t, tt.want, got)
  271. })
  272. }
  273. }
  274. func TestProviderValidate(t *testing.T) {
  275. tests := []struct {
  276. name string
  277. want v1.ValidationResult
  278. assertError func(t *testing.T, err error)
  279. client func() *onepassword.Client
  280. vaultPrefix string
  281. }{
  282. {
  283. name: "validate successfully",
  284. client: func() *onepassword.Client {
  285. fc := &fakeClient{
  286. listAllResult: []onepassword.VaultOverview{
  287. {
  288. ID: "test",
  289. Title: "test",
  290. },
  291. },
  292. }
  293. return &onepassword.Client{
  294. SecretsAPI: fc,
  295. VaultsAPI: fc,
  296. }
  297. },
  298. want: v1.ValidationResultReady,
  299. assertError: func(t *testing.T, err error) {
  300. require.NoError(t, err)
  301. },
  302. vaultPrefix: "op://vault/",
  303. },
  304. }
  305. for _, tt := range tests {
  306. t.Run(tt.name, func(t *testing.T) {
  307. p := &Provider{
  308. client: tt.client(),
  309. vaultPrefix: tt.vaultPrefix,
  310. }
  311. got, err := p.Validate()
  312. tt.assertError(t, err)
  313. require.Equal(t, got, tt.want)
  314. })
  315. }
  316. }
  317. func TestPushSecret(t *testing.T) {
  318. fc := &fakeClient{
  319. listAllResult: []onepassword.VaultOverview{
  320. {
  321. ID: "test",
  322. Title: "test",
  323. },
  324. },
  325. }
  326. tests := []struct {
  327. name string
  328. ref v1alpha1.PushSecretData
  329. secret *corev1.Secret
  330. assertError func(t *testing.T, err error)
  331. lister func() *fakeLister
  332. assertLister func(t *testing.T, lister *fakeLister)
  333. }{
  334. {
  335. name: "create is called",
  336. lister: func() *fakeLister {
  337. return &fakeLister{
  338. listAllResult: []onepassword.ItemOverview{},
  339. }
  340. },
  341. secret: &corev1.Secret{
  342. Data: map[string][]byte{
  343. "foo": []byte("bar"),
  344. },
  345. ObjectMeta: metav1.ObjectMeta{
  346. Name: "secret",
  347. Namespace: "default",
  348. },
  349. },
  350. ref: v1alpha1.PushSecretData{
  351. Match: v1alpha1.PushSecretMatch{
  352. SecretKey: "foo",
  353. RemoteRef: v1alpha1.PushSecretRemoteRef{
  354. RemoteKey: "key",
  355. },
  356. },
  357. },
  358. assertError: func(t *testing.T, err error) {
  359. require.NoError(t, err)
  360. },
  361. assertLister: func(t *testing.T, lister *fakeLister) {
  362. assert.True(t, lister.createCalled)
  363. },
  364. },
  365. {
  366. name: "update is called",
  367. lister: func() *fakeLister {
  368. return &fakeLister{
  369. listAllResult: []onepassword.ItemOverview{
  370. {
  371. ID: "test-item-id",
  372. Title: "key",
  373. Category: "login",
  374. VaultID: "vault-id",
  375. },
  376. },
  377. }
  378. },
  379. secret: &corev1.Secret{
  380. Data: map[string][]byte{
  381. "foo": []byte("bar"),
  382. },
  383. ObjectMeta: metav1.ObjectMeta{
  384. Name: "secret",
  385. Namespace: "default",
  386. },
  387. },
  388. ref: v1alpha1.PushSecretData{
  389. Match: v1alpha1.PushSecretMatch{
  390. SecretKey: "foo",
  391. RemoteRef: v1alpha1.PushSecretRemoteRef{
  392. RemoteKey: "key",
  393. },
  394. },
  395. },
  396. assertError: func(t *testing.T, err error) {
  397. require.NoError(t, err)
  398. },
  399. assertLister: func(t *testing.T, lister *fakeLister) {
  400. assert.True(t, lister.putCalled)
  401. },
  402. },
  403. }
  404. for _, tt := range tests {
  405. t.Run(tt.name, func(t *testing.T) {
  406. ctx := t.Context()
  407. lister := tt.lister()
  408. p := &Provider{
  409. client: &onepassword.Client{
  410. SecretsAPI: fc,
  411. VaultsAPI: fc,
  412. ItemsAPI: lister,
  413. },
  414. }
  415. err := p.PushSecret(ctx, tt.secret, tt.ref)
  416. tt.assertError(t, err)
  417. tt.assertLister(t, lister)
  418. })
  419. }
  420. }
  421. func TestDeleteItemField(t *testing.T) {
  422. fc := &fakeClient{
  423. listAllResult: []onepassword.VaultOverview{
  424. {
  425. ID: "test",
  426. Title: "test",
  427. },
  428. },
  429. }
  430. testCases := []struct {
  431. name string
  432. lister func() *fakeLister
  433. ref *v1alpha1.PushSecretRemoteRef
  434. assertError func(t *testing.T, err error)
  435. assertLister func(t *testing.T, lister *fakeLister)
  436. }{
  437. {
  438. name: "update is called",
  439. ref: &v1alpha1.PushSecretRemoteRef{
  440. RemoteKey: "key",
  441. Property: "password",
  442. },
  443. assertLister: func(t *testing.T, lister *fakeLister) {
  444. require.True(t, lister.putCalled)
  445. },
  446. lister: func() *fakeLister {
  447. fl := &fakeLister{
  448. listAllResult: []onepassword.ItemOverview{
  449. {
  450. ID: "test-item-id",
  451. Title: "key",
  452. Category: "login",
  453. VaultID: "vault-id",
  454. },
  455. },
  456. getResult: onepassword.Item{
  457. ID: "test-item-id",
  458. Title: "key",
  459. Category: "login",
  460. VaultID: "vault-id",
  461. Fields: []onepassword.ItemField{
  462. {
  463. ID: "field-1",
  464. Title: "password",
  465. FieldType: onepassword.ItemFieldTypeConcealed,
  466. Value: "password",
  467. },
  468. {
  469. ID: "field-2",
  470. Title: "other-field",
  471. FieldType: onepassword.ItemFieldTypeConcealed,
  472. Value: "username",
  473. },
  474. },
  475. },
  476. }
  477. return fl
  478. },
  479. assertError: func(t *testing.T, err error) {
  480. require.NoError(t, err)
  481. },
  482. },
  483. {
  484. name: "delete is called",
  485. ref: &v1alpha1.PushSecretRemoteRef{
  486. RemoteKey: "key",
  487. Property: "password",
  488. },
  489. assertLister: func(t *testing.T, lister *fakeLister) {
  490. require.True(t, lister.deleteCalled, "delete should have been called as the item should have existed")
  491. },
  492. lister: func() *fakeLister {
  493. fl := &fakeLister{
  494. listAllResult: []onepassword.ItemOverview{
  495. {
  496. ID: "test-item-id",
  497. Title: "key",
  498. Category: "login",
  499. VaultID: "vault-id",
  500. },
  501. },
  502. getResult: onepassword.Item{
  503. ID: "test-item-id",
  504. Title: "key",
  505. Category: "login",
  506. VaultID: "vault-id",
  507. Fields: []onepassword.ItemField{
  508. {
  509. ID: "field-1",
  510. Title: "password",
  511. FieldType: onepassword.ItemFieldTypeConcealed,
  512. Value: "password",
  513. },
  514. },
  515. },
  516. }
  517. return fl
  518. },
  519. assertError: func(t *testing.T, err error) {
  520. require.NoError(t, err)
  521. },
  522. },
  523. }
  524. for _, testCase := range testCases {
  525. t.Run(testCase.name, func(t *testing.T) {
  526. ctx := t.Context()
  527. lister := testCase.lister()
  528. p := &Provider{
  529. client: &onepassword.Client{
  530. SecretsAPI: fc,
  531. VaultsAPI: fc,
  532. ItemsAPI: lister,
  533. },
  534. }
  535. testCase.assertError(t, p.DeleteSecret(ctx, testCase.ref))
  536. testCase.assertLister(t, lister)
  537. })
  538. }
  539. }
  540. func TestGetVault(t *testing.T) {
  541. fc := &fakeClient{
  542. listAllResult: []onepassword.VaultOverview{
  543. {
  544. ID: "vault-id",
  545. Title: "vault-title",
  546. },
  547. },
  548. }
  549. p := &Provider{
  550. client: &onepassword.Client{
  551. VaultsAPI: fc,
  552. },
  553. }
  554. titleOrUuids := []string{"vault-title", "vault-id"}
  555. for _, titleOrUuid := range titleOrUuids {
  556. t.Run(titleOrUuid, func(t *testing.T) {
  557. vaultID, err := p.GetVault(t.Context(), titleOrUuid)
  558. require.NoError(t, err)
  559. require.Equal(t, fc.listAllResult[0].ID, vaultID)
  560. })
  561. }
  562. }
  563. type fakeLister struct {
  564. listAllResult []onepassword.ItemOverview
  565. createCalled bool
  566. putCalled bool
  567. deleteCalled bool
  568. getResult onepassword.Item
  569. fileLister onepassword.ItemsFilesAPI
  570. }
  571. func (f *fakeLister) Create(ctx context.Context, params onepassword.ItemCreateParams) (onepassword.Item, error) {
  572. f.createCalled = true
  573. return onepassword.Item{}, nil
  574. }
  575. func (f *fakeLister) Get(ctx context.Context, vaultID, itemID string) (onepassword.Item, error) {
  576. return f.getResult, nil
  577. }
  578. func (f *fakeLister) Put(ctx context.Context, item onepassword.Item) (onepassword.Item, error) {
  579. f.putCalled = true
  580. return onepassword.Item{}, nil
  581. }
  582. func (f *fakeLister) Delete(ctx context.Context, vaultID, itemID string) error {
  583. f.deleteCalled = true
  584. return nil
  585. }
  586. func (f *fakeLister) Archive(ctx context.Context, vaultID, itemID string) error {
  587. return nil
  588. }
  589. func (f *fakeLister) List(ctx context.Context, vaultID string, opts ...onepassword.ItemListFilter) ([]onepassword.ItemOverview, error) {
  590. return f.listAllResult, nil
  591. }
  592. func (f *fakeLister) Shares() onepassword.ItemsSharesAPI {
  593. return nil
  594. }
  595. func (f *fakeLister) Files() onepassword.ItemsFilesAPI {
  596. return f.fileLister
  597. }
  598. type fakeFileLister struct {
  599. readContent []byte
  600. }
  601. func (f *fakeFileLister) Attach(ctx context.Context, item onepassword.Item, fileParams onepassword.FileCreateParams) (onepassword.Item, error) {
  602. return onepassword.Item{}, nil
  603. }
  604. func (f *fakeFileLister) Read(ctx context.Context, vaultID, itemID string, attr onepassword.FileAttributes) ([]byte, error) {
  605. return f.readContent, nil
  606. }
  607. func (f *fakeFileLister) Delete(ctx context.Context, item onepassword.Item, sectionID, fieldID string) (onepassword.Item, error) {
  608. return onepassword.Item{}, nil
  609. }
  610. func (f *fakeFileLister) ReplaceDocument(ctx context.Context, item onepassword.Item, docParams onepassword.DocumentCreateParams) (onepassword.Item, error) {
  611. return onepassword.Item{}, nil
  612. }
  613. var _ onepassword.ItemsFilesAPI = (*fakeFileLister)(nil)
  614. type fakeClient struct {
  615. resolveResult string
  616. resolveError error
  617. resolveAll onepassword.ResolveAllResponse
  618. resolveAllError error
  619. listAllResult []onepassword.VaultOverview
  620. listAllError error
  621. }
  622. func (f *fakeClient) List(ctx context.Context) ([]onepassword.VaultOverview, error) {
  623. return f.listAllResult, f.listAllError
  624. }
  625. func (f *fakeClient) Resolve(ctx context.Context, secretReference string) (string, error) {
  626. return f.resolveResult, f.resolveError
  627. }
  628. func (f *fakeClient) ResolveAll(ctx context.Context, secretReferences []string) (onepassword.ResolveAllResponse, error) {
  629. return f.resolveAll, f.resolveAllError
  630. }
  631. func TestCachingGetSecret(t *testing.T) {
  632. t.Run("cache hit returns cached value", func(t *testing.T) {
  633. fcWithCounter := &fakeClientWithCounter{
  634. fakeClient: &fakeClient{
  635. resolveResult: "secret-value",
  636. },
  637. }
  638. p := &Provider{
  639. client: &onepassword.Client{
  640. SecretsAPI: fcWithCounter,
  641. VaultsAPI: fcWithCounter.fakeClient,
  642. },
  643. vaultPrefix: "op://vault/",
  644. }
  645. // Initialize cache
  646. p.cache = expirable.NewLRU[string, []byte](100, nil, time.Minute)
  647. ref := v1.ExternalSecretDataRemoteRef{Key: "item/field"}
  648. // First call - cache miss
  649. val1, err := p.GetSecret(t.Context(), ref)
  650. require.NoError(t, err)
  651. assert.Equal(t, []byte("secret-value"), val1)
  652. assert.Equal(t, 1, fcWithCounter.resolveCallCount)
  653. // Second call - cache hit, should not call API
  654. val2, err := p.GetSecret(t.Context(), ref)
  655. require.NoError(t, err)
  656. assert.Equal(t, []byte("secret-value"), val2)
  657. assert.Equal(t, 1, fcWithCounter.resolveCallCount, "API should not be called on cache hit")
  658. })
  659. t.Run("cache disabled works normally", func(t *testing.T) {
  660. fcWithCounter := &fakeClientWithCounter{
  661. fakeClient: &fakeClient{
  662. resolveResult: "secret-value",
  663. },
  664. }
  665. p := &Provider{
  666. client: &onepassword.Client{
  667. SecretsAPI: fcWithCounter,
  668. VaultsAPI: fcWithCounter.fakeClient,
  669. },
  670. vaultPrefix: "op://vault/",
  671. cache: nil, // Cache disabled
  672. }
  673. ref := v1.ExternalSecretDataRemoteRef{Key: "item/field"}
  674. // Multiple calls should always hit API
  675. _, err := p.GetSecret(t.Context(), ref)
  676. require.NoError(t, err)
  677. assert.Equal(t, 1, fcWithCounter.resolveCallCount)
  678. _, err = p.GetSecret(t.Context(), ref)
  679. require.NoError(t, err)
  680. assert.Equal(t, 2, fcWithCounter.resolveCallCount)
  681. })
  682. }
  683. func TestCachingGetSecretMap(t *testing.T) {
  684. t.Run("cache hit returns cached map", func(t *testing.T) {
  685. fc := &fakeClient{}
  686. flWithCounter := &fakeListerWithCounter{
  687. fakeLister: &fakeLister{
  688. listAllResult: []onepassword.ItemOverview{
  689. {
  690. ID: "item-id",
  691. Title: "item",
  692. Category: "login",
  693. VaultID: "vault-id",
  694. },
  695. },
  696. getResult: onepassword.Item{
  697. ID: "item-id",
  698. Title: "item",
  699. Category: "login",
  700. VaultID: "vault-id",
  701. Fields: []onepassword.ItemField{
  702. {Title: "username", Value: "user1"},
  703. {Title: "password", Value: "pass1"},
  704. },
  705. },
  706. },
  707. }
  708. p := &Provider{
  709. client: &onepassword.Client{
  710. SecretsAPI: fc,
  711. VaultsAPI: fc,
  712. ItemsAPI: flWithCounter,
  713. },
  714. vaultPrefix: "op://vault/",
  715. vaultID: "vault-id",
  716. cache: expirable.NewLRU[string, []byte](100, nil, time.Minute),
  717. }
  718. ref := v1.ExternalSecretDataRemoteRef{Key: "item"}
  719. // First call - cache miss
  720. val1, err := p.GetSecretMap(t.Context(), ref)
  721. require.NoError(t, err)
  722. assert.Equal(t, map[string][]byte{
  723. "username": []byte("user1"),
  724. "password": []byte("pass1"),
  725. }, val1)
  726. assert.Equal(t, 1, flWithCounter.getCallCount)
  727. // Second call - cache hit
  728. val2, err := p.GetSecretMap(t.Context(), ref)
  729. require.NoError(t, err)
  730. assert.Equal(t, val1, val2)
  731. assert.Equal(t, 1, flWithCounter.getCallCount, "API should not be called on cache hit")
  732. })
  733. }
  734. func TestCacheInvalidationPushSecret(t *testing.T) {
  735. t.Run("push secret invalidates cache", func(t *testing.T) {
  736. fcWithCounter := &fakeClientWithCounter{
  737. fakeClient: &fakeClient{
  738. resolveResult: "secret-value",
  739. },
  740. }
  741. fl := &fakeLister{
  742. listAllResult: []onepassword.ItemOverview{
  743. {ID: "item-id", Title: "item", VaultID: "vault-id"},
  744. },
  745. getResult: onepassword.Item{
  746. ID: "item-id",
  747. Title: "item",
  748. VaultID: "vault-id",
  749. Fields: []onepassword.ItemField{{Title: "password", Value: "old"}},
  750. },
  751. }
  752. p := &Provider{
  753. client: &onepassword.Client{
  754. SecretsAPI: fcWithCounter,
  755. VaultsAPI: fcWithCounter.fakeClient,
  756. ItemsAPI: fl,
  757. },
  758. vaultPrefix: "op://vault/",
  759. vaultID: "vault-id",
  760. cache: expirable.NewLRU[string, []byte](100, nil, time.Minute),
  761. }
  762. ref := v1.ExternalSecretDataRemoteRef{Key: "item/password"}
  763. // Populate cache
  764. val1, err := p.GetSecret(t.Context(), ref)
  765. require.NoError(t, err)
  766. assert.Equal(t, []byte("secret-value"), val1)
  767. assert.Equal(t, 1, fcWithCounter.resolveCallCount)
  768. // Push new value (should invalidate cache)
  769. pushRef := v1alpha1.PushSecretData{
  770. Match: v1alpha1.PushSecretMatch{
  771. SecretKey: "key",
  772. RemoteRef: v1alpha1.PushSecretRemoteRef{
  773. RemoteKey: "item",
  774. Property: "password",
  775. },
  776. },
  777. }
  778. secret := &corev1.Secret{
  779. Data: map[string][]byte{"key": []byte("new-value")},
  780. }
  781. err = p.PushSecret(t.Context(), secret, pushRef)
  782. require.NoError(t, err)
  783. // Next GetSecret should fetch fresh value (cache was invalidated)
  784. val2, err := p.GetSecret(t.Context(), ref)
  785. require.NoError(t, err)
  786. assert.Equal(t, []byte("secret-value"), val2)
  787. assert.Equal(t, 2, fcWithCounter.resolveCallCount, "Cache should have been invalidated")
  788. })
  789. }
  790. func TestCacheInvalidationDeleteSecret(t *testing.T) {
  791. t.Run("delete secret invalidates cache", func(t *testing.T) {
  792. fcWithCounter := &fakeClientWithCounter{
  793. fakeClient: &fakeClient{
  794. resolveResult: "cached-value",
  795. },
  796. }
  797. fl := &fakeLister{
  798. listAllResult: []onepassword.ItemOverview{
  799. {ID: "item-id", Title: "item", VaultID: "vault-id"},
  800. },
  801. getResult: onepassword.Item{
  802. ID: "item-id",
  803. Title: "item",
  804. VaultID: "vault-id",
  805. Fields: []onepassword.ItemField{
  806. {Title: "field1", Value: "val1"},
  807. {Title: "field2", Value: "val2"},
  808. },
  809. },
  810. }
  811. p := &Provider{
  812. client: &onepassword.Client{
  813. SecretsAPI: fcWithCounter,
  814. VaultsAPI: fcWithCounter.fakeClient,
  815. ItemsAPI: fl,
  816. },
  817. vaultPrefix: "op://vault/",
  818. vaultID: "vault-id",
  819. cache: expirable.NewLRU[string, []byte](100, nil, time.Minute),
  820. }
  821. ref := v1.ExternalSecretDataRemoteRef{Key: "item/field1"}
  822. // Populate cache
  823. _, err := p.GetSecret(t.Context(), ref)
  824. require.NoError(t, err)
  825. assert.Equal(t, 1, fcWithCounter.resolveCallCount)
  826. // Delete field (should invalidate cache)
  827. deleteRef := v1alpha1.PushSecretRemoteRef{
  828. RemoteKey: "item",
  829. Property: "field1",
  830. }
  831. err = p.DeleteSecret(t.Context(), deleteRef)
  832. require.NoError(t, err)
  833. // Next GetSecret should miss cache
  834. _, err = p.GetSecret(t.Context(), ref)
  835. require.NoError(t, err)
  836. assert.Equal(t, 2, fcWithCounter.resolveCallCount, "Cache should have been invalidated")
  837. })
  838. }
  839. func TestInvalidateCacheByPrefix(t *testing.T) {
  840. t.Run("invalidates all entries with prefix", func(t *testing.T) {
  841. p := &Provider{
  842. vaultPrefix: "op://vault/",
  843. cache: expirable.NewLRU[string, []byte](100, nil, time.Minute),
  844. }
  845. // Add multiple cache entries
  846. p.cache.Add("op://vault/item1/field1", []byte("val1"))
  847. p.cache.Add("op://vault/item1/field2", []byte("val2"))
  848. p.cache.Add("op://vault/item2/field1", []byte("val3"))
  849. // Invalidate item1 entries
  850. p.invalidateCacheByPrefix("op://vault/item1")
  851. // item1 entries should be gone
  852. _, ok1 := p.cache.Get("op://vault/item1/field1")
  853. assert.False(t, ok1)
  854. _, ok2 := p.cache.Get("op://vault/item1/field2")
  855. assert.False(t, ok2)
  856. // item2 entry should still exist
  857. val3, ok3 := p.cache.Get("op://vault/item2/field1")
  858. assert.True(t, ok3)
  859. assert.Equal(t, []byte("val3"), val3)
  860. })
  861. t.Run("handles nil cache gracefully", func(t *testing.T) {
  862. p := &Provider{
  863. vaultPrefix: "op://vault/",
  864. cache: nil,
  865. }
  866. // Should not panic
  867. p.invalidateCacheByPrefix("op://vault/item1")
  868. })
  869. t.Run("does not invalidate entries with similar prefixes", func(t *testing.T) {
  870. p := &Provider{
  871. vaultPrefix: "op://vault/",
  872. cache: expirable.NewLRU[string, []byte](100, nil, time.Minute),
  873. }
  874. p.cache.Add("op://vault/item/field1", []byte("val1"))
  875. p.cache.Add("op://vault/item/field2", []byte("val2"))
  876. p.cache.Add("op://vault/item|property", []byte("val3"))
  877. p.cache.Add("op://vault/item-backup/field1", []byte("val4"))
  878. p.cache.Add("op://vault/prod-db/secret", []byte("val5"))
  879. p.cache.Add("op://vault/prod-db-replica/secret", []byte("val6"))
  880. p.cache.Add("op://vault/prod-db-replica/secret|property", []byte("val7"))
  881. p.invalidateCacheByPrefix("op://vault/item")
  882. _, ok1 := p.cache.Get("op://vault/item/field1")
  883. assert.False(t, ok1)
  884. _, ok2 := p.cache.Get("op://vault/item/field2")
  885. assert.False(t, ok2)
  886. _, ok3 := p.cache.Get("op://vault/item|property")
  887. assert.False(t, ok3)
  888. val4, ok4 := p.cache.Get("op://vault/item-backup/field1")
  889. assert.True(t, ok4, "item-backup should not be invalidated")
  890. assert.Equal(t, []byte("val4"), val4)
  891. p.invalidateCacheByPrefix("op://vault/prod-db")
  892. _, ok5 := p.cache.Get("op://vault/prod-db/secret")
  893. assert.False(t, ok5)
  894. val6, ok6 := p.cache.Get("op://vault/prod-db-replica/secret")
  895. assert.True(t, ok6, "prod-db-replica/secret should not be invalidated")
  896. assert.Equal(t, []byte("val6"), val6)
  897. val7, ok7 := p.cache.Get("op://vault/prod-db-replica/secret|property")
  898. assert.True(t, ok7, "prod-db-replica/secret|property should not be invalidated")
  899. assert.Equal(t, []byte("val7"), val7)
  900. })
  901. }
  902. // fakeClientWithCounter wraps fakeClient and tracks Resolve call count.
  903. type fakeClientWithCounter struct {
  904. *fakeClient
  905. resolveCallCount int
  906. }
  907. func (f *fakeClientWithCounter) Resolve(ctx context.Context, secretReference string) (string, error) {
  908. f.resolveCallCount++
  909. return f.fakeClient.Resolve(ctx, secretReference)
  910. }
  911. // fakeListerWithCounter wraps fakeLister and tracks Get call count.
  912. type fakeListerWithCounter struct {
  913. *fakeLister
  914. getCallCount int
  915. }
  916. func (f *fakeListerWithCounter) Get(ctx context.Context, vaultID, itemID string) (onepassword.Item, error) {
  917. f.getCallCount++
  918. return f.fakeLister.Get(ctx, vaultID, itemID)
  919. }
  920. func (f *fakeListerWithCounter) Put(ctx context.Context, item onepassword.Item) (onepassword.Item, error) {
  921. return f.fakeLister.Put(ctx, item)
  922. }
  923. func (f *fakeListerWithCounter) Delete(ctx context.Context, vaultID, itemID string) error {
  924. return f.fakeLister.Delete(ctx, vaultID, itemID)
  925. }
  926. func (f *fakeListerWithCounter) Archive(ctx context.Context, vaultID, itemID string) error {
  927. return f.fakeLister.Archive(ctx, vaultID, itemID)
  928. }
  929. func (f *fakeListerWithCounter) List(ctx context.Context, vaultID string, opts ...onepassword.ItemListFilter) ([]onepassword.ItemOverview, error) {
  930. return f.fakeLister.List(ctx, vaultID, opts...)
  931. }
  932. func (f *fakeListerWithCounter) Shares() onepassword.ItemsSharesAPI {
  933. return f.fakeLister.Shares()
  934. }
  935. func (f *fakeListerWithCounter) Files() onepassword.ItemsFilesAPI {
  936. return f.fakeLister.Files()
  937. }
  938. func (f *fakeListerWithCounter) Create(ctx context.Context, item onepassword.ItemCreateParams) (onepassword.Item, error) {
  939. return f.fakeLister.Create(ctx, item)
  940. }
  941. var _ onepassword.SecretsAPI = &fakeClient{}
  942. var _ onepassword.VaultsAPI = &fakeClient{}
  943. var _ onepassword.ItemsAPI = &fakeLister{}
  944. var _ onepassword.SecretsAPI = &fakeClientWithCounter{}
  945. var _ onepassword.ItemsAPI = &fakeListerWithCounter{}