// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package mutation import ( "context" "testing" "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" capevents "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/tenant" "github.com/projectcapsule/capsule/pkg/users" ) func TestOwnerReferenceHandlerAllowsAdminTenantMigration(t *testing.T) { t.Parallel() green := testTenant("green", "green-uid") blue := testTenant("blue", "blue-uid") oldNs := testTenantNamespace("workloads", green) newNs := testTenantNamespace("workloads", blue) c := testClient(t, green, blue) response := (&ownerReferenceHandler{}).OnUpdate( c, c, users.AdmissionUser{Type: users.AdmissionUserAdmin}, newNs, oldNs, nil, nil, )(context.Background(), admission.Request{}) if response != nil { t.Fatalf("expected migration to proceed, got response %#v", response) } assertTenantAssignment(t, newNs, blue) } func TestOwnerReferenceHandlerAllowsAdminTenantDetachment(t *testing.T) { t.Parallel() green := testTenant("green", "green-uid") oldNs := testTenantNamespace("workloads", green) newNs := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: oldNs.GetName()}} c := testClient(t, green) response := (&ownerReferenceHandler{}).OnUpdate( c, c, users.AdmissionUser{Type: users.AdmissionUserAdmin}, newNs, oldNs, nil, nil, )(context.Background(), admission.Request{}) if response != nil { t.Fatalf("expected detachment to proceed, got response %#v", response) } if tenant.HasTenantReference(newNs) { t.Fatalf("detached namespace still has tenant ownership: %#v", newNs.ObjectMeta) } } func TestOwnerReferenceHandlerRejectsTenantOwnerAssignmentChanges(t *testing.T) { t.Parallel() owner := rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Name: "alice", Kind: rbac.UserOwner}} green := testTenant("green", "green-uid") green.Status.Owners = rbac.OwnerStatusListSpec{owner} blue := testTenant("blue", "blue-uid") oldNs := testTenantNamespace("workloads", green) recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil) tests := []struct { name string new *corev1.Namespace }{ {name: "migration", new: testTenantNamespace("workloads", blue)}, { name: "label migration with empty ownerReferences", new: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ Name: "workloads", Labels: map[string]string{meta.TenantLabel: blue.Name}, }}, }, {name: "detachment", new: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "workloads"}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := testClient(t, green, blue) newNs := tt.new.DeepCopy() response := (&ownerReferenceHandler{}).OnUpdate( c, c, users.AdmissionUser{Type: users.AdmissionUserCapsule, Username: owner.Name}, newNs, oldNs.DeepCopy(), nil, recorder, )(context.Background(), admission.Request{}) if response == nil || response.Allowed { t.Fatalf("expected assignment change to be denied, got %#v", response) } }) } } func TestOwnerReferenceHandlerRepairsTenantOwnerReferences(t *testing.T) { t.Parallel() owner := rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Name: "alice", Kind: rbac.UserOwner}} green := testTenant("green", "green-uid") green.Status.Owners = rbac.OwnerStatusListSpec{owner} oldNs := testTenantNamespace("workloads", green) recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil) tests := []struct { name string new *corev1.Namespace }{ { name: "missing reference", new: func() *corev1.Namespace { ns := oldNs.DeepCopy() ns.OwnerReferences = nil return ns }(), }, { name: "additional reference", new: func() *corev1.Namespace { ns := oldNs.DeepCopy() ns.OwnerReferences = append(ns.OwnerReferences, metav1.OwnerReference{ APIVersion: capsulev1beta2.GroupVersion.String(), Kind: tenant.ObjectReferenceTenantKind, Name: "blue", UID: "blue-uid", }) return ns }(), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := testClient(t, green) newNs := tt.new.DeepCopy() response := (&ownerReferenceHandler{}).OnUpdate( c, c, users.AdmissionUser{Type: users.AdmissionUserCapsule, Username: owner.Name}, newNs, oldNs.DeepCopy(), nil, recorder, )(context.Background(), admission.Request{}) if response != nil { t.Fatalf("expected ownerReferences to be normalized, got %#v", response) } assertTenantAssignment(t, newNs, green) }) } } func TestOwnerReferenceHandlerRejectsNonOwnerJoin(t *testing.T) { t.Parallel() green := testTenant("green", "green-uid") c := testClient(t, green) recorder := capevents.NewEventRecorder(nil, logr.Discard(), nil, nil) response := (&ownerReferenceHandler{}).OnUpdate( c, c, users.AdmissionUser{Type: users.AdmissionUserCapsule, Username: "alice"}, testTenantNamespace("workloads", green), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "workloads"}}, nil, recorder, )(context.Background(), admission.Request{}) if response == nil || response.Allowed { t.Fatalf("expected unmanaged namespace join to be denied, got %#v", response) } } func testTenant(name string, uid types.UID) *capsulev1beta2.Tenant { return &capsulev1beta2.Tenant{ObjectMeta: metav1.ObjectMeta{Name: name, UID: uid}} } func testTenantNamespace(name string, tnt *capsulev1beta2.Tenant) *corev1.Namespace { return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ Name: name, Labels: map[string]string{meta.TenantLabel: tnt.GetName()}, OwnerReferences: []metav1.OwnerReference{{ APIVersion: capsulev1beta2.GroupVersion.String(), Kind: tenant.ObjectReferenceTenantKind, Name: tnt.GetName(), UID: tnt.GetUID(), }}, }} } func assertTenantAssignment(t *testing.T, ns *corev1.Namespace, tnt *capsulev1beta2.Tenant) { t.Helper() if got := tenant.TenanLabelValue(ns); got != tnt.GetName() { t.Fatalf("tenant label = %q, want %q", got, tnt.GetName()) } refs := tenant.TenantOwnerReferences(ns) if len(refs) != 1 || !tenant.IsTenantOwnerReferenceForTenant(refs[0], tnt) { t.Fatalf("tenant ownerReferences = %#v, want only tenant %q", refs, tnt.GetName()) } } func testClient(t *testing.T, tenants ...*capsulev1beta2.Tenant) client.Client { t.Helper() objects := make([]client.Object, 0, len(tenants)) for _, tnt := range tenants { objects = append(objects, tnt) } return fake.NewClientBuilder(). WithScheme(testScheme(t)). WithObjects(objects...). Build() } func testScheme(t *testing.T) *runtime.Scheme { t.Helper() scheme := runtime.NewScheme() if err := corev1.AddToScheme(scheme); err != nil { t.Fatalf("add core API to scheme: %v", err) } if err := capsulev1beta2.AddToScheme(scheme); err != nil { t.Fatalf("add Capsule API to scheme: %v", err) } return scheme }