webhook_test.go 12 KB

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