client_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  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 dvls
  14. import (
  15. "context"
  16. "errors"
  17. "fmt"
  18. "net/http"
  19. "testing"
  20. "github.com/Devolutions/go-dvls"
  21. "github.com/stretchr/testify/assert"
  22. corev1 "k8s.io/api/core/v1"
  23. apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
  24. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  25. )
  26. type mockCredentialClient struct {
  27. entries map[string]dvls.Entry
  28. getErr error
  29. updateErr error
  30. deleteErr error
  31. lastUpdated dvls.Entry
  32. lastDeleted string
  33. }
  34. func newMockCredentialClient(entries map[string]dvls.Entry) *mockCredentialClient {
  35. if entries == nil {
  36. entries = make(map[string]dvls.Entry)
  37. }
  38. return &mockCredentialClient{entries: entries}
  39. }
  40. func (m *mockCredentialClient) GetByID(_ context.Context, _, entryID string) (dvls.Entry, error) {
  41. if m.getErr != nil {
  42. return dvls.Entry{}, m.getErr
  43. }
  44. if entry, ok := m.entries[entryID]; ok {
  45. return entry, nil
  46. }
  47. return dvls.Entry{}, &dvls.RequestError{Err: fmt.Errorf("unexpected status code %d", http.StatusNotFound), Url: entryID, StatusCode: http.StatusNotFound}
  48. }
  49. func (m *mockCredentialClient) Update(_ context.Context, entry dvls.Entry) (dvls.Entry, error) {
  50. if m.updateErr != nil {
  51. return entry, m.updateErr
  52. }
  53. m.entries[entry.Id] = entry
  54. m.lastUpdated = entry
  55. return entry, nil
  56. }
  57. func (m *mockCredentialClient) DeleteByID(_ context.Context, _, entryID string) error {
  58. if m.deleteErr != nil {
  59. return m.deleteErr
  60. }
  61. delete(m.entries, entryID)
  62. m.lastDeleted = entryID
  63. return nil
  64. }
  65. type pushSecretDataStub struct {
  66. remoteKey string
  67. secretKey string
  68. property string
  69. }
  70. func (p pushSecretDataStub) GetMetadata() *apiextensionsv1.JSON { return nil }
  71. func (p pushSecretDataStub) GetSecretKey() string { return p.secretKey }
  72. func (p pushSecretDataStub) GetRemoteKey() string { return p.remoteKey }
  73. func (p pushSecretDataStub) GetProperty() string { return p.property }
  74. type pushSecretRemoteRefStub struct {
  75. remoteKey string
  76. property string
  77. }
  78. func (p pushSecretRemoteRefStub) GetRemoteKey() string { return p.remoteKey }
  79. func (p pushSecretRemoteRefStub) GetProperty() string { return p.property }
  80. func TestClient_parseSecretRef(t *testing.T) {
  81. c := &Client{}
  82. t.Run("case 1: valid key format", func(t *testing.T) {
  83. vaultID, entryID, err := c.parseSecretRef("vault-123/entry-456")
  84. assert.NoError(t, err)
  85. assert.Equal(t, "vault-123", vaultID)
  86. assert.Equal(t, "entry-456", entryID)
  87. })
  88. t.Run("case 2: invalid key format - no separator", func(t *testing.T) {
  89. _, _, err := c.parseSecretRef("invalid-key")
  90. assert.Error(t, err)
  91. assert.Contains(t, err.Error(), "invalid key format")
  92. })
  93. t.Run("case 3: invalid key format - empty vault ID", func(t *testing.T) {
  94. _, _, err := c.parseSecretRef("/entry-456")
  95. assert.Error(t, err)
  96. assert.Contains(t, err.Error(), "vault ID cannot be empty")
  97. })
  98. t.Run("case 4: invalid key format - empty entry ID", func(t *testing.T) {
  99. _, _, err := c.parseSecretRef("vault-123/")
  100. assert.Error(t, err)
  101. assert.Contains(t, err.Error(), "entry ID cannot be empty")
  102. })
  103. t.Run("case 5: key with spaces", func(t *testing.T) {
  104. vaultID, entryID, err := c.parseSecretRef(" vault-123 / entry-456 ")
  105. assert.NoError(t, err)
  106. assert.Equal(t, "vault-123", vaultID)
  107. assert.Equal(t, "entry-456", entryID)
  108. })
  109. }
  110. func TestClient_Validate(t *testing.T) {
  111. t.Run("case 1: nil client", func(t *testing.T) {
  112. c := &Client{dvls: nil}
  113. result, err := c.Validate()
  114. assert.Error(t, err)
  115. assert.Equal(t, esv1.ValidationResultError, result)
  116. })
  117. t.Run("case 2: initialized client", func(t *testing.T) {
  118. c := &Client{dvls: newMockCredentialClient(nil)}
  119. result, err := c.Validate()
  120. assert.NoError(t, err)
  121. assert.Equal(t, esv1.ValidationResultReady, result)
  122. })
  123. }
  124. func TestNewClient(t *testing.T) {
  125. // Test that NewClient returns a non-nil client
  126. c := NewClient(nil)
  127. assert.NotNil(t, c)
  128. assert.Nil(t, c.dvls)
  129. }
  130. func TestClient_entryToSecretMap(t *testing.T) {
  131. c := &Client{}
  132. t.Run("Default credential type", func(t *testing.T) {
  133. entry := dvls.Entry{
  134. Id: "entry-id-123",
  135. Name: "test-entry",
  136. Type: dvls.EntryCredentialType,
  137. SubType: dvls.EntryCredentialSubTypeDefault,
  138. Data: &dvls.EntryCredentialDefaultData{
  139. Username: "testuser",
  140. Password: "testpass",
  141. Domain: "testdomain",
  142. },
  143. }
  144. secretMap, err := c.entryToSecretMap(entry)
  145. assert.NoError(t, err)
  146. assert.Equal(t, "entry-id-123", string(secretMap["entry-id"]))
  147. assert.Equal(t, "test-entry", string(secretMap["entry-name"]))
  148. assert.Equal(t, "testuser", string(secretMap["username"]))
  149. assert.Equal(t, "testpass", string(secretMap["password"]))
  150. assert.Equal(t, "testdomain", string(secretMap["domain"]))
  151. })
  152. t.Run("AccessCode credential type", func(t *testing.T) {
  153. entry := dvls.Entry{
  154. Id: "entry-id-456",
  155. Name: "access-code-entry",
  156. Type: dvls.EntryCredentialType,
  157. SubType: dvls.EntryCredentialSubTypeAccessCode,
  158. Data: &dvls.EntryCredentialAccessCodeData{
  159. Password: "access-code-123",
  160. },
  161. }
  162. secretMap, err := c.entryToSecretMap(entry)
  163. assert.NoError(t, err)
  164. assert.Equal(t, "entry-id-456", string(secretMap["entry-id"]))
  165. assert.Equal(t, "access-code-entry", string(secretMap["entry-name"]))
  166. assert.Equal(t, "access-code-123", string(secretMap["password"]))
  167. })
  168. t.Run("ApiKey credential type", func(t *testing.T) {
  169. entry := dvls.Entry{
  170. Id: "entry-id-789",
  171. Name: "api-key-entry",
  172. Type: dvls.EntryCredentialType,
  173. SubType: dvls.EntryCredentialSubTypeApiKey,
  174. Data: &dvls.EntryCredentialApiKeyData{
  175. ApiId: "api-id-123",
  176. ApiKey: "api-key-secret",
  177. TenantId: "tenant-123",
  178. },
  179. }
  180. secretMap, err := c.entryToSecretMap(entry)
  181. assert.NoError(t, err)
  182. assert.Equal(t, "entry-id-789", string(secretMap["entry-id"]))
  183. assert.Equal(t, "api-key-entry", string(secretMap["entry-name"]))
  184. assert.Equal(t, "api-id-123", string(secretMap["api-id"]))
  185. assert.Equal(t, "api-key-secret", string(secretMap["api-key"]))
  186. assert.Equal(t, "tenant-123", string(secretMap["tenant-id"]))
  187. })
  188. t.Run("AzureServicePrincipal credential type", func(t *testing.T) {
  189. entry := dvls.Entry{
  190. Id: "entry-id-azure",
  191. Name: "azure-sp-entry",
  192. Type: dvls.EntryCredentialType,
  193. SubType: dvls.EntryCredentialSubTypeAzureServicePrincipal,
  194. Data: &dvls.EntryCredentialAzureServicePrincipalData{
  195. ClientId: "client-id-123",
  196. ClientSecret: "client-secret-456",
  197. TenantId: "tenant-id-789",
  198. },
  199. }
  200. secretMap, err := c.entryToSecretMap(entry)
  201. assert.NoError(t, err)
  202. assert.Equal(t, "entry-id-azure", string(secretMap["entry-id"]))
  203. assert.Equal(t, "azure-sp-entry", string(secretMap["entry-name"]))
  204. assert.Equal(t, "client-id-123", string(secretMap["client-id"]))
  205. assert.Equal(t, "client-secret-456", string(secretMap["client-secret"]))
  206. assert.Equal(t, "tenant-id-789", string(secretMap["tenant-id"]))
  207. })
  208. t.Run("ConnectionString credential type", func(t *testing.T) {
  209. entry := dvls.Entry{
  210. Id: "entry-id-conn",
  211. Name: "connection-string-entry",
  212. Type: dvls.EntryCredentialType,
  213. SubType: dvls.EntryCredentialSubTypeConnectionString,
  214. Data: &dvls.EntryCredentialConnectionStringData{
  215. ConnectionString: "Server=localhost;Database=mydb;User=admin;Password=secret",
  216. },
  217. }
  218. secretMap, err := c.entryToSecretMap(entry)
  219. assert.NoError(t, err)
  220. assert.Equal(t, "entry-id-conn", string(secretMap["entry-id"]))
  221. assert.Equal(t, "connection-string-entry", string(secretMap["entry-name"]))
  222. assert.Equal(t, "Server=localhost;Database=mydb;User=admin;Password=secret", string(secretMap["connection-string"]))
  223. })
  224. t.Run("PrivateKey credential type", func(t *testing.T) {
  225. entry := dvls.Entry{
  226. Id: "entry-id-pk",
  227. Name: "private-key-entry",
  228. Type: dvls.EntryCredentialType,
  229. SubType: dvls.EntryCredentialSubTypePrivateKey,
  230. Data: &dvls.EntryCredentialPrivateKeyData{
  231. Username: "ssh-user",
  232. Password: "key-password",
  233. PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIE...",
  234. PublicKey: "ssh-rsa AAAA...",
  235. Passphrase: "my-passphrase",
  236. },
  237. }
  238. secretMap, err := c.entryToSecretMap(entry)
  239. assert.NoError(t, err)
  240. assert.Equal(t, "entry-id-pk", string(secretMap["entry-id"]))
  241. assert.Equal(t, "private-key-entry", string(secretMap["entry-name"]))
  242. assert.Equal(t, "ssh-user", string(secretMap["username"]))
  243. assert.Equal(t, "key-password", string(secretMap["password"]))
  244. assert.Equal(t, "-----BEGIN RSA PRIVATE KEY-----\nMIIE...", string(secretMap["private-key"]))
  245. assert.Equal(t, "ssh-rsa AAAA...", string(secretMap["public-key"]))
  246. assert.Equal(t, "my-passphrase", string(secretMap["passphrase"]))
  247. })
  248. t.Run("Unsupported credential type", func(t *testing.T) {
  249. entry := dvls.Entry{
  250. Id: "entry-id-unknown",
  251. Name: "unknown-entry",
  252. Type: dvls.EntryCredentialType,
  253. SubType: "UnknownType",
  254. }
  255. _, err := c.entryToSecretMap(entry)
  256. assert.Error(t, err)
  257. assert.Contains(t, err.Error(), "unsupported credential subtype")
  258. })
  259. t.Run("Default credential with partial data", func(t *testing.T) {
  260. entry := dvls.Entry{
  261. Id: "entry-id-partial",
  262. Name: "partial-entry",
  263. Type: dvls.EntryCredentialType,
  264. SubType: dvls.EntryCredentialSubTypeDefault,
  265. Data: &dvls.EntryCredentialDefaultData{
  266. Username: "onlyuser",
  267. // Password and Domain are empty
  268. },
  269. }
  270. secretMap, err := c.entryToSecretMap(entry)
  271. assert.NoError(t, err)
  272. assert.Equal(t, "entry-id-partial", string(secretMap["entry-id"]))
  273. assert.Equal(t, "partial-entry", string(secretMap["entry-name"]))
  274. assert.Equal(t, "onlyuser", string(secretMap["username"]))
  275. // Empty fields should not be included
  276. _, hasPassword := secretMap["password"]
  277. _, hasDomain := secretMap["domain"]
  278. assert.False(t, hasPassword)
  279. assert.False(t, hasDomain)
  280. })
  281. }
  282. func TestClient_GetSecret_NotFound(t *testing.T) {
  283. c := NewClient(newMockCredentialClient(nil))
  284. _, err := c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "vault/entry"})
  285. assert.ErrorIs(t, err, esv1.NoSecretErr)
  286. }
  287. func TestClient_GetSecretAndMap_Success(t *testing.T) {
  288. entry := dvls.Entry{
  289. Id: "entry-1",
  290. Name: "test-entry",
  291. Type: dvls.EntryCredentialType,
  292. SubType: dvls.EntryCredentialSubTypeDefault,
  293. Data: &dvls.EntryCredentialDefaultData{
  294. Password: "super-secret",
  295. },
  296. }
  297. mockClient := newMockCredentialClient(map[string]dvls.Entry{"entry-1": entry})
  298. c := NewClient(mockClient)
  299. val, err := c.GetSecret(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "vault-1/entry-1", Property: "password"})
  300. assert.NoError(t, err)
  301. assert.Equal(t, "super-secret", string(val))
  302. secretMap, err := c.GetSecretMap(context.Background(), esv1.ExternalSecretDataRemoteRef{Key: "vault-1/entry-1"})
  303. assert.NoError(t, err)
  304. assert.Equal(t, "super-secret", string(secretMap["password"]))
  305. assert.Equal(t, "test-entry", string(secretMap["entry-name"]))
  306. }
  307. func TestClient_SecretExists(t *testing.T) {
  308. mockClient := newMockCredentialClient(nil)
  309. c := NewClient(mockClient)
  310. exists, err := c.SecretExists(context.Background(), pushSecretRemoteRefStub{remoteKey: "vault/entry"})
  311. assert.NoError(t, err)
  312. assert.False(t, exists)
  313. mockClient.entries["entry"] = dvls.Entry{Id: "entry", Type: dvls.EntryCredentialType, SubType: dvls.EntryCredentialSubTypeDefault}
  314. exists, err = c.SecretExists(context.Background(), pushSecretRemoteRefStub{remoteKey: "vault/entry"})
  315. assert.NoError(t, err)
  316. assert.True(t, exists)
  317. mockClient.getErr = errors.New("boom")
  318. _, err = c.SecretExists(context.Background(), pushSecretRemoteRefStub{remoteKey: "vault/entry"})
  319. assert.Error(t, err)
  320. }
  321. func TestClient_DeleteSecret(t *testing.T) {
  322. mockClient := newMockCredentialClient(map[string]dvls.Entry{"entry": {Id: "entry", Type: dvls.EntryCredentialType, SubType: dvls.EntryCredentialSubTypeAccessCode}})
  323. c := NewClient(mockClient)
  324. err := c.DeleteSecret(context.Background(), pushSecretRemoteRefStub{remoteKey: "vault/entry"})
  325. assert.NoError(t, err)
  326. assert.Equal(t, "entry", mockClient.lastDeleted)
  327. }
  328. func TestClient_PushSecret_UpdateDefault(t *testing.T) {
  329. mockClient := newMockCredentialClient(map[string]dvls.Entry{
  330. "entry": {Id: "entry", Type: dvls.EntryCredentialType, SubType: dvls.EntryCredentialSubTypeDefault},
  331. })
  332. c := NewClient(mockClient)
  333. secret := &corev1.Secret{
  334. Data: map[string][]byte{
  335. "password": []byte("new-value"),
  336. },
  337. }
  338. data := pushSecretDataStub{remoteKey: "vault/entry", secretKey: "password"}
  339. err := c.PushSecret(context.Background(), secret, data)
  340. assert.NoError(t, err)
  341. updatedEntry := mockClient.entries["entry"]
  342. credData, ok := updatedEntry.Data.(*dvls.EntryCredentialDefaultData)
  343. assert.True(t, ok)
  344. assert.Equal(t, "new-value", credData.Password)
  345. }
  346. func TestClient_PushSecret_UpdateAccessCode(t *testing.T) {
  347. mockClient := newMockCredentialClient(map[string]dvls.Entry{
  348. "entry": {Id: "entry", Type: dvls.EntryCredentialType, SubType: dvls.EntryCredentialSubTypeAccessCode},
  349. })
  350. c := NewClient(mockClient)
  351. secret := &corev1.Secret{
  352. Data: map[string][]byte{
  353. "code": []byte("code-value"),
  354. },
  355. }
  356. data := pushSecretDataStub{remoteKey: "vault/entry", secretKey: "code"}
  357. err := c.PushSecret(context.Background(), secret, data)
  358. assert.NoError(t, err)
  359. updatedEntry := mockClient.entries["entry"]
  360. credData, ok := updatedEntry.Data.(*dvls.EntryCredentialAccessCodeData)
  361. assert.True(t, ok)
  362. assert.Equal(t, "code-value", credData.Password)
  363. }
  364. func TestClient_PushSecret_NotFound(t *testing.T) {
  365. c := NewClient(newMockCredentialClient(nil))
  366. secret := &corev1.Secret{Data: map[string][]byte{"password": []byte("pw")}}
  367. data := pushSecretDataStub{remoteKey: "vault/missing", secretKey: "password"}
  368. err := c.PushSecret(context.Background(), secret, data)
  369. assert.Error(t, err)
  370. assert.Contains(t, err.Error(), "not found")
  371. }
  372. func TestClient_PushSecret_UnsupportedSubtype(t *testing.T) {
  373. mockClient := newMockCredentialClient(map[string]dvls.Entry{
  374. "entry": {Id: "entry", Type: dvls.EntryCredentialType, SubType: dvls.EntryCredentialSubTypeApiKey},
  375. })
  376. c := NewClient(mockClient)
  377. secret := &corev1.Secret{Data: map[string][]byte{"password": []byte("pw")}}
  378. data := pushSecretDataStub{remoteKey: "vault/entry", secretKey: "password"}
  379. err := c.PushSecret(context.Background(), secret, data)
  380. assert.Error(t, err)
  381. assert.Contains(t, err.Error(), "cannot set secret for credential subtype")
  382. }