diff --git a/test/README.md b/test/README.md new file mode 100644 index 000000000..079c2d034 --- /dev/null +++ b/test/README.md @@ -0,0 +1,182 @@ +# Tests + +This is the test catalog for the kbind slim core: every scenario, what it +asserts, and what is **not** covered yet. Keep it in sync when adding tests. + +## How the tests run + +- **e2e** (`test/e2e/`) run on **envtest** — real `kube-apiserver` + `etcd` + binaries as local processes (no kind, no Docker, no kubelet/nodes). Each test + starts **two** control planes: a *consumer* (pre-loaded with the core CRDs) and + a *provider*, and runs the real engine reconcilers in-process against the + consumer. See [e2e/framework/framework.go](e2e/framework/framework.go). +- **unit** tests live next to the code under `engine/*/` and need no cluster. + +```sh +make test # unit tests only (no external setup) +make test-e2e # downloads envtest assets and runs the e2e suite + +# one e2e test, verbose: +KUBEBUILDER_ASSETS="$(go run sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.21 use 1.34.1 -p path)" \ + go test -v ./test/e2e -run TestSlimCoreHappyCase +``` + +## Direction conventions + +- **Bound instances** (the synced CRs): **spec** flows consumer→provider, **status** + flows provider→consumer. There is no other direction by design. +- **Related resources** (Secrets/ConfigMaps): direction is per-rule — + `FromProvider` (provider→consumer) or `FromConsumer` (consumer→provider). + +--- + +## E2E scenarios (`test/e2e/`) + +### `TestSlimCoreHappyCase` — [sync_test.go](e2e/sync_test.go) +The full one-apply flow (Secret + Connection + ClusterBinding), stepped in order: + +| # | Scenario | Asserts | +|---|---|---| +| 1 | Connection becomes Ready and discovers the exported API | discovery → `status.exportedAPIs`, Ready | +| 2 | provider heartbeat Lease is maintained and renewed | `coordination.k8s.io/Lease` on provider, renewed | +| 3 | ClusterBinding Ready and the CRD is pulled onto the consumer | `schema.source: CRD` pull, `boundAPIs`, managed marker | +| 4 | instance spec syncs consumer → provider | SSA + ownership markers + syncer finalizer | +| 5 | status syncs provider → consumer | status subresource flows down | +| 6 | spec update syncs consumer → provider | update propagation | +| 7 | conflict: foreign provider object not overwritten | `conflictPolicy: Fail`, `conflictCount` + `Conflicts` condition | +| 8 | consumer delete removes the provider copy + releases finalizer | delete propagation | +| 9 | deleting a conflicting consumer object leaves the foreign provider object intact | conflict-safe delete | +| 10 | a CRD exported after connect is picked up | periodic re-discovery | +| 11 | `conflictPolicy: Adopt` takes over an un-owned provider object | adopt (never steals an owned object) | +| 12 | Connection created before its Secret resolves when the Secret arrives | order-independence (Secret watch) | +| 13 | Secret survives deletion while its Connection exists, released on Connection delete | Secret finalizer | +| 14 | a Secret shared by multiple Connections released only when the last is deleted | shared-Secret refcount | +| 15 | deleting the ClusterBinding unbinds and cleans up | full unbind | + +### `TestSlimCorePolicies` — [policies_test.go](e2e/policies_test.go) +| Scenario | Asserts | +|---|---| +| `deletion-policy: Orphan` keeps the provider copy | orphan on delete/unbind | +| `updatePolicy: Always` follows provider CRD changes | schema tracking | +| `autoBind` maintains a managed ClusterBinding | auto-bind mirrors exported APIs | +| `pullPolicy: All` installs CRDs without a binding | pull-all | +| `PermissionDenied` surfaces on a restricted Connection | provider RBAC denial → condition/Event | + +### `TestSlimCoreRelatedResources` — [related_resources_test.go](e2e/related_resources_test.go) +| Scenario | Asserts | +|---|---| +| a label-selected provider **Secret** syncs to the consumer | `FromProvider` + labelSelector | +| the synced copy is GC'd when it stops matching | GC on stop-matching | + +### `TestSlimCoreRelatedConfigMaps` — [related_resources_more_test.go](e2e/related_resources_more_test.go) +| Scenario | Asserts | +|---|---| +| a label-selected provider **ConfigMap** syncs to the consumer | `FromProvider` ConfigMap | +| the synced ConfigMap is GC'd when it stops matching | GC | +| a foreign ConfigMap of the same name is not overwritten | foreign-object guard | + +### `TestSlimCoreRelatedReverseDirection` — [related_resources_more_test.go](e2e/related_resources_more_test.go) +| Scenario | Asserts | +|---|---| +| a label-selected consumer **Secret** syncs **up** to the provider | `FromConsumer` (reverse) | +| the provider copy is GC'd when the consumer Secret stops matching | reverse GC | + +### `TestSlimCoreRelatedNamedSelector` — [related_resources_more_test.go](e2e/related_resources_more_test.go) +| Scenario | Asserts | +|---|---| +| only the named ConfigMap syncs; a non-named one does not | `selector.names` | + +### `TestSlimCoreRelatedCleanupOnUnbind` — [related_resources_more_test.go](e2e/related_resources_more_test.go) +| Scenario | Asserts | +|---|---| +| deleting the binding removes the related copies | `cleanupRelated` on unbind | + +### `TestSlimCoreOpenAPISource` — [schema_source_test.go](e2e/schema_source_test.go) +| Scenario | Asserts | +|---|---| +| Connection synthesizes and installs the CRD via OpenAPI | `schema.source: OpenAPI` (CRD-less provider) | +| a binding syncs an instance over the synthesized CRD | sync works on a synthesized CRD | + +### `TestSlimCoreKCPLikeProvider` — [schema_source_test.go](e2e/schema_source_test.go) +| Scenario | Asserts | +|---|---| +| identity is pinned from the LogicalCluster | kcp identity (LogicalCluster UID over kube-system) | +| instances sync against the kcp-like provider | end-to-end on a kcp-shaped provider | + +### `TestSlimCoreStopOnDisengage` — [disengage_test.go](e2e/disengage_test.go) +| Scenario | Asserts | +|---|---| +| a Connection that loses readiness disengages | syncers torn down, not left against a dead cluster | +| re-engage rebuilds the syncer and sync resumes | fresh cluster on re-engage | + +### `TestSlimCoreNamespacedBinding` — [namespaced_binding_test.go](e2e/namespaced_binding_test.go) +| Scenario | Asserts | +|---|---| +| the namespaced Binding becomes Ready and pulls the CRD | namespaced `Binding` kind reconciles | +| an instance in the bound namespace syncs to the provider | namespace-scoped sync | +| an instance in another namespace is not synced | scope enforcement (ClusterBinding would cover all; a Binding only its namespace) | + +--- + +## Unit tests (`engine/*/`) + +### `engine/crdpull` — [crdpull_test.go](../engine/crdpull/crdpull_test.go) +| Test | Covers | +|---|---| +| `TestPull_BoundCreatesCRD` | default pull creates the CRD | +| `TestPull_UpdatePolicyOnce_DoesNotUpdate` | **`updatePolicy: Once`** pins the schema | +| `TestPull_UpdatePolicyAlways_FollowsProviderChanges` | `updatePolicy: Always` | +| `TestPull_NoneAbsent_NotInstalled` | **`pullPolicy: None`** does not install | +| `TestPull_NonePresent_StampsMarkers` | `pullPolicy: None` still stamps markers on an existing CRD | + +### `engine/remote` — [remote_test.go](../engine/remote/remote_test.go) +| Test | Covers | +|---|---| +| `TestClusterUID_KubeSystem` | identity from kube-system on plain k8s | +| `TestClusterUID_KCPLogicalClusterFallback` | identity from LogicalCluster | +| `TestClusterUID_LogicalClusterWinsWhenBothPresent` | LogicalCluster preferred | +| `TestClusterUID_NoSource` | error when neither is present | + +### `engine/mapper` — [mapper_test.go](../engine/mapper/mapper_test.go) +| Test | Covers | +|---|---| +| `TestIdentity_RoundTrips` | `Identity` mapper key round-trip | +| `TestMapper_NonIdentityRoundTrips` | a **custom (non-identity) Mapper** round-trip contract | + +--- + +## Coverage matrix (by feature) + +| Feature | e2e | unit | +|---|---|---| +| Connection: secret resolve, identity pin, discovery | ✅ | — | +| Heartbeat Lease | ✅ | — | +| Schema delivery: CRD pull | ✅ | ✅ | +| Schema delivery: OpenAPI synthesis / Auto | ✅ | — | +| `pullPolicy`: Bound (default), All | ✅ | — | +| `pullPolicy`: None | — | ✅ | +| `updatePolicy`: Always | ✅ | ✅ | +| `updatePolicy`: Once | — | ✅ | +| Instance sync: spec up / status down / update | ✅ | — | +| Conflicts: `Fail` (no overwrite), `Adopt` | ✅ | — | +| `deletion-policy: Orphan` | ✅ | — | +| `autoBind` | ✅ | — | +| Order-independent apply + Secret lifecycle | ✅ | — | +| Unbind / cleanup (instances, CRD, related) | ✅ | — | +| Related: `FromProvider` Secret + ConfigMap, GC, foreign guard | ✅ | — | +| Related: `FromConsumer` (reverse) Secret, GC | ✅ | — | +| Related: `names` selector | ✅ | — | +| Stop-on-disengage + re-engage | ✅ | — | +| kcp `LogicalCluster` identity | ✅ | ✅ | +| `PermissionDenied` / provider RBAC | ✅ | — | +| Mapper extension point | Identity only (implicit) | ✅ (contract) | + +## Not covered yet + +- **Custom `Mapper` end-to-end** — the contract is unit-tested; no e2e wires a + non-identity mapper through the running syncer. +- **`pullPolicy: None` / `updatePolicy: Once` in e2e** — unit-tested in `crdpull`. +- **`FromConsumer` ConfigMap** — only the `FromConsumer` Secret path is e2e'd + (direction is resource-kind-agnostic in the code, so this is cosmetic). +- **Conversion-webhook CRDs** — not refused yet; not tested (known gap). +- **Multi-version CRDs** — accepted limitation; not tested. diff --git a/test/e2e/namespaced_binding_test.go b/test/e2e/namespaced_binding_test.go new file mode 100644 index 000000000..a881c6ccc --- /dev/null +++ b/test/e2e/namespaced_binding_test.go @@ -0,0 +1,101 @@ +/* +Copyright 2026 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1alpha1 "github.com/kbind/kbind/sdk/apis/core/v1alpha1" + "github.com/kbind/kbind/test/e2e/framework" +) + +// TestSlimCoreNamespacedBinding exercises the namespaced Binding kind (the +// happy case uses ClusterBinding): it becomes Ready and pulls the CRD, and the +// syncer scopes instance sync to the Binding's namespace — instances elsewhere +// are not synced (ResolveConnection: a namespaced Binding covers only its own +// namespace). +func TestSlimCoreNamespacedBinding(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + gvr := env.InstallExportedWidgetCRD(t) + + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "demo-provider"}, + Spec: corev1alpha1.ConnectionSpec{ + KubeconfigSecretRef: corev1alpha1.SecretKeyRef{Namespace: framework.KbindNamespace, Name: "demo-provider-kubeconfig", Key: "kubeconfig"}, + Schema: corev1alpha1.SchemaPolicy{Source: corev1alpha1.SchemaSourceCRD}, + }, + })) + // A namespaced Binding scoped to "default". + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.Binding{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "widgets"}, + Spec: corev1alpha1.BindingSpec{ + ConnectionRef: corev1alpha1.ConnectionRef{Name: "demo-provider"}, + APIs: []corev1alpha1.APIRef{{Name: widgetCRDName}}, + }, + })) + + t.Run("the namespaced Binding becomes Ready and pulls the CRD", func(t *testing.T) { + framework.WaitForConditionTrue(t, func() ([]metav1.Condition, error) { + b := &corev1alpha1.Binding{} + err := env.ConsumerClient.Get(ctx, client.ObjectKey{Namespace: "default", Name: "widgets"}, b) + return b.Status.Conditions, err + }, corev1alpha1.ConditionReady) + require.Eventually(t, func() bool { + crd := &apiextensionsv1.CustomResourceDefinition{} + return env.ConsumerClient.Get(ctx, client.ObjectKey{Name: widgetCRDName}, crd) == nil + }, 30*time.Second, 200*time.Millisecond, "the bound CRD should be pulled onto the consumer") + }) + + t.Run("an instance in the bound namespace syncs to the provider", func(t *testing.T) { + _, err := env.ConsumerDyn.Resource(gvr).Namespace("default").Create(ctx, nsWidget("default", "in-scope"), metav1.CreateOptions{}) + require.NoError(t, err) + require.Eventually(t, func() bool { + _, err := env.ProviderDyn.Resource(gvr).Namespace("default").Get(ctx, "in-scope", metav1.GetOptions{}) + return err == nil + }, wait.ForeverTestTimeout, 200*time.Millisecond, "an instance in the bound namespace should sync to the provider") + }) + + t.Run("an instance in another namespace is not synced", func(t *testing.T) { + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "other"}})) + _, err := env.ConsumerDyn.Resource(gvr).Namespace("other").Create(ctx, nsWidget("other", "out-of-scope"), metav1.CreateOptions{}) + require.NoError(t, err) + require.Never(t, func() bool { + _, err := env.ProviderDyn.Resource(gvr).Namespace("other").Get(ctx, "out-of-scope", metav1.GetOptions{}) + return err == nil + }, 4*time.Second, 300*time.Millisecond, "an instance outside the bound namespace must not sync") + }) +} + +func nsWidget(ns, name string) *unstructured.Unstructured { + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(framework.WidgetGVK()) + u.SetNamespace(ns) + u.SetName(name) + _ = unstructured.SetNestedField(u.Object, "small", "spec", "size") + return u +} diff --git a/test/e2e/related_resources_more_test.go b/test/e2e/related_resources_more_test.go new file mode 100644 index 000000000..fdd62a3ad --- /dev/null +++ b/test/e2e/related_resources_more_test.go @@ -0,0 +1,225 @@ +/* +Copyright 2026 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1alpha1 "github.com/kbind/kbind/sdk/apis/core/v1alpha1" + "github.com/kbind/kbind/test/e2e/framework" +) + +// setupWidgetRelatedBinding creates the demo Connection (CRD schema source) and a +// Ready ClusterBinding for the Widget API carrying the given relatedResources +// rules. The Widget CRD must already be installed on the provider. +func setupWidgetRelatedBinding(t *testing.T, env *framework.Env, ctx context.Context, name string, rr []corev1alpha1.RelatedResource) { + t.Helper() + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "demo-provider"}, + Spec: corev1alpha1.ConnectionSpec{ + KubeconfigSecretRef: corev1alpha1.SecretKeyRef{Namespace: framework.KbindNamespace, Name: "demo-provider-kubeconfig", Key: "kubeconfig"}, + Schema: corev1alpha1.SchemaPolicy{Source: corev1alpha1.SchemaSourceCRD}, + }, + })) + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1alpha1.ClusterBinding{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: corev1alpha1.BindingSpec{ + ConnectionRef: corev1alpha1.ConnectionRef{Name: "demo-provider"}, + APIs: []corev1alpha1.APIRef{{Name: widgetCRDName}}, + RelatedResources: rr, + }, + })) + framework.WaitForConditionTrue(t, func() ([]metav1.Condition, error) { + cb := &corev1alpha1.ClusterBinding{} + err := env.ConsumerClient.Get(ctx, client.ObjectKey{Name: name}, cb) + return cb.Status.Conditions, err + }, corev1alpha1.ConditionReady) +} + +// TestSlimCoreRelatedConfigMaps covers ConfigMaps as a related resource +// (FromProvider): sync, GC on stop-matching, and the foreign-object guard. +func TestSlimCoreRelatedConfigMaps(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + env.InstallExportedWidgetCRD(t) + setupWidgetRelatedBinding(t, env, ctx, "widgets", []corev1alpha1.RelatedResource{{ + Resource: "configmaps", + Direction: corev1alpha1.FromProvider, + Selector: &corev1alpha1.RelatedResourceSelector{LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "widget"}}}, + }}) + + key := client.ObjectKey{Namespace: "default", Name: "widget-config"} + + t.Run("a label-selected provider ConfigMap syncs to the consumer", func(t *testing.T) { + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "widget-config", Labels: map[string]string{"app": "widget"}}, + Data: map[string]string{"region": "eu"}, + })) + require.Eventually(t, func() bool { + cm := &corev1.ConfigMap{} + if err := env.ConsumerClient.Get(ctx, key, cm); err != nil { + return false + } + return cm.Data["region"] == "eu" && + cm.Labels[corev1alpha1.LabelManaged] == "true" && + cm.Annotations[corev1alpha1.AnnotationRelatedBinding] != "" + }, 30*time.Second, 200*time.Millisecond, "the label-selected provider ConfigMap should sync to the consumer") + }) + + t.Run("the synced ConfigMap is GC'd when it stops matching", func(t *testing.T) { + cm := &corev1.ConfigMap{} + require.NoError(t, env.ProviderClient.Get(ctx, key, cm)) + delete(cm.Labels, "app") + require.NoError(t, env.ProviderClient.Update(ctx, cm)) + require.Eventually(t, func() bool { + c := &corev1.ConfigMap{} + return apierrors.IsNotFound(env.ConsumerClient.Get(ctx, key, c)) + }, 30*time.Second, 200*time.Millisecond, "the consumer copy should be GC'd once the ConfigMap stops matching") + }) + + t.Run("a foreign ConfigMap of the same name is not overwritten", func(t *testing.T) { + foreignKey := client.ObjectKey{Namespace: "default", Name: "foreign-config"} + // A pre-existing, unmanaged consumer ConfigMap. + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "foreign-config"}, + Data: map[string]string{"region": "FOREIGN"}, + })) + // The provider exports a same-named, matching ConfigMap. + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "foreign-config", Labels: map[string]string{"app": "widget"}}, + Data: map[string]string{"region": "PROVIDER"}, + })) + require.Never(t, func() bool { + cm := &corev1.ConfigMap{} + if err := env.ConsumerClient.Get(ctx, foreignKey, cm); err != nil { + return false + } + return cm.Data["region"] == "PROVIDER" || cm.Labels[corev1alpha1.LabelManaged] == "true" + }, 3*time.Second, 300*time.Millisecond, "the foreign consumer ConfigMap must not be overwritten or marked managed") + }) +} + +// TestSlimCoreRelatedReverseDirection covers FromConsumer (consumer -> provider): +// a consumer Secret is synced UP to the provider and GC'd when it stops matching. +func TestSlimCoreRelatedReverseDirection(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + env.InstallExportedWidgetCRD(t) + setupWidgetRelatedBinding(t, env, ctx, "widgets", []corev1alpha1.RelatedResource{{ + Resource: "secrets", + Direction: corev1alpha1.FromConsumer, + Selector: &corev1alpha1.RelatedResourceSelector{LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "widget"}}}, + }}) + + key := client.ObjectKey{Namespace: "default", Name: "consumer-creds"} + + t.Run("a label-selected consumer Secret syncs up to the provider", func(t *testing.T) { + require.NoError(t, env.ConsumerClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "consumer-creds", Labels: map[string]string{"app": "widget"}}, + StringData: map[string]string{"token": "up3cr3t"}, + })) + require.Eventually(t, func() bool { + s := &corev1.Secret{} + if err := env.ProviderClient.Get(ctx, key, s); err != nil { + return false + } + return string(s.Data["token"]) == "up3cr3t" && + s.Labels[corev1alpha1.LabelManaged] == "true" && + s.Annotations[corev1alpha1.AnnotationRelatedBinding] != "" + }, 30*time.Second, 200*time.Millisecond, "the consumer Secret should sync up to the provider") + }) + + t.Run("the provider copy is GC'd when the consumer Secret stops matching", func(t *testing.T) { + s := &corev1.Secret{} + require.NoError(t, env.ConsumerClient.Get(ctx, key, s)) + delete(s.Labels, "app") + require.NoError(t, env.ConsumerClient.Update(ctx, s)) + require.Eventually(t, func() bool { + c := &corev1.Secret{} + return apierrors.IsNotFound(env.ProviderClient.Get(ctx, key, c)) + }, 30*time.Second, 200*time.Millisecond, "the provider copy should be GC'd once the consumer Secret stops matching") + }) +} + +// TestSlimCoreRelatedNamedSelector covers the named selector: only the listed +// objects are synced; others are left alone. +func TestSlimCoreRelatedNamedSelector(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + env.InstallExportedWidgetCRD(t) + setupWidgetRelatedBinding(t, env, ctx, "widgets", []corev1alpha1.RelatedResource{{ + Resource: "configmaps", + Direction: corev1alpha1.FromProvider, + Selector: &corev1alpha1.RelatedResourceSelector{Names: []string{"wanted"}}, + }}) + + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "wanted"}, Data: map[string]string{"k": "v"}, + })) + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "unwanted"}, Data: map[string]string{"k": "v"}, + })) + + require.Eventually(t, func() bool { + cm := &corev1.ConfigMap{} + return env.ConsumerClient.Get(ctx, client.ObjectKey{Namespace: "default", Name: "wanted"}, cm) == nil + }, 30*time.Second, 200*time.Millisecond, "the named ConfigMap should sync") + + // Once "wanted" has synced the reconcile loop has run, so a non-named object + // would already have synced if names were ignored. + require.Never(t, func() bool { + cm := &corev1.ConfigMap{} + return env.ConsumerClient.Get(ctx, client.ObjectKey{Namespace: "default", Name: "unwanted"}, cm) == nil + }, 3*time.Second, 300*time.Millisecond, "the non-named ConfigMap must not sync") +} + +// TestSlimCoreRelatedCleanupOnUnbind verifies that deleting the binding removes +// the related copies it created (cleanupRelated on the unbind finalizer). +func TestSlimCoreRelatedCleanupOnUnbind(t *testing.T) { + env := framework.Start(t) + ctx := context.Background() + env.InstallExportedWidgetCRD(t) + setupWidgetRelatedBinding(t, env, ctx, "widgets", []corev1alpha1.RelatedResource{{ + Resource: "secrets", + Direction: corev1alpha1.FromProvider, + Selector: &corev1alpha1.RelatedResourceSelector{LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "widget"}}}, + }}) + + key := client.ObjectKey{Namespace: "default", Name: "widget-creds"} + require.NoError(t, env.ProviderClient.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "widget-creds", Labels: map[string]string{"app": "widget"}}, + StringData: map[string]string{"token": "s3cr3t"}, + })) + require.Eventually(t, func() bool { + s := &corev1.Secret{} + return env.ConsumerClient.Get(ctx, key, s) == nil + }, 30*time.Second, 200*time.Millisecond, "the related Secret should sync before unbind") + + require.NoError(t, env.ConsumerClient.Delete(ctx, &corev1alpha1.ClusterBinding{ObjectMeta: metav1.ObjectMeta{Name: "widgets"}})) + require.Eventually(t, func() bool { + s := &corev1.Secret{} + return apierrors.IsNotFound(env.ConsumerClient.Get(ctx, key, s)) + }, 30*time.Second, 200*time.Millisecond, "the related copy should be cleaned up on unbind") +}