// Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 package configuration_test import ( "context" "errors" "reflect" "testing" "time" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" capsuleapi "github.com/projectcapsule/capsule/pkg/api" capsulemeta "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" "github.com/projectcapsule/capsule/pkg/runtime/configuration" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" ) type failingConfigurationReader struct { client.Reader gets int } type timeoutConfigurationClient struct { client.Client } func (c *timeoutConfigurationClient) Get( context.Context, client.ObjectKey, client.Object, ...client.GetOption, ) error { return context.DeadlineExceeded } func (r *failingConfigurationReader) Get( context.Context, client.ObjectKey, client.Object, ...client.GetOption, ) error { r.gets++ return errors.New("direct reader should not be used") } func TestDefaultCapsuleConfiguration(t *testing.T) { t.Parallel() got := configuration.DefaultCapsuleConfiguration() if !reflect.DeepEqual(got.Users, rbac.UserListSpec{{Kind: rbac.GroupOwner, Name: "projectcapsule.dev"}}) { t.Fatalf("DefaultCapsuleConfiguration().Users = %#v", got.Users) } if got.CacheInvalidation.Duration != time.Hour { t.Fatalf("CacheInvalidation = %s, want 1h", got.CacheInvalidation.Duration) } if got.RBAC == nil || got.RBAC.DeleterClusterRole != "capsule-namespace-deleter" { t.Fatalf("RBAC defaults = %#v, want namespace deleter defaults", got.RBAC) } } func TestEnvironmentHelpers(t *testing.T) { t.Setenv(configuration.EnvironmentServiceaccountName, "capsule-controller") t.Setenv(configuration.EnvironmentControllerNamespace, "capsule-system") if got := configuration.ControllerNamespace(); got != "capsule-system" { t.Fatalf("ControllerNamespace() = %q, want capsule-system", got) } name, namespace := configuration.ControllerServiceAccount() if name != "capsule-controller" || namespace != "capsule-system" { t.Fatalf("ControllerServiceAccount() = %q/%q, want capsule-controller/capsule-system", namespace, name) } if !configuration.IsControllerServiceAccount("capsule-controller", "capsule-system") { t.Fatalf("IsControllerServiceAccount() = false, want true") } if configuration.IsControllerServiceAccount("other", "capsule-system") { t.Fatalf("IsControllerServiceAccount() = true, want false") } } func TestNewCapsuleConfigurationCreatesDefaultWhenMissing(t *testing.T) { t.Parallel() ctx := context.Background() cl := configurationFakeClient(t) cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{Host: "https://kubernetes.default"}, "capsule") got := cfg.GetConfigObject() if got.Name != "capsule" { t.Fatalf("GetConfigObject().Name = %q, want capsule", got.Name) } if !reflect.DeepEqual(got.Spec.Users, configuration.DefaultCapsuleConfiguration().Users) { t.Fatalf("default config users = %#v, want %#v", got.Spec.Users, configuration.DefaultCapsuleConfiguration().Users) } var stored capsulev1beta2.CapsuleConfiguration if err := cl.Get(ctx, client.ObjectKey{Name: "capsule"}, &stored); err != nil { t.Fatalf("created configuration was not stored: %v", err) } } func TestNewCapsuleConfigurationCreatesDefaultAfterInformerTimeout(t *testing.T) { t.Parallel() ctx := context.Background() storage := configurationFakeClient(t) cachedClient := &timeoutConfigurationClient{Client: storage} directReader := configurationFakeClient(t) cfg := configuration.NewCapsuleConfiguration( ctx, cachedClient, directReader, &rest.Config{Host: "https://kubernetes.default"}, "capsule", ) got := cfg.GetConfigObject() if got.Name != "capsule" { t.Fatalf("GetConfigObject().Name = %q, want capsule", got.Name) } if !reflect.DeepEqual(got.Spec, configuration.DefaultCapsuleConfiguration()) { t.Fatalf("default configuration = %#v, want %#v", got.Spec, configuration.DefaultCapsuleConfiguration()) } stored := &capsulev1beta2.CapsuleConfiguration{} if err := storage.Get(ctx, client.ObjectKey{Name: "capsule"}, stored); err != nil { t.Fatalf("created configuration was not stored: %v", err) } } func TestCapsuleConfigurationUsesInformerClientBeforeDirectReader(t *testing.T) { t.Parallel() ctx := context.Background() stored := &capsulev1beta2.CapsuleConfiguration{ ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, Spec: capsulev1beta2.CapsuleConfigurationSpec{ ForceTenantPrefix: true, }, } cl := configurationFakeClient(t, stored) reader := &failingConfigurationReader{Reader: cl} cfg := configuration.NewCapsuleConfiguration(ctx, cl, reader, &rest.Config{}, "capsule") if !cfg.ForceTenantPrefix() { t.Fatal("ForceTenantPrefix() = false, want cached configuration value") } if reader.gets != 0 { t.Fatalf("direct reader Get calls = %d, want 0", reader.gets) } } func TestCapsuleConfigurationGetters(t *testing.T) { t.Parallel() ctx := context.Background() stored := &capsulev1beta2.CapsuleConfiguration{ ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, Spec: capsulev1beta2.CapsuleConfigurationSpec{ ProtectedNamespaceRegexpString: "^(kube|openshift)-", ForceTenantPrefix: true, EnableTLSReconciler: true, AllowServiceAccountPromotion: true, CapsuleResources: capsulev1beta2.CapsuleResources{ TLSSecretName: "capsule-tls", }, Users: rbac.UserListSpec{ {Kind: rbac.UserOwner, Name: "alice"}, {Kind: rbac.GroupOwner, Name: "developers"}, {Kind: rbac.ServiceAccountOwner, Name: "system:serviceaccount:tenant-a:builder"}, }, UserNames: []string{"legacy-user"}, UserGroups: []string{"legacy-group"}, IgnoreUserWithGroups: []string{"ignored"}, Administrators: rbac.UserListSpec{{Kind: rbac.UserOwner, Name: "admin"}}, NodeMetadata: &capsulev1beta2.NodeMetadata{ ForbiddenLabels: capsuleapi.ForbiddenListSpec{Exact: []string{"node-role.kubernetes.io/control-plane"}}, ForbiddenAnnotations: capsuleapi.ForbiddenListSpec{Regex: "internal/.*"}, }, RBAC: &capsulev1beta2.RBACConfiguration{DeleterClusterRole: "delete-role"}, CacheInvalidation: metav1.Duration{ Duration: 2 * time.Hour, }, Impersonation: capsulev1beta2.ServiceAccountClient{ Endpoint: "https://impersonation.example", SkipTLSVerify: true, }, Events: capsulev1beta2.EventsConfiguration{ClusterEventNamespace: "audit"}, }, Status: capsulev1beta2.CapsuleConfigurationStatus{ Users: rbac.UserListSpec{{Kind: rbac.GroupOwner, Name: "status-group"}}, }, } cl := configurationFakeClient(t, stored) cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{ Host: "https://kubernetes.default", TLSClientConfig: rest.TLSClientConfig{ CAData: []byte("ca"), CAFile: "ca-file", }, }, "capsule") regex, err := cfg.ProtectedNamespaceRegexp() if err != nil { t.Fatalf("ProtectedNamespaceRegexp() unexpected error: %v", err) } if !regex.MatchString("kube-system") { t.Fatalf("ProtectedNamespaceRegexp() did not match kube-system") } if !cfg.ForceTenantPrefix() || !cfg.EnableTLSConfiguration() || !cfg.AllowServiceAccountPromotion() { t.Fatalf("boolean getters did not return configured true values") } if cfg.TLSSecretName() != "capsule-tls" { t.Fatalf("TLSSecretName() = %q, want capsule-tls", cfg.TLSSecretName()) } if cfg.TenantCRDName() != configuration.TenantCRDName { t.Fatalf("TenantCRDName() = %q, want %q", cfg.TenantCRDName(), configuration.TenantCRDName) } if !reflect.DeepEqual(cfg.IgnoreUserWithGroups(), []string{"ignored"}) { t.Fatalf("IgnoreUserWithGroups() = %#v", cfg.IgnoreUserWithGroups()) } if !reflect.DeepEqual(cfg.GetUsersByStatus(), rbac.UserListSpec{{Kind: rbac.GroupOwner, Name: "status-group"}}) { t.Fatalf("GetUsersByStatus() = %#v", cfg.GetUsersByStatus()) } if cfg.ForbiddenUserNodeLabels() == nil || cfg.ForbiddenUserNodeLabels().Exact[0] != "node-role.kubernetes.io/control-plane" { t.Fatalf("ForbiddenUserNodeLabels() = %#v", cfg.ForbiddenUserNodeLabels()) } if cfg.ForbiddenUserNodeAnnotations() == nil || cfg.ForbiddenUserNodeAnnotations().Regex != "internal/.*" { t.Fatalf("ForbiddenUserNodeAnnotations() = %#v", cfg.ForbiddenUserNodeAnnotations()) } if cfg.Administrators()[0].Name != "admin" { t.Fatalf("Administrators() = %#v", cfg.Administrators()) } if cfg.Events().ClusterEventNamespace != "audit" { t.Fatalf("Events() = %#v", cfg.Events()) } if cfg.RBAC().DeleterClusterRole != "delete-role" { t.Fatalf("RBAC() = %#v", cfg.RBAC()) } if cfg.CacheInvalidation().Duration != 2*time.Hour { t.Fatalf("CacheInvalidation() = %s", cfg.CacheInvalidation().Duration) } wantUsers := rbac.UserListSpec{ {Kind: rbac.GroupOwner, Name: "developers"}, {Kind: rbac.GroupOwner, Name: "legacy-group"}, {Kind: rbac.ServiceAccountOwner, Name: "system:serviceaccount:tenant-a:builder"}, {Kind: rbac.UserOwner, Name: "alice"}, {Kind: rbac.UserOwner, Name: "legacy-user"}, } if got := cfg.Users(); !reflect.DeepEqual(got, wantUsers) { t.Fatalf("Users() = %#v, want %#v", got, wantUsers) } saClient, err := cfg.ServiceAccountClient(ctx) if err != nil { t.Fatalf("ServiceAccountClient() unexpected error: %v", err) } if saClient.Host != "https://impersonation.example" || !saClient.Insecure || len(saClient.TLSClientConfig.CAData) != 0 || saClient.TLSClientConfig.CAFile != "" { t.Fatalf("ServiceAccountClient() = %#v, want endpoint with insecure TLS", saClient) } } func TestProtectedNamespaceRegexpErrors(t *testing.T) { t.Parallel() ctx := context.Background() cl := configurationFakeClient(t, &capsulev1beta2.CapsuleConfiguration{ ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, Spec: capsulev1beta2.CapsuleConfigurationSpec{ ProtectedNamespaceRegexpString: "[", }, }) cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{}, "capsule") if _, err := cfg.ProtectedNamespaceRegexp(); err == nil { t.Fatalf("ProtectedNamespaceRegexp() expected error") } } func TestServiceAccountClientLoadsCASecret(t *testing.T) { t.Parallel() ctx := context.Background() cl := configurationFakeClient(t, &capsulev1beta2.CapsuleConfiguration{ ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, Spec: capsulev1beta2.CapsuleConfigurationSpec{ Impersonation: capsulev1beta2.ServiceAccountClient{ Endpoint: "https://impersonation.example", CASecretNamespace: capsulemeta.RFC1123SubdomainName("capsule-system"), CASecretName: capsulemeta.RFC1123Name("ca"), CASecretKey: "ca.crt", }, }, }, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "capsule-system", Name: "ca"}, Data: map[string][]byte{"ca.crt": []byte("secret-ca")}}, ) cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{ Host: "https://kubernetes.default", TLSClientConfig: rest.TLSClientConfig{CAFile: "old"}, }, "capsule") got, err := cfg.ServiceAccountClient(ctx) if err != nil { t.Fatalf("ServiceAccountClient() unexpected error: %v", err) } if got.Host != "https://impersonation.example" || string(got.TLSClientConfig.CAData) != "secret-ca" || got.TLSClientConfig.CAFile != "" { t.Fatalf("ServiceAccountClient() = %#v, want CA data from secret", got) } } func TestServiceAccountClientMissingCASecretKey(t *testing.T) { t.Parallel() ctx := context.Background() cl := configurationFakeClient(t, &capsulev1beta2.CapsuleConfiguration{ ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, Spec: capsulev1beta2.CapsuleConfigurationSpec{ Impersonation: capsulev1beta2.ServiceAccountClient{ CASecretNamespace: capsulemeta.RFC1123SubdomainName("capsule-system"), CASecretName: capsulemeta.RFC1123Name("ca"), CASecretKey: "missing", }, }, }, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "capsule-system", Name: "ca"}, Data: map[string][]byte{"ca.crt": []byte("secret-ca")}}, ) cfg := configuration.NewCapsuleConfiguration(ctx, cl, cl, &rest.Config{}, "capsule") if _, err := cfg.ServiceAccountClient(ctx); err == nil { t.Fatalf("ServiceAccountClient() expected error for missing CA key") } } func configurationFakeClient(t *testing.T, objects ...client.Object) client.Client { t.Helper() scheme := runtime.NewScheme() if err := corev1.AddToScheme(scheme); err != nil { t.Fatalf("adding core scheme: %v", err) } if err := capsulev1beta2.AddToScheme(scheme); err != nil { t.Fatalf("adding capsule scheme: %v", err) } return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() }