webhook_test.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  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. "github.com/external-secrets/external-secrets/pkg/provider"
  27. )
  28. type testCase struct {
  29. Case string `json:"case,omitempty"`
  30. Args args `json:"args"`
  31. Want want `json:"want"`
  32. }
  33. type args struct {
  34. URL string `json:"url,omitempty"`
  35. Body string `json:"body,omitempty"`
  36. Timeout string `json:"timeout,omitempty"`
  37. Key string `json:"key,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 not found
  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: endpoint gave error 404
  79. ---
  80. case: error bad json
  81. args:
  82. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  83. key: testkey
  84. version: 1
  85. jsonpath: $.result.thesecret
  86. response: '{"result":{"thesecret":"secret-value"}'
  87. want:
  88. path: /api/getsecret?id=testkey&version=1
  89. err: failed to parse response json
  90. ---
  91. case: error bad jsonpath
  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":{"nosecret":"secret-value"}}'
  98. want:
  99. path: /api/getsecret?id=testkey&version=1
  100. err: failed to get response path
  101. ---
  102. case: error bad json data
  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":{"thesecret":{"one":"secret-value"}}}'
  109. want:
  110. path: /api/getsecret?id=testkey&version=1
  111. err: failed to get response (wrong type
  112. ---
  113. case: error timeout
  114. args:
  115. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  116. key: testkey
  117. version: 1
  118. response: secret-value
  119. timeout: 0.01ms
  120. want:
  121. path: /api/getsecret?id=testkey&version=1
  122. err: context deadline exceeded
  123. ---
  124. case: good plaintext
  125. args:
  126. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  127. key: testkey
  128. version: 1
  129. response: secret-value
  130. want:
  131. path: /api/getsecret?id=testkey&version=1
  132. result: secret-value
  133. ---
  134. case: good json
  135. args:
  136. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  137. key: testkey
  138. version: 1
  139. jsonpath: $.result.thesecret
  140. response: '{"result":{"thesecret":"secret-value"}}'
  141. want:
  142. path: /api/getsecret?id=testkey&version=1
  143. result: secret-value
  144. ---
  145. case: good json map
  146. args:
  147. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  148. key: testkey
  149. version: 1
  150. jsonpath: $.result
  151. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value"}}'
  152. want:
  153. path: /api/getsecret?id=testkey&version=1
  154. resultmap:
  155. thesecret: secret-value
  156. alsosecret: another-value
  157. ---
  158. case: good json map string
  159. args:
  160. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  161. key: testkey
  162. version: 1
  163. response: '{"thesecret":"secret-value","alsosecret":"another-value"}'
  164. want:
  165. path: /api/getsecret?id=testkey&version=1
  166. resultmap:
  167. thesecret: secret-value
  168. alsosecret: another-value
  169. ---
  170. case: error json map string
  171. args:
  172. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  173. key: testkey
  174. version: 1
  175. response: 'some simple string'
  176. want:
  177. path: /api/getsecret?id=testkey&version=1
  178. err: failed to get response (wrong type
  179. resultmap:
  180. thesecret: secret-value
  181. alsosecret: another-value
  182. ---
  183. case: error json map
  184. args:
  185. url: /api/getsecret?id={{ .remoteRef.key }}&version={{ .remoteRef.version }}
  186. key: testkey
  187. version: 1
  188. jsonpath: $.result.thesecret
  189. response: '{"result":{"thesecret":"secret-value","alsosecret":"another-value"}}'
  190. want:
  191. path: /api/getsecret?id=testkey&version=1
  192. err: failed to get response (wrong type
  193. resultmap:
  194. thesecret: secret-value
  195. alsosecret: another-value
  196. `
  197. func TestWebhookGetSecret(t *testing.T) {
  198. ydec := yaml.NewDecoder(bytes.NewReader([]byte(testCases)))
  199. for {
  200. var tc testCase
  201. if err := ydec.Decode(&tc); err != nil {
  202. if !errors.Is(err, io.EOF) {
  203. t.Errorf("testcase decode error %w", err)
  204. }
  205. break
  206. }
  207. runTestCase(tc, t)
  208. }
  209. }
  210. func testCaseServer(tc testCase, t *testing.T) *httptest.Server {
  211. // Start a new server for every test case because the server wants to check the expected api path
  212. return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
  213. if tc.Want.Path != "" && req.URL.String() != tc.Want.Path {
  214. t.Errorf("%s: unexpected api path: %s, expected %s", tc.Case, req.URL.String(), tc.Want.Path)
  215. }
  216. if tc.Args.StatusCode != 0 {
  217. rw.WriteHeader(tc.Args.StatusCode)
  218. }
  219. rw.Write([]byte(tc.Args.Response))
  220. }))
  221. }
  222. func parseTimeout(timeout string) (*metav1.Duration, error) {
  223. if timeout == "" {
  224. return nil, nil
  225. }
  226. dur, err := time.ParseDuration(timeout)
  227. if err != nil {
  228. return nil, err
  229. }
  230. return &metav1.Duration{Duration: dur}, nil
  231. }
  232. func runTestCase(tc testCase, t *testing.T) {
  233. ts := testCaseServer(tc, t)
  234. defer ts.Close()
  235. testStore := makeClusterSecretStore(ts.URL, tc.Args)
  236. var err error
  237. timeout, err := parseTimeout(tc.Args.Timeout)
  238. if err != nil {
  239. t.Errorf("%s: error parsing timeout '%s': %s", tc.Case, tc.Args.Timeout, err.Error())
  240. return
  241. }
  242. testStore.Spec.Provider.Webhook.Timeout = timeout
  243. testProv := &Provider{}
  244. client, err := testProv.NewClient(context.Background(), testStore, nil, "testnamespace")
  245. if err != nil {
  246. t.Errorf("%s: error creating client: %s", tc.Case, err.Error())
  247. return
  248. }
  249. if tc.Want.ResultMap != nil {
  250. testGetSecretMap(tc, t, client)
  251. } else {
  252. testGetSecret(tc, t, client)
  253. }
  254. }
  255. func testGetSecretMap(tc testCase, t *testing.T, client provider.SecretsClient) {
  256. testRef := esv1beta1.ExternalSecretDataRemoteRef{
  257. Key: tc.Args.Key,
  258. Version: tc.Args.Version,
  259. }
  260. secretmap, err := client.GetSecretMap(context.Background(), testRef)
  261. errStr := ""
  262. if err != nil {
  263. errStr = err.Error()
  264. }
  265. if (tc.Want.Err == "") != (errStr == "") || !strings.Contains(errStr, tc.Want.Err) {
  266. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  267. }
  268. if err == nil {
  269. for wantkey, wantval := range tc.Want.ResultMap {
  270. gotval, ok := secretmap[wantkey]
  271. if !ok {
  272. t.Errorf("%s: unexpected response: wanted key '%s' not found", tc.Case, wantkey)
  273. } else if string(gotval) != wantval {
  274. t.Errorf("%s: unexpected response: key '%s' = '%s' (expected '%s')", tc.Case, wantkey, wantval, gotval)
  275. }
  276. }
  277. }
  278. }
  279. func testGetSecret(tc testCase, t *testing.T, client provider.SecretsClient) {
  280. testRef := esv1beta1.ExternalSecretDataRemoteRef{
  281. Key: tc.Args.Key,
  282. Version: tc.Args.Version,
  283. }
  284. ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  285. defer cancel()
  286. secret, err := client.GetSecret(ctx, testRef)
  287. errStr := ""
  288. if err != nil {
  289. errStr = err.Error()
  290. }
  291. if !strings.Contains(errStr, tc.Want.Err) {
  292. t.Errorf("%s: unexpected error: '%s' (expected '%s')", tc.Case, errStr, tc.Want.Err)
  293. }
  294. if err == nil && string(secret) != tc.Want.Result {
  295. t.Errorf("%s: unexpected response: '%s' (expected '%s')", tc.Case, secret, tc.Want.Result)
  296. }
  297. }
  298. func makeClusterSecretStore(url string, args args) *esv1beta1.ClusterSecretStore {
  299. store := &esv1beta1.ClusterSecretStore{
  300. TypeMeta: metav1.TypeMeta{
  301. Kind: "ClusterSecretStore",
  302. },
  303. ObjectMeta: metav1.ObjectMeta{
  304. Name: "wehbook-store",
  305. Namespace: "default",
  306. },
  307. Spec: esv1beta1.SecretStoreSpec{
  308. Provider: &esv1beta1.SecretStoreProvider{
  309. Webhook: &esv1beta1.WebhookProvider{
  310. URL: url + args.URL,
  311. Body: args.Body,
  312. Headers: map[string]string{
  313. "Content-Type": "application.json",
  314. "X-SecretKey": "{{ .remoteRef.key }}",
  315. },
  316. Result: esv1beta1.WebhookResult{
  317. JSONPath: args.JSONPath,
  318. },
  319. },
  320. },
  321. },
  322. }
  323. return store
  324. }