Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions test/README.md
Original file line number Diff line number Diff line change
@@ -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.
101 changes: 101 additions & 0 deletions test/e2e/namespaced_binding_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading