webhook_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. /*
  2. Licensed under the Apache License, Version 2.0 (the "License");
  3. you may not use this file except in compliance with the License.
  4. You may obtain a copy of the License at
  5. http://www.apache.org/licenses/LICENSE-2.0
  6. Unless required by applicable law or agreed to in writing, software
  7. distributed under the License is distributed on an "AS IS" BASIS,
  8. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  9. See the License for the specific language governing permissions and
  10. limitations under the License.
  11. */
  12. package webhook
  13. import (
  14. "bytes"
  15. "context"
  16. "errors"
  17. "io"
  18. "net/http"
  19. "net/http/httptest"
  20. "strings"
  21. "testing"
  22. "time"
  23. "gopkg.in/yaml.v3"
  24. corev1 "k8s.io/api/core/v1"
  25. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  26. "github.com/external-secrets/external-secrets/apis/externalsecrets/v1alpha1"
  27. esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
  28. )
  29. type testCase struct {
  30. Case string `json:"case,omitempty"`
  31. Args args `json:"args"`
  32. Want want `json:"want"`
  33. }
  34. type secret struct {
  35. Name string `json:"name"`
  36. Data map[string]string `json:"data"`
  37. }
  38. type args struct {
  39. URL string `json:"url,omitempty"`
  40. Body string `json:"body,omitempty"`
  41. Timeout string `json:"timeout,omitempty"`
  42. Key string `json:"key,omitempty"`
  43. SecretKey string `json:"secretkey,omitempty"`
  44. Property string `json:"property,omitempty"`
  45. Version string `json:"version,omitempty"`
  46. JSONPath string `json:"jsonpath,omitempty"`
  47. Response string `json:"response,omitempty"`
  48. StatusCode int `json:"statuscode,omitempty"`
  49. PushSecret bool `json:"pushsecret,omitempty"`
  50. Secret secret `json:"secret,omitempty"`
  51. }
  52. type want struct {
  53. Path string `json:"path,omitempty"`
  54. Body string `json:"body,omitempty"`
  55. Err string `json:"err,omitempty"`
  56. Result string `json:"result,omitempty"`
  57. ResultMap map[string]string `json:"resultmap,omitempty"`
  58. }
  59. var testCases = `
  60. case: error url
  61. args:
  62. url: /api/getsecret?id={{ .unclosed.template
  63. want:
  64. err: failed to parse url
  65. ---
  66. case: error body
  67. args:
  68. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  69. body: Body error {{ .unclosed.template
  70. want:
  71. err: failed to parse body
  72. ---
  73. case: error connection
  74. args:
  75. url: 1/api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  76. want:
  77. err: failed to call endpoint
  78. ---
  79. case: error no secret err
  80. args:
  81. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  82. key: testkey
  83. version: 1
  84. statuscode: 404
  85. response: not found
  86. want:
  87. path: /api/getsecret?id=testkey&version=1
  88. err: ` + esv1beta1.NoSecretErr.Error() + `
  89. ---
  90. case: error server error
  91. args:
  92. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  93. key: testkey
  94. version: 1
  95. statuscode: 500
  96. response: server error
  97. want:
  98. path: /api/getsecret?id=testkey&version=1
  99. err: endpoint gave error 500
  100. ---
  101. case: error bad json
  102. args:
  103. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  104. key: testkey
  105. version: 1
  106. jsonpath: $.result.thesecret
  107. response: '{"result":{"thesecret":"secret-value"}'
  108. want:
  109. path: /api/getsecret?id=testkey&version=1
  110. err: failed to parse response json
  111. ---
  112. case: error bad jsonpath
  113. args:
  114. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  115. key: testkey
  116. version: 1
  117. jsonpath: $.result.thesecret
  118. response: '{"result":{"nosecret":"secret-value"}}'
  119. want:
  120. path: /api/getsecret?id=testkey&version=1
  121. err: failed to get response path
  122. ---
  123. case: pull data out of map
  124. args:
  125. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  126. key: testkey
  127. version: 1
  128. jsonpath: $.result.thesecret
  129. response: '{"result":{"thesecret":{"one":"secret-value"}}}'
  130. want:
  131. path: /api/getsecret?id=testkey&version=1
  132. err: ''
  133. result: '{"one":"secret-value"}'
  134. ---
  135. case: error timeout
  136. args:
  137. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  138. key: testkey
  139. version: 1
  140. response: secret-value
  141. timeout: 0.01ms
  142. want:
  143. path: /api/getsecret?id=testkey&version=1
  144. err: context deadline exceeded
  145. ---
  146. case: good plaintext
  147. args:
  148. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  149. key: testkey
  150. version: 1
  151. response: secret-value
  152. want:
  153. path: /api/getsecret?id=testkey&version=1
  154. err: ''
  155. result: secret-value
  156. ---
  157. case: good json
  158. args:
  159. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  160. key: testkey
  161. version: 1
  162. jsonpath: $.result.thesecret
  163. response: '{"result":{"thesecret":"secret-value"}}'
  164. want:
  165. path: /api/getsecret?id=testkey&version=1
  166. err: ''
  167. result: secret-value
  168. ---
  169. case: good json map
  170. args:
  171. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  172. key: testkey
  173. version: 1
  174. jsonpath: $.result
  175. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value"}}'
  176. want:
  177. path: /api/getsecret?id=testkey&version=1
  178. err: ''
  179. resultmap:
  180. thesecret: secret-value
  181. alsosecret: another-value
  182. ---
  183. case: good json map string
  184. args:
  185. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  186. key: testkey
  187. version: 1
  188. response: '{"thesecret":"secret-value","alsosecret":"another-value"}'
  189. want:
  190. path: /api/getsecret?id=testkey&version=1
  191. err: ''
  192. resultmap:
  193. thesecret: secret-value
  194. alsosecret: another-value
  195. ---
  196. case: error json map string
  197. args:
  198. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  199. key: testkey
  200. version: 1
  201. response: 'some simple string'
  202. want:
  203. path: /api/getsecret?id=testkey&version=1
  204. err: "failed to parse response json: invalid character"
  205. resultmap: {}
  206. ---
  207. case: error json map
  208. args:
  209. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  210. key: testkey
  211. version: 1
  212. jsonpath: $.result.thesecret
  213. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value"}}'
  214. want:
  215. path: /api/getsecret?id=testkey&version=1
  216. err: "failed to parse response json from jsonpath"
  217. resultmap: {}
  218. ---
  219. case: good json with good templated jsonpath
  220. args:
  221. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  222. key: testkey
  223. property: thesecret
  224. version: 1
  225. jsonpath: $.result.{{ .remoteRef.property }}
  226. response: '{"result":{"thesecret":"secret-value"}}'
  227. want:
  228. path: /api/getsecret?id=testkey&version=1
  229. err: ''
  230. result: secret-value
  231. ---
  232. case: good json with jsonpath filter
  233. args:
  234. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  235. key: testkey
  236. version: 1
  237. jsonpath: $.secrets[?@.name=="thesecret"].value
  238. response: '{"secrets": [{"name": "thesecret", "value": "secret-value"}, {"name": "alsosecret", "value": "another-value"}]}'
  239. want:
  240. path: /api/getsecret?id=testkey&version=1
  241. err: ''
  242. result: secret-value
  243. ---
  244. case: good json with bad templated jsonpath
  245. args:
  246. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  247. key: testkey
  248. property: thesecret
  249. version: 1
  250. jsonpath: $.result.{{ .remoteRef.property }
  251. response: '{"result":{"thesecret":"secret-value"}}'
  252. want:
  253. path: /api/getsecret?id=testkey&version=1
  254. err: 'template: webhooktemplate:1: unexpected "}" in operand'
  255. ---
  256. case: error with jsonpath filter empty results
  257. args:
  258. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  259. key: testkey
  260. version: 1
  261. jsonpath: $.secrets[?@.name=="thebadsecret"].value
  262. response: '{"secrets": [{"name": "thesecret", "value": "secret-value"}, {"name": "alsosecret", "value": "another-value"}]}'
  263. want:
  264. path: /api/getsecret?id=testkey&version=1
  265. err: "filter worked but didn't get any result"
  266. ---
  267. case: success with jsonpath filter and result array
  268. args:
  269. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  270. key: testkey
  271. version: 1
  272. jsonpath: $..name
  273. response: '{"secrets": [{"name": "thesecret", "value": "secret-value"}, {"name": "alsosecret", "value": "another-value"}]}'
  274. want:
  275. path: /api/getsecret?id=testkey&version=1
  276. err: ''
  277. result: 'thesecret'
  278. ---
  279. case: success with jsonpath filter and result array of ints
  280. args:
  281. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  282. key: testkey
  283. version: 1
  284. jsonpath: $..name
  285. response: '{"secrets": [{"name": 123, "value": "secret-value"}, {"name": 456, "value": "another-value"}]}'
  286. want:
  287. path: /api/getsecret?id=testkey&version=1
  288. err: ''
  289. result: 123
  290. ---
  291. case: support backslash
  292. args:
  293. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  294. key: testkey
  295. version: 1
  296. jsonpath: $.refresh_token
  297. response: '{"access_token":"REDACTED","refresh_token":"RE\/DACTED=="}'
  298. want:
  299. path: /api/getsecret?id=testkey&version=1
  300. err: ''
  301. result: "RE/DACTED=="
  302. ---
  303. case: good json with mixed fields and jsonpath filter
  304. args:
  305. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  306. key: testkey
  307. version: 1
  308. jsonpath: $.result.thesecret
  309. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value", "id": 1234, "weight": 1.5}}'
  310. want:
  311. path: /api/getsecret?id=testkey&version=1
  312. err: ''
  313. result: secret-value
  314. ---
  315. case: good json with mixed fields to map
  316. args:
  317. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  318. key: testkey
  319. version: 1
  320. jsonpath: $.result
  321. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value", "id": 1234, "weight": 1.5}}'
  322. want:
  323. path: /api/getsecret?id=testkey&version=1
  324. err: ''
  325. resultmap:
  326. thesecret: secret-value
  327. alsosecret: another-value
  328. id: 1234
  329. weight: 1.5
  330. ---
  331. case: only url encoding for url templates
  332. args:
  333. url: /api/getsecrets?folder={{ .remoteRef.key }}
  334. body: '{"folder": "{{ .remoteRef.key }}"}'
  335. key: /myapp/secrets
  336. want:
  337. path: /api/getsecrets?folder=%2Fmyapp%2Fsecrets
  338. body: '{"folder": "/myapp/secrets"}'
  339. `
  340. func TestWebhookGetSecret(t *testing.T) {
  341. ydec := yaml.NewDecoder(bytes.NewReader([]byte(testCases)))
  342. for {
  343. var tc testCase
  344. if err := ydec.Decode(&tc); err != nil {
  345. if !errors.Is(err, io.EOF) {
  346. t.Errorf("testcase decode error %v", err)
  347. }
  348. break
  349. }
  350. runTestCase(tc, t)
  351. }
  352. }
  353. var testCasesPushSecret = `
  354. case: good json
  355. args:
  356. url: /api/pushsecret?id={{ .remoteRef.remoteKey }}&secret={{ .remoteRef.secretKey }}
  357. key: testkey
  358. secretkey: secretkey
  359. pushsecret: true
  360. secret:
  361. name: test-secret
  362. data:
  363. secretkey: value
  364. want:
  365. path: /api/pushsecret?id=testkey&secret=secretkey
  366. err: ''
  367. ---
  368. case: secret key not found
  369. args:
  370. url: /api/pushsecret?id={{ .remoteRef.remoteKey }}&secret={{ .remoteRef.secretKey }}
  371. key: testkey
  372. secretkey: not-found
  373. pushsecret: true
  374. secret:
  375. name: test-secret
  376. data:
  377. secretkey: value
  378. want:
  379. path: /api/pushsecret?id=testkey&secret=not-found
  380. err: 'failed to find secret key in secret with key: not-found'
  381. ---
  382. case: pushing without secret key
  383. args:
  384. url: /api/pushsecret?id={{ .remoteRef.remoteKey }}
  385. key: testkey
  386. pushsecret: true
  387. secret:
  388. name: test-secret
  389. data:
  390. secretkey: value
  391. want:
  392. path: /api/pushsecret?id=testkey
  393. err: ''
  394. ---
  395. `
  396. func TestWebhookPushSecret(t *testing.T) {
  397. ydec := yaml.NewDecoder(bytes.NewReader([]byte(testCasesPushSecret)))
  398. for {
  399. var tc testCase
  400. if err := ydec.Decode(&tc); err != nil {
  401. if !errors.Is(err, io.EOF) {
  402. t.Errorf("testcase decode error %v", err)
  403. }
  404. break
  405. }
  406. runTestCase(tc, t)
  407. }
  408. }
  409. func testCaseServer(tc testCase, t *testing.T) *httptest.Server {
  410. // Start a new server for every test case because the server wants to check the expected api path
  411. return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
  412. if tc.Want.Path != "" && req.URL.String() != tc.Want.Path {
  413. t.Errorf("%s: unexpected api path: %s, expected %s", tc.Case, req.URL.String(), tc.Want.Path)
  414. }
  415. if tc.Want.Body != "" {
  416. b, _ := io.ReadAll(req.Body)
  417. if string(b) != tc.Want.Body {
  418. t.Errorf("%s: unexpected body: %s, expected %s", tc.Case, string(b), tc.Want.Body)
  419. }
  420. }
  421. if tc.Args.StatusCode != 0 {
  422. rw.WriteHeader(tc.Args.StatusCode)
  423. }
  424. rw.Write([]byte(tc.Args.Response))
  425. }))
  426. }
  427. func parseTimeout(timeout string) (*metav1.Duration, error) {
  428. if timeout == "" {
  429. return nil, nil
  430. }
  431. dur, err := time.ParseDuration(timeout)
  432. if err != nil {
  433. return nil, err
  434. }
  435. return &metav1.Duration{Duration: dur}, nil
  436. }
  437. func runTestCase(tc testCase, t *testing.T) {
  438. ts := testCaseServer(tc, t)
  439. defer ts.Close()
  440. testStore := makeClusterSecretStore(ts.URL, tc.Args)
  441. var err error
  442. timeout, err := parseTimeout(tc.Args.Timeout)
  443. if err != nil {
  444. t.Errorf("%s: error parsing timeout '%s': %s", tc.Case, tc.Args.Timeout, err.Error())
  445. return
  446. }
  447. testStore.Spec.Provider.Webhook.Timeout = timeout
  448. testProv := &Provider{}
  449. client, err := testProv.NewClient(context.Background(), testStore, nil, "testnamespace")
  450. if err != nil {
  451. t.Errorf("%s: error creating client: %s", tc.Case, err.Error())
  452. return
  453. }
  454. if tc.Want.ResultMap != nil && !tc.Args.PushSecret {
  455. testGetSecretMap(tc, t, client)
  456. } else if !tc.Args.PushSecret {
  457. testGetSecret(tc, t, client)
  458. } else {
  459. testPushSecret(tc, t, client)
  460. }
  461. }
  462. func testGetSecretMap(tc testCase, t *testing.T, client esv1beta1.SecretsClient) {
  463. testRef := esv1beta1.ExternalSecretDataRemoteRef{
  464. Key: tc.Args.Key,
  465. Version: tc.Args.Version,
  466. }
  467. secretmap, err := client.GetSecretMap(context.Background(), testRef)
  468. errStr := ""
  469. if err != nil {
  470. errStr = err.Error()
  471. }
  472. if (tc.Want.Err == "") != (errStr == "") || !strings.Contains(errStr, tc.Want.Err) {
  473. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  474. }
  475. if err == nil {
  476. for wantkey, wantval := range tc.Want.ResultMap {
  477. gotval, ok := secretmap[wantkey]
  478. if !ok {
  479. t.Errorf("%s: unexpected response: wanted key '%s' not found", tc.Case, wantkey)
  480. } else if string(gotval) != wantval {
  481. t.Errorf("%s: unexpected response: key '%s' = '%s' (expected '%s')", tc.Case, wantkey, wantval, gotval)
  482. }
  483. }
  484. }
  485. }
  486. func testGetSecret(tc testCase, t *testing.T, client esv1beta1.SecretsClient) {
  487. testRef := esv1beta1.ExternalSecretDataRemoteRef{
  488. Key: tc.Args.Key,
  489. Property: tc.Args.Property,
  490. Version: tc.Args.Version,
  491. }
  492. ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  493. defer cancel()
  494. secret, err := client.GetSecret(ctx, testRef)
  495. errStr := ""
  496. if err != nil {
  497. errStr = err.Error()
  498. }
  499. if !strings.Contains(errStr, tc.Want.Err) {
  500. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  501. }
  502. if err == nil && string(secret) != tc.Want.Result {
  503. t.Errorf("%s: unexpected response: '%s' (expected '%s')", tc.Case, secret, tc.Want.Result)
  504. }
  505. }
  506. func testPushSecret(tc testCase, t *testing.T, client esv1beta1.SecretsClient) {
  507. testRef := v1alpha1.PushSecretData{
  508. Match: v1alpha1.PushSecretMatch{
  509. SecretKey: tc.Args.SecretKey,
  510. RemoteRef: v1alpha1.PushSecretRemoteRef{
  511. RemoteKey: tc.Args.Key,
  512. },
  513. },
  514. }
  515. data := map[string][]byte{}
  516. for k, v := range tc.Args.Secret.Data {
  517. data[k] = []byte(v)
  518. }
  519. sec := &corev1.Secret{
  520. ObjectMeta: metav1.ObjectMeta{
  521. Name: tc.Args.Secret.Name,
  522. },
  523. Data: data,
  524. }
  525. ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  526. defer cancel()
  527. err := client.PushSecret(ctx, sec, testRef)
  528. errStr := ""
  529. if err != nil {
  530. errStr = err.Error()
  531. }
  532. if tc.Want.Err == "" && errStr != "" {
  533. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  534. }
  535. if !strings.Contains(errStr, tc.Want.Err) {
  536. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  537. }
  538. }
  539. func makeClusterSecretStore(url string, args args) *esv1beta1.ClusterSecretStore {
  540. store := &esv1beta1.ClusterSecretStore{
  541. TypeMeta: metav1.TypeMeta{
  542. Kind: "ClusterSecretStore",
  543. },
  544. ObjectMeta: metav1.ObjectMeta{
  545. Name: "wehbook-store",
  546. Namespace: "default",
  547. },
  548. Spec: esv1beta1.SecretStoreSpec{
  549. Provider: &esv1beta1.SecretStoreProvider{
  550. Webhook: &esv1beta1.WebhookProvider{
  551. URL: url + args.URL,
  552. Body: args.Body,
  553. Headers: map[string]string{
  554. "Content-Type": "application.json",
  555. "X-SecretKey": "{{ .remoteRef.key }}",
  556. },
  557. Result: esv1beta1.WebhookResult{
  558. JSONPath: args.JSONPath,
  559. },
  560. },
  561. },
  562. },
  563. }
  564. return store
  565. }