package k8s import ( "context" "errors" "strings" "testing" "time" authenticationv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/fake" k8stesting "k8s.io/client-go/testing" "github.com/ocdp/cluster-service/internal/domain/entity" ) func TestTenantClientEnsureTenantCreatesResources(t *testing.T) { ctx := context.Background() clientset := fake.NewSimpleClientset() client := NewTenantClientForClientset(clientset) binding := tenantBinding() if err := client.EnsureTenant(ctx, nil, binding); err != nil { t.Fatalf("EnsureTenant returned error: %v", err) } if _, err := clientset.CoreV1().Namespaces().Get(ctx, binding.Namespace, metav1.GetOptions{}); err != nil { t.Fatalf("expected namespace: %v", err) } if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); err != nil { t.Fatalf("expected service account: %v", err) } roleBinding, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{}) if err != nil { t.Fatalf("expected role binding: %v", err) } if roleBinding.RoleRef.Kind != "ClusterRole" || roleBinding.RoleRef.Name != binding.ClusterRoleName { t.Fatalf("unexpected role ref: %#v", roleBinding.RoleRef) } if len(roleBinding.Subjects) != 1 || roleBinding.Subjects[0].Name != binding.ServiceAccountName { t.Fatalf("unexpected role binding subjects: %#v", roleBinding.Subjects) } quota, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{}) if err != nil { t.Fatalf("expected resource quota: %v", err) } if quota.Spec.Hard.Cpu().String() != "2" { t.Fatalf("expected cpu quota 2, got %s", quota.Spec.Hard.Cpu().String()) } } func TestTenantClientEnsureTenantUpdatesExistingResources(t *testing.T) { ctx := context.Background() binding := tenantBinding() clientset := fake.NewSimpleClientset( &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace, Labels: binding.Labels}}, &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}}, &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{Name: binding.RoleBindingName, Namespace: binding.Namespace}, RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: "view"}, }, &corev1.ResourceQuota{ ObjectMeta: metav1.ObjectMeta{Name: binding.ResourceQuotaName, Namespace: binding.Namespace}, Spec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("1"), }}, }, ) client := NewTenantClientForClientset(clientset) if err := client.EnsureTenant(ctx, nil, binding); err != nil { t.Fatalf("EnsureTenant returned error: %v", err) } roleBinding, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{}) if err != nil { t.Fatalf("expected updated role binding: %v", err) } if roleBinding.RoleRef.Name != binding.ClusterRoleName { t.Fatalf("expected role ref %q, got %q", binding.ClusterRoleName, roleBinding.RoleRef.Name) } if roleBinding.Labels["ocdp.io/tenant"] != binding.Namespace { t.Fatalf("expected tenant label on updated role binding, got %#v", roleBinding.Labels) } quota, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{}) if err != nil { t.Fatalf("expected updated quota: %v", err) } if quota.Spec.Hard.Cpu().String() != "2" { t.Fatalf("expected updated cpu quota 2, got %s", quota.Spec.Hard.Cpu().String()) } } func TestTenantClientSuspendTenantDeletesOnlyRoleBinding(t *testing.T) { ctx := context.Background() binding := tenantBinding() clientset := fake.NewSimpleClientset( &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace, Labels: binding.Labels}}, &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}}, desiredRoleBinding(binding), ) client := NewTenantClientForClientset(clientset) if err := client.SuspendTenant(ctx, nil, binding); err != nil { t.Fatalf("SuspendTenant returned error: %v", err) } if _, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{}); !apierrors.IsNotFound(err) { t.Fatalf("expected deleted role binding, got err %v", err) } if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); err != nil { t.Fatalf("service account should remain: %v", err) } } func TestTenantClientDeleteTenantDeletesTenantResources(t *testing.T) { ctx := context.Background() binding := tenantBinding() clientset := fake.NewSimpleClientset( &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace, Labels: binding.Labels}}, &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}}, desiredRoleBinding(binding), &corev1.ResourceQuota{ObjectMeta: metav1.ObjectMeta{Name: binding.ResourceQuotaName, Namespace: binding.Namespace}}, ) client := NewTenantClientForClientset(clientset) if err := client.DeleteTenant(ctx, nil, binding); err != nil { t.Fatalf("DeleteTenant returned error: %v", err) } if _, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{}); !apierrors.IsNotFound(err) { t.Fatalf("expected role binding deleted, got %v", err) } if _, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{}); !apierrors.IsNotFound(err) { t.Fatalf("expected resource quota deleted, got %v", err) } if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); !apierrors.IsNotFound(err) { t.Fatalf("expected service account deleted, got %v", err) } if _, err := clientset.CoreV1().Namespaces().Get(ctx, binding.Namespace, metav1.GetOptions{}); !apierrors.IsNotFound(err) { t.Fatalf("expected namespace deleted, got %v", err) } } func TestTenantClientDeleteTenantRejectsProtectedNamespace(t *testing.T) { ctx := context.Background() client := NewTenantClientForClientset(fake.NewSimpleClientset( &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}, )) binding := entity.NewTenantBinding("default") err := client.DeleteTenant(ctx, nil, binding) if !errors.Is(err, entity.ErrProtectedNamespace) { t.Fatalf("expected protected namespace error, got %v", err) } } func TestTenantClientIssueKubeconfigCapsTokenTTL(t *testing.T) { ctx := context.Background() binding := tenantBinding() clientset := fake.NewSimpleClientset(&corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}, }) var requestedExpirationSeconds int64 expiresAt := time.Now().Add(entity.MaxTenantKubeconfigTTL).UTC() clientset.Fake.PrependReactor("create", "serviceaccounts", func(action k8stesting.Action) (bool, runtime.Object, error) { if action.GetSubresource() != "token" { return false, nil, nil } createAction := action.(k8stesting.CreateAction) tokenRequest := createAction.GetObject().(*authenticationv1.TokenRequest) if tokenRequest.Spec.ExpirationSeconds != nil { requestedExpirationSeconds = *tokenRequest.Spec.ExpirationSeconds } return true, &authenticationv1.TokenRequest{ Status: authenticationv1.TokenRequestStatus{ Token: "short-lived-token", ExpirationTimestamp: metav1.NewTime(expiresAt), }, }, nil }) client := NewTenantClientForClientset(clientset) kubeconfig, err := client.IssueKubeconfig(ctx, &entity.Cluster{Name: "test", Host: "https://example.invalid"}, binding, 24*time.Hour) if err != nil { t.Fatalf("IssueKubeconfig returned error: %v", err) } if requestedExpirationSeconds != int64(entity.MaxTenantKubeconfigTTL.Seconds()) { t.Fatalf("expected capped ttl %d, got %d", int64(entity.MaxTenantKubeconfigTTL.Seconds()), requestedExpirationSeconds) } if !kubeconfig.ExpiresAt.Equal(expiresAt) { t.Fatalf("expected expiration %s, got %s", expiresAt, kubeconfig.ExpiresAt) } if !strings.Contains(kubeconfig.Kubeconfig, "short-lived-token") { t.Fatal("expected kubeconfig to contain issued token") } if !strings.Contains(kubeconfig.Kubeconfig, "namespace: tenant-a") { t.Fatalf("expected kubeconfig namespace, got:\n%s", kubeconfig.Kubeconfig) } } func tenantBinding() entity.TenantBinding { binding := entity.NewTenantBinding("tenant-a") binding.ResourceQuotaHard = corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("2"), corev1.ResourceMemory: resource.MustParse("4Gi"), } return binding }