webhook_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484
  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. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  25. esv1beta1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1beta1"
  26. )
  27. type testCase struct {
  28. Case string `json:"case,omitempty"`
  29. Args args `json:"args"`
  30. Want want `json:"want"`
  31. }
  32. type args struct {
  33. URL string `json:"url,omitempty"`
  34. Body string `json:"body,omitempty"`
  35. Timeout string `json:"timeout,omitempty"`
  36. Key string `json:"key,omitempty"`
  37. Property string `json:"property,omitempty"`
  38. Version string `json:"version,omitempty"`
  39. JSONPath string `json:"jsonpath,omitempty"`
  40. Response string `json:"response,omitempty"`
  41. StatusCode int `json:"statuscode,omitempty"`
  42. }
  43. type want struct {
  44. Path string `json:"path,omitempty"`
  45. Body string `json:"body,omitempty"`
  46. Err string `json:"err,omitempty"`
  47. Result string `json:"result,omitempty"`
  48. ResultMap map[string]string `json:"resultmap,omitempty"`
  49. }
  50. var testCases = `
  51. case: error url
  52. args:
  53. url: /api/getsecret?id={{ .unclosed.template
  54. want:
  55. err: failed to parse url
  56. ---
  57. case: error body
  58. args:
  59. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  60. body: Body error {{ .unclosed.template
  61. want:
  62. err: failed to parse body
  63. ---
  64. case: error connection
  65. args:
  66. url: 1/api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  67. want:
  68. err: failed to call endpoint
  69. ---
  70. case: error no secret err
  71. args:
  72. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  73. key: testkey
  74. version: 1
  75. statuscode: 404
  76. response: not found
  77. want:
  78. path: /api/getsecret?id=testkey&version=1
  79. err: ` + esv1beta1.NoSecretErr.Error() + `
  80. ---
  81. case: error server error
  82. args:
  83. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  84. key: testkey
  85. version: 1
  86. statuscode: 500
  87. response: server error
  88. want:
  89. path: /api/getsecret?id=testkey&version=1
  90. err: endpoint gave error 500
  91. ---
  92. case: error bad json
  93. args:
  94. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  95. key: testkey
  96. version: 1
  97. jsonpath: $.result.thesecret
  98. response: '{"result":{"thesecret":"secret-value"}'
  99. want:
  100. path: /api/getsecret?id=testkey&version=1
  101. err: failed to parse response json
  102. ---
  103. case: error bad jsonpath
  104. args:
  105. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  106. key: testkey
  107. version: 1
  108. jsonpath: $.result.thesecret
  109. response: '{"result":{"nosecret":"secret-value"}}'
  110. want:
  111. path: /api/getsecret?id=testkey&version=1
  112. err: failed to get response path
  113. ---
  114. case: pull data out of map
  115. args:
  116. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  117. key: testkey
  118. version: 1
  119. jsonpath: $.result.thesecret
  120. response: '{"result":{"thesecret":{"one":"secret-value"}}}'
  121. want:
  122. path: /api/getsecret?id=testkey&version=1
  123. err: ''
  124. result: '{"one":"secret-value"}'
  125. ---
  126. case: error timeout
  127. args:
  128. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  129. key: testkey
  130. version: 1
  131. response: secret-value
  132. timeout: 0.01ms
  133. want:
  134. path: /api/getsecret?id=testkey&version=1
  135. err: context deadline exceeded
  136. ---
  137. case: good plaintext
  138. args:
  139. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  140. key: testkey
  141. version: 1
  142. response: secret-value
  143. want:
  144. path: /api/getsecret?id=testkey&version=1
  145. err: ''
  146. result: secret-value
  147. ---
  148. case: good json
  149. args:
  150. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  151. key: testkey
  152. version: 1
  153. jsonpath: $.result.thesecret
  154. response: '{"result":{"thesecret":"secret-value"}}'
  155. want:
  156. path: /api/getsecret?id=testkey&version=1
  157. err: ''
  158. result: secret-value
  159. ---
  160. case: good json map
  161. args:
  162. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  163. key: testkey
  164. version: 1
  165. jsonpath: $.result
  166. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value"}}'
  167. want:
  168. path: /api/getsecret?id=testkey&version=1
  169. err: ''
  170. resultmap:
  171. thesecret: secret-value
  172. alsosecret: another-value
  173. ---
  174. case: good json map string
  175. args:
  176. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  177. key: testkey
  178. version: 1
  179. response: '{"thesecret":"secret-value","alsosecret":"another-value"}'
  180. want:
  181. path: /api/getsecret?id=testkey&version=1
  182. err: ''
  183. resultmap:
  184. thesecret: secret-value
  185. alsosecret: another-value
  186. ---
  187. case: error json map string
  188. args:
  189. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  190. key: testkey
  191. version: 1
  192. response: 'some simple string'
  193. want:
  194. path: /api/getsecret?id=testkey&version=1
  195. err: "failed to parse response json: invalid character"
  196. resultmap: {}
  197. ---
  198. case: error json map
  199. args:
  200. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  201. key: testkey
  202. version: 1
  203. jsonpath: $.result.thesecret
  204. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value"}}'
  205. want:
  206. path: /api/getsecret?id=testkey&version=1
  207. err: "failed to parse response json from jsonpath"
  208. resultmap: {}
  209. ---
  210. case: good json with good templated jsonpath
  211. args:
  212. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  213. key: testkey
  214. property: thesecret
  215. version: 1
  216. jsonpath: $.result.{{ .remoteRef.property }}
  217. response: '{"result":{"thesecret":"secret-value"}}'
  218. want:
  219. path: /api/getsecret?id=testkey&version=1
  220. err: ''
  221. result: secret-value
  222. ---
  223. case: good json with jsonpath filter
  224. args:
  225. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  226. key: testkey
  227. version: 1
  228. jsonpath: $.secrets[?@.name=="thesecret"].value
  229. response: '{"secrets": [{"name": "thesecret", "value": "secret-value"}, {"name": "alsosecret", "value": "another-value"}]}'
  230. want:
  231. path: /api/getsecret?id=testkey&version=1
  232. err: ''
  233. result: secret-value
  234. ---
  235. case: good json with bad templated jsonpath
  236. args:
  237. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  238. key: testkey
  239. property: thesecret
  240. version: 1
  241. jsonpath: $.result.{{ .remoteRef.property }
  242. response: '{"result":{"thesecret":"secret-value"}}'
  243. want:
  244. path: /api/getsecret?id=testkey&version=1
  245. err: 'template: webhooktemplate:1: unexpected "}" in operand'
  246. ---
  247. case: error with jsonpath filter empty results
  248. args:
  249. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  250. key: testkey
  251. version: 1
  252. jsonpath: $.secrets[?@.name=="thebadsecret"].value
  253. response: '{"secrets": [{"name": "thesecret", "value": "secret-value"}, {"name": "alsosecret", "value": "another-value"}]}'
  254. want:
  255. path: /api/getsecret?id=testkey&version=1
  256. err: "filter worked but didn't get any result"
  257. ---
  258. case: success with jsonpath filter and result array
  259. args:
  260. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  261. key: testkey
  262. version: 1
  263. jsonpath: $..name
  264. response: '{"secrets": [{"name": "thesecret", "value": "secret-value"}, {"name": "alsosecret", "value": "another-value"}]}'
  265. want:
  266. path: /api/getsecret?id=testkey&version=1
  267. err: ''
  268. result: 'thesecret'
  269. ---
  270. case: success with jsonpath filter and result array of ints
  271. args:
  272. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  273. key: testkey
  274. version: 1
  275. jsonpath: $..name
  276. response: '{"secrets": [{"name": 123, "value": "secret-value"}, {"name": 456, "value": "another-value"}]}'
  277. want:
  278. path: /api/getsecret?id=testkey&version=1
  279. err: ''
  280. result: 123
  281. ---
  282. case: support backslash
  283. args:
  284. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  285. key: testkey
  286. version: 1
  287. jsonpath: $.refresh_token
  288. response: '{"access_token":"REDACTED","refresh_token":"RE\/DACTED=="}'
  289. want:
  290. path: /api/getsecret?id=testkey&version=1
  291. err: ''
  292. result: "RE/DACTED=="
  293. ---
  294. case: good json with mixed fields and jsonpath filter
  295. args:
  296. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  297. key: testkey
  298. version: 1
  299. jsonpath: $.result.thesecret
  300. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value", "id": 1234, "weight": 1.5}}'
  301. want:
  302. path: /api/getsecret?id=testkey&version=1
  303. err: ''
  304. result: secret-value
  305. ---
  306. case: good json with mixed fields to map
  307. args:
  308. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  309. key: testkey
  310. version: 1
  311. jsonpath: $.result
  312. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value", "id": 1234, "weight": 1.5}}'
  313. want:
  314. path: /api/getsecret?id=testkey&version=1
  315. err: ''
  316. resultmap:
  317. thesecret: secret-value
  318. alsosecret: another-value
  319. id: 1234
  320. weight: 1.5
  321. ---
  322. case: only url encoding for url templates
  323. args:
  324. url: /api/getsecrets?folder={{ .remoteRef.key }}
  325. body: '{"folder": "{{ .remoteRef.key }}"}'
  326. key: /myapp/secrets
  327. want:
  328. path: /api/getsecrets?folder=%2Fmyapp%2Fsecrets
  329. body: '{"folder": "/myapp/secrets"}'
  330. `
  331. func TestWebhookGetSecret(t *testing.T) {
  332. ydec := yaml.NewDecoder(bytes.NewReader([]byte(testCases)))
  333. for {
  334. var tc testCase
  335. if err := ydec.Decode(&tc); err != nil {
  336. if !errors.Is(err, io.EOF) {
  337. t.Errorf("testcase decode error %v", err)
  338. }
  339. break
  340. }
  341. runTestCase(tc, t)
  342. }
  343. }
  344. func testCaseServer(tc testCase, t *testing.T) *httptest.Server {
  345. // Start a new server for every test case because the server wants to check the expected api path
  346. return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
  347. if tc.Want.Path != "" && req.URL.String() != tc.Want.Path {
  348. t.Errorf("%s: unexpected api path: %s, expected %s", tc.Case, req.URL.String(), tc.Want.Path)
  349. }
  350. if tc.Want.Body != "" {
  351. b, _ := io.ReadAll(req.Body)
  352. if string(b) != tc.Want.Body {
  353. t.Errorf("%s: unexpected body: %s, expected %s", tc.Case, string(b), tc.Want.Body)
  354. }
  355. }
  356. if tc.Args.StatusCode != 0 {
  357. rw.WriteHeader(tc.Args.StatusCode)
  358. }
  359. rw.Write([]byte(tc.Args.Response))
  360. }))
  361. }
  362. func parseTimeout(timeout string) (*metav1.Duration, error) {
  363. if timeout == "" {
  364. return nil, nil
  365. }
  366. dur, err := time.ParseDuration(timeout)
  367. if err != nil {
  368. return nil, err
  369. }
  370. return &metav1.Duration{Duration: dur}, nil
  371. }
  372. func runTestCase(tc testCase, t *testing.T) {
  373. ts := testCaseServer(tc, t)
  374. defer ts.Close()
  375. testStore := makeClusterSecretStore(ts.URL, tc.Args)
  376. var err error
  377. timeout, err := parseTimeout(tc.Args.Timeout)
  378. if err != nil {
  379. t.Errorf("%s: error parsing timeout '%s': %s", tc.Case, tc.Args.Timeout, err.Error())
  380. return
  381. }
  382. testStore.Spec.Provider.Webhook.Timeout = timeout
  383. testProv := &Provider{}
  384. client, err := testProv.NewClient(context.Background(), testStore, nil, "testnamespace")
  385. if err != nil {
  386. t.Errorf("%s: error creating client: %s", tc.Case, err.Error())
  387. return
  388. }
  389. if tc.Want.ResultMap != nil {
  390. testGetSecretMap(tc, t, client)
  391. } else {
  392. testGetSecret(tc, t, client)
  393. }
  394. }
  395. func testGetSecretMap(tc testCase, t *testing.T, client esv1beta1.SecretsClient) {
  396. testRef := esv1beta1.ExternalSecretDataRemoteRef{
  397. Key: tc.Args.Key,
  398. Version: tc.Args.Version,
  399. }
  400. secretmap, err := client.GetSecretMap(context.Background(), testRef)
  401. errStr := ""
  402. if err != nil {
  403. errStr = err.Error()
  404. }
  405. if (tc.Want.Err == "") != (errStr == "") || !strings.Contains(errStr, tc.Want.Err) {
  406. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  407. }
  408. if err == nil {
  409. for wantkey, wantval := range tc.Want.ResultMap {
  410. gotval, ok := secretmap[wantkey]
  411. if !ok {
  412. t.Errorf("%s: unexpected response: wanted key '%s' not found", tc.Case, wantkey)
  413. } else if string(gotval) != wantval {
  414. t.Errorf("%s: unexpected response: key '%s' = '%s' (expected '%s')", tc.Case, wantkey, wantval, gotval)
  415. }
  416. }
  417. }
  418. }
  419. func testGetSecret(tc testCase, t *testing.T, client esv1beta1.SecretsClient) {
  420. testRef := esv1beta1.ExternalSecretDataRemoteRef{
  421. Key: tc.Args.Key,
  422. Property: tc.Args.Property,
  423. Version: tc.Args.Version,
  424. }
  425. ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  426. defer cancel()
  427. secret, err := client.GetSecret(ctx, testRef)
  428. errStr := ""
  429. if err != nil {
  430. errStr = err.Error()
  431. }
  432. if !strings.Contains(errStr, tc.Want.Err) {
  433. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  434. }
  435. if err == nil && string(secret) != tc.Want.Result {
  436. t.Errorf("%s: unexpected response: '%s' (expected '%s')", tc.Case, secret, tc.Want.Result)
  437. }
  438. }
  439. func makeClusterSecretStore(url string, args args) *esv1beta1.ClusterSecretStore {
  440. store := &esv1beta1.ClusterSecretStore{
  441. TypeMeta: metav1.TypeMeta{
  442. Kind: "ClusterSecretStore",
  443. },
  444. ObjectMeta: metav1.ObjectMeta{
  445. Name: "wehbook-store",
  446. Namespace: "default",
  447. },
  448. Spec: esv1beta1.SecretStoreSpec{
  449. Provider: &esv1beta1.SecretStoreProvider{
  450. Webhook: &esv1beta1.WebhookProvider{
  451. URL: url + args.URL,
  452. Body: args.Body,
  453. Headers: map[string]string{
  454. "Content-Type": "application.json",
  455. "X-SecretKey": "{{ .remoteRef.key }}",
  456. },
  457. Result: esv1beta1.WebhookResult{
  458. JSONPath: args.JSONPath,
  459. },
  460. },
  461. },
  462. },
  463. }
  464. return store
  465. }