crd_test.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  1. /*
  2. Copyright © The ESO Authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package crd
  14. import (
  15. "context"
  16. "encoding/json"
  17. "errors"
  18. "strings"
  19. "testing"
  20. apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
  21. "k8s.io/apimachinery/pkg/api/meta"
  22. "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
  23. "k8s.io/apimachinery/pkg/runtime"
  24. "k8s.io/apimachinery/pkg/runtime/schema"
  25. kclient "sigs.k8s.io/controller-runtime/pkg/client"
  26. crfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
  27. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  28. esmeta "github.com/external-secrets/external-secrets/apis/meta/v1"
  29. )
  30. // ── test doubles ─────────────────────────────────────────────────────────────
  31. type testPushSecretData struct{}
  32. func (testPushSecretData) GetMetadata() *apiextensionsv1.JSON { return nil }
  33. func (testPushSecretData) GetSecretKey() string { return "" }
  34. func (testPushSecretData) GetRemoteKey() string { return "" }
  35. func (testPushSecretData) GetProperty() string { return "" }
  36. type testPushSecretRemoteRef struct {
  37. remoteKey string
  38. property string
  39. }
  40. func (r testPushSecretRemoteRef) GetRemoteKey() string { return r.remoteKey }
  41. func (r testPushSecretRemoteRef) GetProperty() string { return r.property }
  42. // ── helpers ──────────────────────────────────────────────────────────────────
  43. const testStringHello = "hello"
  44. var testResource = esv1.CRDProviderResource{
  45. Group: "example.io", Version: "v1alpha1", Kind: "Widget",
  46. }
  47. func makeStore(rules ...esv1.CRDProviderWhitelistRule) *esv1.CRDProvider {
  48. s := &esv1.CRDProvider{
  49. Auth: &esv1.KubernetesAuth{
  50. ServiceAccount: &esmeta.ServiceAccountSelector{Name: "reader"},
  51. },
  52. Resource: testResource,
  53. }
  54. if len(rules) > 0 {
  55. s.Whitelist = &esv1.CRDProviderWhitelist{Rules: rules}
  56. }
  57. return s
  58. }
  59. func wlRule(name string, props ...string) esv1.CRDProviderWhitelistRule {
  60. return esv1.CRDProviderWhitelistRule{Name: name, Properties: props}
  61. }
  62. func wlRuleNS(ns, name string, props ...string) esv1.CRDProviderWhitelistRule {
  63. return esv1.CRDProviderWhitelistRule{Namespace: ns, Name: name, Properties: props}
  64. }
  65. func widget(name, namespace string, spec map[string]any) *unstructured.Unstructured {
  66. metaData := map[string]any{"name": name}
  67. if namespace != "" {
  68. metaData["namespace"] = namespace
  69. }
  70. return &unstructured.Unstructured{Object: map[string]any{
  71. "apiVersion": "example.io/v1alpha1", "kind": "Widget",
  72. "metadata": metaData, "spec": spec,
  73. }}
  74. }
  75. // testWidgetGVK is the GroupVersionKind of the Widget test resource.
  76. var testWidgetGVK = schema.GroupVersionKind{Group: "example.io", Version: "v1alpha1", Kind: "Widget"}
  77. // fakeCRDClient builds a controller-runtime fake client that serves the Widget
  78. // test resource as unstructured objects, with a RESTMapper carrying the given
  79. // scope so List/Get behave like the production controller-runtime client.
  80. func fakeCRDClient(namespaced bool, objs ...kclient.Object) kclient.Client {
  81. scheme := runtime.NewScheme()
  82. scheme.AddKnownTypeWithName(testWidgetGVK, &unstructured.Unstructured{})
  83. scheme.AddKnownTypeWithName(testWidgetGVK.GroupVersion().WithKind(testWidgetGVK.Kind+"List"), &unstructured.UnstructuredList{})
  84. scope := meta.RESTScopeNamespace
  85. if !namespaced {
  86. scope = meta.RESTScopeRoot
  87. }
  88. mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{testWidgetGVK.GroupVersion()})
  89. mapper.Add(testWidgetGVK, scope)
  90. return crfake.NewClientBuilder().WithScheme(scheme).WithRESTMapper(mapper).WithObjects(objs...).Build()
  91. }
  92. // newTestClient builds a Client for use in unit tests.
  93. // storeKind must be esv1.SecretStoreKind or esv1.ClusterSecretStoreKind.
  94. // Whitelist regexes must be valid: invalid patterns are caught at admission
  95. // (ValidateStore) and are not reachable via the Client constructor in production.
  96. func newTestClient(store *esv1.CRDProvider, storeKind, namespace string, namespaced bool, objs ...kclient.Object) *Client {
  97. rules, err := compileWhitelistRules(store.Whitelist)
  98. if err != nil {
  99. panic("newTestClient: invalid whitelist in test fixture: " + err.Error())
  100. }
  101. return &Client{
  102. store: store,
  103. namespace: namespace,
  104. namespaced: namespaced,
  105. storeKind: storeKind,
  106. kube: fakeCRDClient(namespaced, objs...),
  107. whitelistRules: rules,
  108. }
  109. }
  110. // Shorthands for the two most common configurations.
  111. func ssClient(store *esv1.CRDProvider, ns string, objs ...kclient.Object) *Client {
  112. return newTestClient(store, esv1.SecretStoreKind, ns, true, objs...)
  113. }
  114. func cssClient(store *esv1.CRDProvider, objs ...kclient.Object) *Client {
  115. return newTestClient(store, esv1.ClusterSecretStoreKind, "", true, objs...)
  116. }
  117. func ref(key, prop string) esv1.ExternalSecretDataRemoteRef {
  118. return esv1.ExternalSecretDataRemoteRef{Key: key, Property: prop}
  119. }
  120. // assertJSON unmarshals b and calls check on the result.
  121. func assertJSON[T any](t *testing.T, b []byte, check func(*testing.T, T)) {
  122. t.Helper()
  123. var v T
  124. if err := json.Unmarshal(b, &v); err != nil {
  125. t.Fatalf("unmarshal: %v\nraw: %s", err, b)
  126. }
  127. check(t, v)
  128. }
  129. // ── tests ────────────────────────────────────────────────────────────────────
  130. func TestClientBuildGVK(t *testing.T) {
  131. c := newTestClient(makeStore(), esv1.SecretStoreKind, "", true)
  132. gvk := c.buildGVK()
  133. if gvk.Group != "example.io" || gvk.Version != "v1alpha1" || gvk.Kind != "Widget" {
  134. t.Fatalf("unexpected GVK: %+v", gvk)
  135. }
  136. }
  137. func TestClientGetSecret(t *testing.T) {
  138. richSpec := map[string]any{
  139. "password": "pw1",
  140. "foo": map[string]any{"bar": int64(42), "baz": testStringHello},
  141. "nested": []any{map[string]any{"key": "ep", "val": "db:5432"}, map[string]any{"key": "fqdn", "val": "u:p@db"}},
  142. }
  143. tests := []struct {
  144. name string
  145. client func() *Client
  146. ref esv1.ExternalSecretDataRemoteRef
  147. wantStr string
  148. wantErrIs error
  149. wantErrMsg string
  150. checkFn func(*testing.T, []byte)
  151. }{
  152. // ── SecretStore ──
  153. {
  154. name: "empty key", client: func() *Client { return ssClient(makeStore(), "ns1", widget("x", "ns1", richSpec)) },
  155. ref: ref("", ""), wantErrMsg: "must not be empty",
  156. },
  157. {
  158. name: "slash rejected", client: func() *Client { return ssClient(makeStore(), "ns1", widget("x", "ns1", richSpec)) },
  159. ref: ref("a/b", ""), wantErrMsg: "must not contain '/'",
  160. },
  161. {
  162. name: "missing object", client: func() *Client { return ssClient(makeStore(), "ns1", widget("x", "ns1", richSpec)) },
  163. ref: ref("does-not-exist", ""), wantErrIs: esv1.NoSecretError{},
  164. },
  165. {
  166. name: "scalar property", client: func() *Client { return ssClient(makeStore(), "ns1", widget("item-a", "ns1", richSpec)) },
  167. ref: ref("item-a", "spec.password"), wantStr: "pw1",
  168. },
  169. {
  170. name: "nested scalar via dot path", client: func() *Client { return ssClient(makeStore(), "ns1", widget("item-a", "ns1", richSpec)) },
  171. ref: ref("item-a", "spec.foo.bar"), wantStr: "42",
  172. },
  173. {
  174. name: "gjson query on array", client: func() *Client { return ssClient(makeStore(), "ns1", widget("item-a", "ns1", richSpec)) },
  175. ref: ref("item-a", `spec.nested.#(key=="fqdn").val`), wantStr: "u:p@db",
  176. },
  177. {
  178. name: "nested object returns JSON",
  179. client: func() *Client { return ssClient(makeStore(), "ns1", widget("item-a", "ns1", richSpec)) },
  180. ref: ref("item-a", "spec.foo"),
  181. checkFn: func(t *testing.T, b []byte) {
  182. assertJSON(t, b, func(t *testing.T, m map[string]any) {
  183. if m["bar"] != float64(42) || m["baz"] != testStringHello {
  184. t.Fatalf("spec.foo = %v", m)
  185. }
  186. })
  187. },
  188. },
  189. {
  190. name: "array property returns JSON array",
  191. client: func() *Client { return ssClient(makeStore(), "ns1", widget("item-a", "ns1", richSpec)) },
  192. ref: ref("item-a", "spec.nested"),
  193. checkFn: func(t *testing.T, b []byte) {
  194. assertJSON(t, b, func(t *testing.T, arr []map[string]any) {
  195. if len(arr) != 2 || arr[0]["key"] != "ep" {
  196. t.Fatalf("spec.nested = %v", arr)
  197. }
  198. })
  199. },
  200. },
  201. // ── ClusterSecretStore: namespaced kind ──
  202. {
  203. name: "CSS: namespace/name resolves", client: func() *Client { return cssClient(makeStore(), widget("item-a", "ns1", richSpec)) },
  204. ref: ref("ns1/item-a", "spec.password"), wantStr: "pw1",
  205. },
  206. {
  207. name: "CSS: bare name rejected for namespaced kind", client: func() *Client { return cssClient(makeStore(), widget("item-a", "ns1", richSpec)) },
  208. ref: ref("item-a", ""), wantErrMsg: "namespace/objectName",
  209. },
  210. // ── ClusterSecretStore: cluster-scoped kind ──
  211. {
  212. name: "CSS cluster-scoped: bare name resolves",
  213. client: func() *Client {
  214. return newTestClient(makeStore(), esv1.ClusterSecretStoreKind, "default", false,
  215. widget("global", "", map[string]any{"password": "x"}))
  216. },
  217. ref: ref("global", "spec.password"), wantStr: "x",
  218. },
  219. {
  220. name: "CSS cluster-scoped: slash rejected",
  221. client: func() *Client {
  222. return newTestClient(makeStore(), esv1.ClusterSecretStoreKind, "default", false,
  223. widget("global", "", map[string]any{"password": "x"}))
  224. },
  225. ref: ref("ns/global", "spec.password"), wantErrMsg: "does not allow '/'",
  226. },
  227. }
  228. for _, tt := range tests {
  229. t.Run(tt.name, func(t *testing.T) {
  230. got, err := tt.client().GetSecret(context.Background(), tt.ref)
  231. switch {
  232. case tt.wantErrMsg != "":
  233. if err == nil || !strings.Contains(err.Error(), tt.wantErrMsg) {
  234. t.Fatalf("error = %v, want %q", err, tt.wantErrMsg)
  235. }
  236. case tt.wantErrIs != nil:
  237. if !errors.Is(err, tt.wantErrIs) {
  238. t.Fatalf("error = %v, want %T", err, tt.wantErrIs)
  239. }
  240. default:
  241. if err != nil {
  242. t.Fatalf("unexpected error: %v", err)
  243. }
  244. if tt.wantStr != "" && string(got) != tt.wantStr {
  245. t.Fatalf("= %q, want %q", string(got), tt.wantStr)
  246. }
  247. if tt.checkFn != nil {
  248. tt.checkFn(t, got)
  249. }
  250. }
  251. })
  252. }
  253. }
  254. func TestExtractValue(t *testing.T) {
  255. obj := widget("sample", "default", map[string]any{
  256. "password": "s3cr3t",
  257. "meta": map[string]any{"a": "b"},
  258. "targets": []any{map[string]any{"name": "app", "value": "v1"}, map[string]any{"name": "db", "value": "v2"}},
  259. })
  260. tests := []struct {
  261. name string
  262. property string
  263. fields []string
  264. wantStr string
  265. wantErrMsg string
  266. checkFn func(*testing.T, []byte)
  267. }{
  268. {name: "by property", property: "spec.password", wantStr: "s3cr3t"},
  269. {name: "missing property", property: "spec.missing", wantErrMsg: "not found"},
  270. {name: "query with no match is not found", property: `spec.targets.#(name=="nope").value`, wantErrMsg: "not found"},
  271. {name: "gjson array query", property: `spec.targets.#(name=="db").value`, wantStr: "v2"},
  272. {
  273. name: "selected fields", fields: []string{"spec.password", "spec.meta.a"},
  274. checkFn: func(t *testing.T, b []byte) {
  275. assertJSON(t, b, func(t *testing.T, m map[string]any) {
  276. if m["spec.password"] != "s3cr3t" || m["spec.meta.a"] != "b" {
  277. t.Fatalf("subset = %v", m)
  278. }
  279. })
  280. },
  281. },
  282. }
  283. for _, tt := range tests {
  284. t.Run(tt.name, func(t *testing.T) {
  285. got, err := extractValue(obj, tt.property, tt.fields)
  286. if tt.wantErrMsg != "" {
  287. if err == nil || !strings.Contains(err.Error(), tt.wantErrMsg) {
  288. t.Fatalf("error = %v, want %q", err, tt.wantErrMsg)
  289. }
  290. return
  291. }
  292. if err != nil {
  293. t.Fatalf("unexpected error: %v", err)
  294. }
  295. if tt.wantStr != "" && string(got) != tt.wantStr {
  296. t.Fatalf("= %q, want %q", string(got), tt.wantStr)
  297. }
  298. if tt.checkFn != nil {
  299. tt.checkFn(t, got)
  300. }
  301. })
  302. }
  303. }
  304. func TestJSONBytesToMap(t *testing.T) {
  305. tests := []struct {
  306. name string
  307. raw string
  308. checkFn func(*testing.T, map[string][]byte)
  309. }{
  310. {
  311. name: "mixed value types",
  312. raw: `{"a":"x","b":1}`,
  313. checkFn: func(t *testing.T, got map[string][]byte) {
  314. if string(got["a"]) != "x" || string(got["b"]) != "1" {
  315. t.Fatalf("got %v", got)
  316. }
  317. },
  318. },
  319. {
  320. name: "non-object falls back to value key",
  321. raw: `"hello"`,
  322. checkFn: func(t *testing.T, got map[string][]byte) {
  323. if string(got["value"]) != `"hello"` {
  324. t.Fatalf(`["value"] = %q`, string(got["value"]))
  325. }
  326. },
  327. },
  328. {
  329. name: "nested object preserved as JSON",
  330. raw: `{"user":"admin","foo":{"bar":42,"baz":"hello"}}`,
  331. checkFn: func(t *testing.T, got map[string][]byte) {
  332. if string(got["user"]) != "admin" {
  333. t.Fatalf(`["user"] = %q`, string(got["user"]))
  334. }
  335. assertJSON(t, got["foo"], func(t *testing.T, m map[string]any) {
  336. if m["bar"] != float64(42) || m["baz"] != testStringHello {
  337. t.Fatalf("foo = %v", m)
  338. }
  339. })
  340. },
  341. },
  342. {
  343. name: "array preserved as JSON",
  344. raw: `{"items":[{"key":"a","val":"1"},{"key":"b","val":"2"}]}`,
  345. checkFn: func(t *testing.T, got map[string][]byte) {
  346. assertJSON(t, got["items"], func(t *testing.T, items []map[string]any) {
  347. if len(items) != 2 || items[0]["key"] != "a" {
  348. t.Fatalf("items = %v", items)
  349. }
  350. })
  351. },
  352. },
  353. }
  354. for _, tt := range tests {
  355. t.Run(tt.name, func(t *testing.T) {
  356. got, err := jsonBytesToMap([]byte(tt.raw))
  357. if err != nil {
  358. t.Fatalf("unexpected error: %v", err)
  359. }
  360. tt.checkFn(t, got)
  361. })
  362. }
  363. }
  364. func TestClientGetSecretMap(t *testing.T) {
  365. obj := widget("item-a", "ns1", map[string]any{
  366. "map": map[string]any{"a": "x", "b": int64(1)},
  367. "foo": map[string]any{"bar": int64(42), "baz": testStringHello},
  368. "nested": []any{map[string]any{"key": "ep", "val": "db:5432"}, map[string]any{"key": "fqdn", "val": "u:p@db"}},
  369. })
  370. c := ssClient(makeStore(), "ns1", obj)
  371. tests := []struct {
  372. name string
  373. ref esv1.ExternalSecretDataRemoteRef
  374. checkFn func(*testing.T, map[string][]byte)
  375. }{
  376. {
  377. name: "flat sub-object",
  378. ref: ref("item-a", "spec.map"),
  379. checkFn: func(t *testing.T, got map[string][]byte) {
  380. if string(got["a"]) != "x" || string(got["b"]) != "1" {
  381. t.Fatalf("got %v", got)
  382. }
  383. },
  384. },
  385. {
  386. name: "spec returns nested objects as JSON",
  387. ref: ref("item-a", "spec"),
  388. checkFn: func(t *testing.T, got map[string][]byte) {
  389. assertJSON(t, got["foo"], func(t *testing.T, m map[string]any) {
  390. if m["bar"] != float64(42) || m["baz"] != testStringHello {
  391. t.Fatalf("foo = %v", m)
  392. }
  393. })
  394. assertJSON(t, got["nested"], func(t *testing.T, arr []map[string]any) {
  395. if len(arr) != 2 || arr[0]["key"] != "ep" {
  396. t.Fatalf("nested = %v", arr)
  397. }
  398. })
  399. },
  400. },
  401. {
  402. name: "spec.foo returns flat map",
  403. ref: ref("item-a", "spec.foo"),
  404. checkFn: func(t *testing.T, got map[string][]byte) {
  405. if string(got["bar"]) != "42" || string(got["baz"]) != testStringHello {
  406. t.Fatalf("got %v", got)
  407. }
  408. },
  409. },
  410. }
  411. for _, tt := range tests {
  412. t.Run(tt.name, func(t *testing.T) {
  413. got, err := c.GetSecretMap(context.Background(), tt.ref)
  414. if err != nil {
  415. t.Fatalf("unexpected error: %v", err)
  416. }
  417. tt.checkFn(t, got)
  418. })
  419. }
  420. }
  421. func TestClientGetAllSecrets(t *testing.T) {
  422. objA := widget("app-a", "ns1", map[string]any{"password": "a"})
  423. objB := widget("sys-b", "ns1", map[string]any{"password": "b"})
  424. tests := []struct {
  425. name string
  426. client func() *Client
  427. find esv1.ExternalSecretFind
  428. wantKeys []string
  429. wantErrMsg string
  430. }{
  431. {
  432. name: "no filter returns all", wantKeys: []string{"app-a", "sys-b"},
  433. client: func() *Client { return ssClient(makeStore(), "ns1", objA, objB) },
  434. },
  435. {
  436. name: "regexp filters list", wantKeys: []string{"sys-b"},
  437. client: func() *Client { return ssClient(makeStore(), "ns1", objA, objB) },
  438. find: esv1.ExternalSecretFind{Name: &esv1.FindName{RegExp: "^sys-.*$"}},
  439. },
  440. {
  441. name: "invalid regex", wantErrMsg: "invalid name pattern",
  442. client: func() *Client { return ssClient(makeStore(), "ns1", objA) },
  443. find: esv1.ExternalSecretFind{Name: &esv1.FindName{RegExp: "("}},
  444. },
  445. {
  446. name: "whitelist name rule", wantKeys: []string{"app-a"},
  447. client: func() *Client { return ssClient(makeStore(wlRule("^app-.*$")), "ns1", objA, objB) },
  448. },
  449. {
  450. // The provider builds namespace/name keys; the conversion strategy
  451. // then replaces the slash so the key is valid in a Secret.
  452. name: "CSS namespaced kind uses namespace/name keys", wantKeys: []string{"ns1_app-a", "ns2_sys-b"},
  453. client: func() *Client {
  454. return cssClient(makeStore(),
  455. widget("app-a", "ns1", map[string]any{"password": "a"}),
  456. widget("sys-b", "ns2", map[string]any{"password": "b"}))
  457. },
  458. },
  459. }
  460. for _, tt := range tests {
  461. t.Run(tt.name, func(t *testing.T) {
  462. got, err := tt.client().GetAllSecrets(context.Background(), tt.find)
  463. if tt.wantErrMsg != "" {
  464. if err == nil || !strings.Contains(err.Error(), tt.wantErrMsg) {
  465. t.Fatalf("error = %v, want %q", err, tt.wantErrMsg)
  466. }
  467. return
  468. }
  469. if err != nil {
  470. t.Fatalf("unexpected error: %v", err)
  471. }
  472. if len(got) != len(tt.wantKeys) {
  473. t.Fatalf("len = %d, want %d; keys: %v", len(got), len(tt.wantKeys), got)
  474. }
  475. for _, k := range tt.wantKeys {
  476. if _, ok := got[k]; !ok {
  477. t.Fatalf("missing key %q", k)
  478. }
  479. }
  480. })
  481. }
  482. }
  483. func TestClientMiscMethods(t *testing.T) {
  484. c := ssClient(makeStore(), "ns1")
  485. if err := c.PushSecret(context.Background(), nil, testPushSecretData{}); err == nil {
  486. t.Fatal("PushSecret() expected error")
  487. }
  488. if err := c.DeleteSecret(context.Background(), testPushSecretRemoteRef{}); err == nil {
  489. t.Fatal("DeleteSecret() expected error")
  490. }
  491. if got, err := c.Validate(); err != nil || got != esv1.ValidationResultReady {
  492. t.Fatalf("Validate() = (%v, %v), want (%v, nil)", got, err, esv1.ValidationResultReady)
  493. }
  494. if err := c.Close(context.Background()); err != nil {
  495. t.Fatalf("Close() unexpected error: %v", err)
  496. }
  497. }
  498. func TestReadsRejectReferentStub(t *testing.T) {
  499. // The referent stub (returned by newClient for a ClusterSecretStore whose SA
  500. // namespace is not yet known) has no kube client. Every read must return
  501. // errClientNotReady instead of nil-panicking on c.kube.
  502. c := &Client{referent: true, storeKind: esv1.ClusterSecretStoreKind}
  503. ctx := context.Background()
  504. if _, err := c.GetSecret(ctx, esv1.ExternalSecretDataRemoteRef{Key: "widget"}); !errors.Is(err, errClientNotReady) {
  505. t.Fatalf("GetSecret() err = %v, want errClientNotReady", err)
  506. }
  507. if _, err := c.GetSecretMap(ctx, esv1.ExternalSecretDataRemoteRef{Key: "widget"}); !errors.Is(err, errClientNotReady) {
  508. t.Fatalf("GetSecretMap() err = %v, want errClientNotReady", err)
  509. }
  510. if _, err := c.GetAllSecrets(ctx, esv1.ExternalSecretFind{}); !errors.Is(err, errClientNotReady) {
  511. t.Fatalf("GetAllSecrets() err = %v, want errClientNotReady", err)
  512. }
  513. if _, err := c.SecretExists(ctx, testPushSecretRemoteRef{remoteKey: "widget"}); !errors.Is(err, errClientNotReady) {
  514. t.Fatalf("SecretExists() err = %v, want errClientNotReady", err)
  515. }
  516. }
  517. func TestClientSecretExists(t *testing.T) {
  518. obj := widget("item-a", "ns1", map[string]any{"password": "pw1"})
  519. c := ssClient(makeStore(), "ns1", obj)
  520. if exists, err := c.SecretExists(context.Background(), testPushSecretRemoteRef{remoteKey: "item-a"}); err != nil || !exists {
  521. t.Fatalf("SecretExists(item-a) = (%v, %v), want (true, nil)", exists, err)
  522. }
  523. if exists, err := c.SecretExists(context.Background(), testPushSecretRemoteRef{remoteKey: "missing"}); err != nil || exists {
  524. t.Fatalf("SecretExists(missing) = (%v, %v), want (false, nil)", exists, err)
  525. }
  526. }
  527. // TestWhitelistMatching covers all whitelist filter dimensions: name, namespace,
  528. // properties, and combinations — as a single table-driven test.
  529. func TestWhitelistMatching(t *testing.T) {
  530. obj := widget("item-a", "ns1", map[string]any{"password": "pw1"})
  531. tests := []struct {
  532. name string
  533. client func() *Client
  534. ref esv1.ExternalSecretDataRemoteRef
  535. wantVal string
  536. wantErrMsg string
  537. }{
  538. // ── name-only rules (SecretStore) ──
  539. {
  540. name: "denied when no rule matches", wantErrMsg: "denied by whitelist",
  541. client: func() *Client { return ssClient(makeStore(wlRule("^allowed-.*$")), "ns1", obj) },
  542. ref: ref("item-a", "spec.password"),
  543. },
  544. {
  545. name: "allowed by name rule", wantVal: "pw1",
  546. client: func() *Client { return ssClient(makeStore(wlRule("^item-.*$")), "ns1", obj) },
  547. ref: ref("item-a", "spec.password"),
  548. },
  549. // ── name + properties ──
  550. {
  551. name: "denied when property does not match", wantErrMsg: "denied by whitelist",
  552. client: func() *Client { return ssClient(makeStore(wlRule("^item-.*$", `^spec\.allowed$`)), "ns1", obj) },
  553. ref: ref("item-a", "spec.password"),
  554. },
  555. {
  556. name: "allowed when both name and property match", wantVal: "pw1",
  557. client: func() *Client { return ssClient(makeStore(wlRule("^item-.*$", `^spec\.password$`)), "ns1", obj) },
  558. ref: ref("item-a", "spec.password"),
  559. },
  560. // ── properties-only ──
  561. {
  562. name: "allowed when one of two properties matches", wantVal: "pw1",
  563. client: func() *Client {
  564. return ssClient(makeStore(esv1.CRDProviderWhitelistRule{Properties: []string{`^spec\.username$`, `^spec\.password$`}}), "ns1", obj)
  565. },
  566. ref: ref("item-a", "spec.password"),
  567. },
  568. {
  569. name: "denied when no property matches", wantErrMsg: "denied by whitelist",
  570. client: func() *Client {
  571. return ssClient(makeStore(esv1.CRDProviderWhitelistRule{Properties: []string{`^spec\.username$`, `^spec\.token$`}}), "ns1", obj)
  572. },
  573. ref: ref("item-a", "spec.password"),
  574. },
  575. // ── namespace rules (ClusterSecretStore) ──
  576. {
  577. name: "CSS: namespace allows matching NS", wantVal: "pw1",
  578. client: func() *Client { return cssClient(makeStore(wlRuleNS("^ns1$", "")), obj) },
  579. ref: ref("ns1/item-a", "spec.password"),
  580. },
  581. {
  582. name: "CSS: namespace denies non-matching NS", wantErrMsg: "denied by whitelist",
  583. client: func() *Client { return cssClient(makeStore(wlRuleNS("^prod$", "")), obj) },
  584. ref: ref("ns1/item-a", "spec.password"),
  585. },
  586. {
  587. name: "CSS: namespace regex pattern", wantVal: "pw1",
  588. client: func() *Client { return cssClient(makeStore(wlRuleNS("^ns.*$", "")), obj) },
  589. ref: ref("ns1/item-a", "spec.password"),
  590. },
  591. {
  592. name: "CSS: namespace + name both must match", wantErrMsg: "denied by whitelist",
  593. client: func() *Client { return cssClient(makeStore(wlRuleNS("^ns1$", "^other-.*$")), obj) },
  594. ref: ref("ns1/item-a", "spec.password"),
  595. },
  596. {
  597. name: "SecretStore ignores namespace rule", wantVal: "pw1",
  598. client: func() *Client { return ssClient(makeStore(wlRuleNS("^prod$", "")), "ns1", obj) },
  599. ref: ref("item-a", "spec.password"),
  600. },
  601. {
  602. // Regression: namespace rule must not match cluster-scoped objects.
  603. name: "CSS: namespace rule does not match cluster-scoped object", wantErrMsg: "denied by whitelist",
  604. client: func() *Client {
  605. return newTestClient(makeStore(wlRuleNS("^prod$", "")), esv1.ClusterSecretStoreKind, "", false,
  606. widget("item-a", "", map[string]any{"password": "pw1"}))
  607. },
  608. ref: ref("item-a", "spec.password"),
  609. },
  610. }
  611. for _, tt := range tests {
  612. t.Run(tt.name, func(t *testing.T) {
  613. got, err := tt.client().GetSecret(context.Background(), tt.ref)
  614. if tt.wantErrMsg != "" {
  615. if err == nil || !strings.Contains(err.Error(), tt.wantErrMsg) {
  616. t.Fatalf("error = %v, want %q", err, tt.wantErrMsg)
  617. }
  618. return
  619. }
  620. if err != nil {
  621. t.Fatalf("unexpected error: %v", err)
  622. }
  623. if string(got) != tt.wantVal {
  624. t.Fatalf("= %q, want %q", string(got), tt.wantVal)
  625. }
  626. })
  627. }
  628. }
  629. // TestWhitelistGetAllSecrets verifies namespace whitelist filtering in GetAllSecrets.
  630. func TestWhitelistGetAllSecrets(t *testing.T) {
  631. o1 := widget("app-a", "ns1", map[string]any{"password": "a"})
  632. o2 := widget("app-b", "ns2", map[string]any{"password": "b"})
  633. tests := []struct {
  634. name string
  635. rules []esv1.CRDProviderWhitelistRule
  636. wantKeys []string
  637. }{
  638. {name: "allow only ns1", rules: []esv1.CRDProviderWhitelistRule{wlRuleNS("^ns1$", "")}, wantKeys: []string{"ns1_app-a"}},
  639. {name: "ns1 + name rule", rules: []esv1.CRDProviderWhitelistRule{wlRuleNS("^ns1$", ""), wlRuleNS("", "^app-b$")}, wantKeys: []string{"ns1_app-a", "ns2_app-b"}},
  640. {name: "filter to ns2", rules: []esv1.CRDProviderWhitelistRule{wlRuleNS("^ns2$", "")}, wantKeys: []string{"ns2_app-b"}},
  641. }
  642. for _, tt := range tests {
  643. t.Run(tt.name, func(t *testing.T) {
  644. c := cssClient(makeStore(tt.rules...), o1, o2)
  645. got, err := c.GetAllSecrets(context.Background(), esv1.ExternalSecretFind{})
  646. if err != nil {
  647. t.Fatalf("unexpected error: %v", err)
  648. }
  649. if len(got) != len(tt.wantKeys) {
  650. t.Fatalf("len = %d, want %d; keys: %v", len(got), len(tt.wantKeys), got)
  651. }
  652. for _, k := range tt.wantKeys {
  653. if _, ok := got[k]; !ok {
  654. t.Fatalf("missing key %q; got %v", k, got)
  655. }
  656. }
  657. })
  658. }
  659. }