webhook_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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: error bad json data
  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: failed to get response (wrong type
  123. ---
  124. case: error timeout
  125. args:
  126. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  127. key: testkey
  128. version: 1
  129. response: secret-value
  130. timeout: 0.01ms
  131. want:
  132. path: /api/getsecret?id=testkey&version=1
  133. err: context deadline exceeded
  134. ---
  135. case: good plaintext
  136. args:
  137. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  138. key: testkey
  139. version: 1
  140. response: secret-value
  141. want:
  142. path: /api/getsecret?id=testkey&version=1
  143. err: ''
  144. result: secret-value
  145. ---
  146. case: good json
  147. args:
  148. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  149. key: testkey
  150. version: 1
  151. jsonpath: $.result.thesecret
  152. response: '{"result":{"thesecret":"secret-value"}}'
  153. want:
  154. path: /api/getsecret?id=testkey&version=1
  155. err: ''
  156. result: secret-value
  157. ---
  158. case: good json map
  159. args:
  160. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  161. key: testkey
  162. version: 1
  163. jsonpath: $.result
  164. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value"}}'
  165. want:
  166. path: /api/getsecret?id=testkey&version=1
  167. err: ''
  168. resultmap:
  169. thesecret: secret-value
  170. alsosecret: another-value
  171. ---
  172. case: good json map string
  173. args:
  174. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  175. key: testkey
  176. version: 1
  177. response: '{"thesecret":"secret-value","alsosecret":"another-value"}'
  178. want:
  179. path: /api/getsecret?id=testkey&version=1
  180. err: ''
  181. resultmap:
  182. thesecret: secret-value
  183. alsosecret: another-value
  184. ---
  185. case: error json map string
  186. args:
  187. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  188. key: testkey
  189. version: 1
  190. response: 'some simple string'
  191. want:
  192. path: /api/getsecret?id=testkey&version=1
  193. err: failed to get response (wrong type
  194. resultmap:
  195. thesecret: secret-value
  196. alsosecret: another-value
  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 get response (wrong type
  208. resultmap:
  209. thesecret: secret-value
  210. alsosecret: another-value
  211. ---
  212. case: good json with good templated jsonpath
  213. args:
  214. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  215. key: testkey
  216. property: thesecret
  217. version: 1
  218. jsonpath: $.result.{{ .remoteRef.property }}
  219. response: '{"result":{"thesecret":"secret-value"}}'
  220. want:
  221. path: /api/getsecret?id=testkey&version=1
  222. err: ''
  223. result: secret-value
  224. ---
  225. case: good json with jsonpath filter
  226. args:
  227. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  228. key: testkey
  229. version: 1
  230. jsonpath: $.secrets[?@.name=="thesecret"].value
  231. response: '{"secrets": [{"name": "thesecret", "value": "secret-value"}, {"name": "alsosecret", "value": "another-value"}]}'
  232. want:
  233. path: /api/getsecret?id=testkey&version=1
  234. err: ''
  235. result: secret-value
  236. ---
  237. case: good json with bad templated jsonpath
  238. args:
  239. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  240. key: testkey
  241. property: thesecret
  242. version: 1
  243. jsonpath: $.result.{{ .remoteRef.property }
  244. response: '{"result":{"thesecret":"secret-value"}}'
  245. want:
  246. path: /api/getsecret?id=testkey&version=1
  247. err: 'template: webhooktemplate:1: unexpected "}" in operand'
  248. ---
  249. case: error with jsonpath filter empty results
  250. args:
  251. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  252. key: testkey
  253. version: 1
  254. jsonpath: $.secrets[?@.name=="thebadsecret"].value
  255. response: '{"secrets": [{"name": "thesecret", "value": "secret-value"}, {"name": "alsosecret", "value": "another-value"}]}'
  256. want:
  257. path: /api/getsecret?id=testkey&version=1
  258. err: "filter worked but didn't get any result"
  259. `
  260. func TestWebhookGetSecret(t *testing.T) {
  261. ydec := yaml.NewDecoder(bytes.NewReader([]byte(testCases)))
  262. for {
  263. var tc testCase
  264. if err := ydec.Decode(&tc); err != nil {
  265. if !errors.Is(err, io.EOF) {
  266. t.Errorf("testcase decode error %v", err)
  267. }
  268. break
  269. }
  270. runTestCase(tc, t)
  271. }
  272. }
  273. func testCaseServer(tc testCase, t *testing.T) *httptest.Server {
  274. // Start a new server for every test case because the server wants to check the expected api path
  275. return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
  276. if tc.Want.Path != "" && req.URL.String() != tc.Want.Path {
  277. t.Errorf("%s: unexpected api path: %s, expected %s", tc.Case, req.URL.String(), tc.Want.Path)
  278. }
  279. if tc.Args.StatusCode != 0 {
  280. rw.WriteHeader(tc.Args.StatusCode)
  281. }
  282. rw.Write([]byte(tc.Args.Response))
  283. }))
  284. }
  285. func parseTimeout(timeout string) (*metav1.Duration, error) {
  286. if timeout == "" {
  287. return nil, nil
  288. }
  289. dur, err := time.ParseDuration(timeout)
  290. if err != nil {
  291. return nil, err
  292. }
  293. return &metav1.Duration{Duration: dur}, nil
  294. }
  295. func runTestCase(tc testCase, t *testing.T) {
  296. ts := testCaseServer(tc, t)
  297. defer ts.Close()
  298. testStore := makeClusterSecretStore(ts.URL, tc.Args)
  299. var err error
  300. timeout, err := parseTimeout(tc.Args.Timeout)
  301. if err != nil {
  302. t.Errorf("%s: error parsing timeout '%s': %s", tc.Case, tc.Args.Timeout, err.Error())
  303. return
  304. }
  305. testStore.Spec.Provider.Webhook.Timeout = timeout
  306. testProv := &Provider{}
  307. client, err := testProv.NewClient(context.Background(), testStore, nil, "testnamespace")
  308. if err != nil {
  309. t.Errorf("%s: error creating client: %s", tc.Case, err.Error())
  310. return
  311. }
  312. if tc.Want.ResultMap != nil {
  313. testGetSecretMap(tc, t, client)
  314. } else {
  315. testGetSecret(tc, t, client)
  316. }
  317. }
  318. func testGetSecretMap(tc testCase, t *testing.T, client esv1beta1.SecretsClient) {
  319. testRef := esv1beta1.ExternalSecretDataRemoteRef{
  320. Key: tc.Args.Key,
  321. Version: tc.Args.Version,
  322. }
  323. secretmap, err := client.GetSecretMap(context.Background(), testRef)
  324. errStr := ""
  325. if err != nil {
  326. errStr = err.Error()
  327. }
  328. if (tc.Want.Err == "") != (errStr == "") || !strings.Contains(errStr, tc.Want.Err) {
  329. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  330. }
  331. if err == nil {
  332. for wantkey, wantval := range tc.Want.ResultMap {
  333. gotval, ok := secretmap[wantkey]
  334. if !ok {
  335. t.Errorf("%s: unexpected response: wanted key '%s' not found", tc.Case, wantkey)
  336. } else if string(gotval) != wantval {
  337. t.Errorf("%s: unexpected response: key '%s' = '%s' (expected '%s')", tc.Case, wantkey, wantval, gotval)
  338. }
  339. }
  340. }
  341. }
  342. func testGetSecret(tc testCase, t *testing.T, client esv1beta1.SecretsClient) {
  343. testRef := esv1beta1.ExternalSecretDataRemoteRef{
  344. Key: tc.Args.Key,
  345. Property: tc.Args.Property,
  346. Version: tc.Args.Version,
  347. }
  348. ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  349. defer cancel()
  350. secret, err := client.GetSecret(ctx, testRef)
  351. errStr := ""
  352. if err != nil {
  353. errStr = err.Error()
  354. }
  355. if !strings.Contains(errStr, tc.Want.Err) {
  356. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  357. }
  358. if err == nil && string(secret) != tc.Want.Result {
  359. t.Errorf("%s: unexpected response: '%s' (expected '%s')", tc.Case, secret, tc.Want.Result)
  360. }
  361. }
  362. func makeClusterSecretStore(url string, args args) *esv1beta1.ClusterSecretStore {
  363. store := &esv1beta1.ClusterSecretStore{
  364. TypeMeta: metav1.TypeMeta{
  365. Kind: "ClusterSecretStore",
  366. },
  367. ObjectMeta: metav1.ObjectMeta{
  368. Name: "wehbook-store",
  369. Namespace: "default",
  370. },
  371. Spec: esv1beta1.SecretStoreSpec{
  372. Provider: &esv1beta1.SecretStoreProvider{
  373. Webhook: &esv1beta1.WebhookProvider{
  374. URL: url + args.URL,
  375. Body: args.Body,
  376. Headers: map[string]string{
  377. "Content-Type": "application.json",
  378. "X-SecretKey": "{{ .remoteRef.key }}",
  379. },
  380. Result: esv1beta1.WebhookResult{
  381. JSONPath: args.JSONPath,
  382. },
  383. },
  384. },
  385. },
  386. }
  387. return store
  388. }