diff --git a/pkg/controller/certmanager/cert_manager_networkpolicy_test.go b/pkg/controller/certmanager/cert_manager_networkpolicy_test.go new file mode 100644 index 000000000..66de8e92f --- /dev/null +++ b/pkg/controller/certmanager/cert_manager_networkpolicy_test.go @@ -0,0 +1,308 @@ +package certmanager + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" +) + +func TestValidateComponentName(t *testing.T) { + tests := []struct { + name string + componentName v1alpha1.ComponentName + expectError bool + }{ + { + name: "CoreController is valid", + componentName: v1alpha1.CoreController, + expectError: false, + }, + { + name: "CAInjector is valid", + componentName: v1alpha1.CAInjector, + expectError: false, + }, + { + name: "Webhook is valid", + componentName: v1alpha1.Webhook, + expectError: false, + }, + { + name: "empty string is invalid", + componentName: "", + expectError: true, + }, + { + name: "unknown component name is invalid", + componentName: "UnknownComponent", + expectError: true, + }, + { + name: "lowercase controller is invalid", + componentName: "controller", + expectError: true, + }, + } + + c := &CertManagerNetworkPolicyUserDefinedController{} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := c.validateComponentName(tc.componentName) + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported component name") + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGetPodSelectorForComponent(t *testing.T) { + tests := []struct { + name string + componentName v1alpha1.ComponentName + expectedLabels map[string]string + }{ + { + name: "CoreController returns cert-manager app label", + componentName: v1alpha1.CoreController, + expectedLabels: map[string]string{ + "app": "cert-manager", + }, + }, + { + name: "CAInjector returns cainjector app label", + componentName: v1alpha1.CAInjector, + expectedLabels: map[string]string{ + "app": "cainjector", + }, + }, + { + name: "Webhook returns webhook app label", + componentName: v1alpha1.Webhook, + expectedLabels: map[string]string{ + "app": "webhook", + }, + }, + { + name: "unknown component returns default label", + componentName: "Unknown", + expectedLabels: map[string]string{ + "app.kubernetes.io/name": "cert-manager", + }, + }, + } + + c := &CertManagerNetworkPolicyUserDefinedController{} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + selector := c.getPodSelectorForComponent(tc.componentName) + require.Equal(t, tc.expectedLabels, selector.MatchLabels) + }) + } +} + +func TestValidateNetworkPolicyConfig(t *testing.T) { + tests := []struct { + name string + certManager *v1alpha1.CertManager + expectError bool + errContains string + }{ + { + name: "valid config with single policy passes", + certManager: &v1alpha1.CertManager{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster"}, + Spec: v1alpha1.CertManagerSpec{ + NetworkPolicies: []v1alpha1.NetworkPolicy{ + { + Name: "allow-egress", + ComponentName: v1alpha1.CoreController, + }, + }, + }, + }, + expectError: false, + }, + { + name: "valid config with multiple policies passes", + certManager: &v1alpha1.CertManager{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster"}, + Spec: v1alpha1.CertManagerSpec{ + NetworkPolicies: []v1alpha1.NetworkPolicy{ + { + Name: "allow-egress-controller", + ComponentName: v1alpha1.CoreController, + }, + { + Name: "allow-egress-webhook", + ComponentName: v1alpha1.Webhook, + }, + { + Name: "allow-egress-cainjector", + ComponentName: v1alpha1.CAInjector, + }, + }, + }, + }, + expectError: false, + }, + { + name: "empty network policies list passes", + certManager: &v1alpha1.CertManager{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster"}, + Spec: v1alpha1.CertManagerSpec{ + NetworkPolicies: []v1alpha1.NetworkPolicy{}, + }, + }, + expectError: false, + }, + { + name: "nil network policies list passes", + certManager: &v1alpha1.CertManager{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster"}, + Spec: v1alpha1.CertManagerSpec{}, + }, + expectError: false, + }, + { + name: "invalid component name fails", + certManager: &v1alpha1.CertManager{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster"}, + Spec: v1alpha1.CertManagerSpec{ + NetworkPolicies: []v1alpha1.NetworkPolicy{ + { + Name: "bad-policy", + ComponentName: "InvalidComponent", + }, + }, + }, + }, + expectError: true, + errContains: "invalid component name", + }, + { + name: "empty policy name fails", + certManager: &v1alpha1.CertManager{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster"}, + Spec: v1alpha1.CertManagerSpec{ + NetworkPolicies: []v1alpha1.NetworkPolicy{ + { + Name: "", + ComponentName: v1alpha1.CoreController, + }, + }, + }, + }, + expectError: true, + errContains: "name cannot be empty", + }, + { + name: "second policy with invalid component fails", + certManager: &v1alpha1.CertManager{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster"}, + Spec: v1alpha1.CertManagerSpec{ + NetworkPolicies: []v1alpha1.NetworkPolicy{ + { + Name: "good-policy", + ComponentName: v1alpha1.CoreController, + }, + { + Name: "bad-policy", + ComponentName: "BadComponent", + }, + }, + }, + }, + expectError: true, + errContains: "network policy at index 1", + }, + } + + c := &CertManagerNetworkPolicyUserDefinedController{} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := c.validateNetworkPolicyConfig(tc.certManager) + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.errContains) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCreateUserNetworkPolicy(t *testing.T) { + tests := []struct { + name string + userPolicy v1alpha1.NetworkPolicy + expectedName string + expectedLabels map[string]string + expectedPodLabels map[string]string + }{ + { + name: "creates policy for CoreController", + userPolicy: v1alpha1.NetworkPolicy{ + Name: "allow-dns", + ComponentName: v1alpha1.CoreController, + }, + expectedName: "cert-manager-user-allow-dns", + expectedLabels: map[string]string{ + networkPolicyOwnerLabel: "cert-manager", + }, + expectedPodLabels: map[string]string{ + "app": "cert-manager", + }, + }, + { + name: "creates policy for Webhook", + userPolicy: v1alpha1.NetworkPolicy{ + Name: "allow-api", + ComponentName: v1alpha1.Webhook, + }, + expectedName: "cert-manager-user-allow-api", + expectedLabels: map[string]string{ + networkPolicyOwnerLabel: "cert-manager", + }, + expectedPodLabels: map[string]string{ + "app": "webhook", + }, + }, + { + name: "creates policy for CAInjector", + userPolicy: v1alpha1.NetworkPolicy{ + Name: "allow-egress", + ComponentName: v1alpha1.CAInjector, + }, + expectedName: "cert-manager-user-allow-egress", + expectedLabels: map[string]string{ + networkPolicyOwnerLabel: "cert-manager", + }, + expectedPodLabels: map[string]string{ + "app": "cainjector", + }, + }, + } + + c := &CertManagerNetworkPolicyUserDefinedController{} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + policy := c.createUserNetworkPolicy(tc.userPolicy) + + require.Equal(t, tc.expectedName, policy.Name) + require.Equal(t, certManagerNamespace, policy.Namespace) + require.Equal(t, tc.expectedLabels, policy.Labels) + require.Equal(t, tc.expectedPodLabels, policy.Spec.PodSelector.MatchLabels) + }) + } +} diff --git a/pkg/controller/certmanager/default_cert_manager_controller_test.go b/pkg/controller/certmanager/default_cert_manager_controller_test.go new file mode 100644 index 000000000..a9113fee2 --- /dev/null +++ b/pkg/controller/certmanager/default_cert_manager_controller_test.go @@ -0,0 +1,58 @@ +package certmanager + +import ( + "context" + "testing" + + operatorv1 "github.com/openshift/api/operator/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + fakeclientset "github.com/openshift/cert-manager-operator/pkg/operator/clientset/versioned/fake" +) + +func TestCreateDefaultCertManager(t *testing.T) { + fakeClient := fakeclientset.NewSimpleClientset() + + controller := &DefaultCertManagerController{ + certManagerClient: fakeClient.OperatorV1alpha1(), + } + + ctx := context.Background() + cm, err := controller.createDefaultCertManager(ctx) + require.NoError(t, err) + require.NotNil(t, cm) + + assert.Equal(t, "cluster", cm.Name) + assert.Equal(t, operatorv1.Managed, cm.Spec.ManagementState) + + // Verify the resource was actually created in the fake client + got, err := fakeClient.OperatorV1alpha1().CertManagers().Get(ctx, "cluster", metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "cluster", got.Name) + assert.Equal(t, operatorv1.Managed, got.Spec.ManagementState) +} + +func TestCreateDefaultCertManagerAlreadyExists(t *testing.T) { + existing := &v1alpha1.CertManager{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + }, + Spec: v1alpha1.CertManagerSpec{ + OperatorSpec: operatorv1.OperatorSpec{ + ManagementState: operatorv1.Managed, + }, + }, + } + fakeClient := fakeclientset.NewSimpleClientset(existing) + + controller := &DefaultCertManagerController{ + certManagerClient: fakeClient.OperatorV1alpha1(), + } + + ctx := context.Background() + _, err := controller.createDefaultCertManager(ctx) + require.Error(t, err, "creating a duplicate CertManager should fail") +} diff --git a/pkg/controller/certmanager/deployment_log_level_test.go b/pkg/controller/certmanager/deployment_log_level_test.go new file mode 100644 index 000000000..66a4a919d --- /dev/null +++ b/pkg/controller/certmanager/deployment_log_level_test.go @@ -0,0 +1,118 @@ +package certmanager + +import ( + "testing" + + operatorv1 "github.com/openshift/api/operator/v1" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func newTestDeploymentWithArgs(args []string) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cert-manager", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "cert-manager-controller", + Args: args, + }, + }, + }, + }, + }, + } +} + +func TestWithLogLevel(t *testing.T) { + tests := []struct { + name string + logLevel operatorv1.LogLevel + existingArgs []string + wantArgs []string + wantChanged bool + }{ + { + name: "Normal log level sets --v=2", + logLevel: operatorv1.Normal, + existingArgs: []string{"--cluster-resource-namespace=cert-manager"}, + wantArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=2"}, + wantChanged: true, + }, + { + name: "Debug log level sets --v=4", + logLevel: operatorv1.Debug, + existingArgs: []string{"--cluster-resource-namespace=cert-manager"}, + wantArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=4"}, + wantChanged: true, + }, + { + name: "Trace log level sets --v=6", + logLevel: operatorv1.Trace, + existingArgs: []string{"--cluster-resource-namespace=cert-manager"}, + wantArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=6"}, + wantChanged: true, + }, + { + name: "TraceAll log level sets --v=8", + logLevel: operatorv1.TraceAll, + existingArgs: []string{"--cluster-resource-namespace=cert-manager"}, + wantArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=8"}, + wantChanged: true, + }, + { + name: "empty log level does not modify args", + logLevel: "", + existingArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=2"}, + wantArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=2"}, + wantChanged: false, + }, + { + name: "unknown log level does not modify args", + logLevel: "UnknownLevel", + existingArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=2"}, + wantArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=2"}, + wantChanged: false, + }, + { + name: "log level overrides existing --v arg", + logLevel: operatorv1.Debug, + existingArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=2"}, + wantArgs: []string{"--cluster-resource-namespace=cert-manager", "--v=4"}, + wantChanged: true, + }, + { + name: "log level merges with multiple existing args", + logLevel: operatorv1.Trace, + existingArgs: []string{"--leader-election-namespace=kube-system", "--v=2", "--cluster-resource-namespace=cert-manager"}, + wantArgs: []string{"--cluster-resource-namespace=cert-manager", "--leader-election-namespace=kube-system", "--v=6"}, + wantChanged: true, + }, + { + name: "log level works with empty existing args", + logLevel: operatorv1.Normal, + existingArgs: nil, + wantArgs: []string{"--v=2"}, + wantChanged: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deployment := newTestDeploymentWithArgs(tc.existingArgs) + operatorSpec := &operatorv1.OperatorSpec{ + LogLevel: tc.logLevel, + } + + err := withLogLevel(operatorSpec, deployment) + require.NoError(t, err) + require.Equal(t, tc.wantArgs, deployment.Spec.Template.Spec.Containers[0].Args) + }) + } +} diff --git a/pkg/controller/certmanager/deployment_overrides_test.go b/pkg/controller/certmanager/deployment_overrides_test.go index e51fc669f..e1254ab3f 100644 --- a/pkg/controller/certmanager/deployment_overrides_test.go +++ b/pkg/controller/certmanager/deployment_overrides_test.go @@ -4,12 +4,14 @@ import ( "strings" "testing" + operatorv1 "github.com/openshift/api/operator/v1" "github.com/openshift/library-go/pkg/operator/resource/resourceread" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" corelistersv1 "k8s.io/client-go/listers/core/v1" "k8s.io/client-go/tools/cache" @@ -226,6 +228,27 @@ func TestUnsupportedConfigOverrides(t *testing.T) { } } +func TestWithUnsupportedArgsOverrideHookInvalidJSON(t *testing.T) { + deployment := newTestDeploymentWithArgs([]string{"--v=2"}) + operatorSpec := &operatorv1.OperatorSpec{ + UnsupportedConfigOverrides: runtime.RawExtension{ + Raw: []byte(`{invalid-json`), + }, + } + + err := withUnsupportedArgsOverrideHook(operatorSpec, deployment) + require.Error(t, err, "expected error for invalid JSON in UnsupportedConfigOverrides") +} + +func TestWithUnsupportedArgsOverrideHookEmptyRaw(t *testing.T) { + deployment := newTestDeploymentWithArgs([]string{"--v=2"}) + operatorSpec := &operatorv1.OperatorSpec{} + + err := withUnsupportedArgsOverrideHook(operatorSpec, deployment) + require.NoError(t, err) + require.Equal(t, []string{"--v=2"}, deployment.Spec.Template.Spec.Containers[0].Args) +} + func TestParseEnvMap(t *testing.T) { tests := []struct { name string diff --git a/pkg/controller/certmanager/related_images_test.go b/pkg/controller/certmanager/related_images_test.go index 3727dbfc3..8e94b6502 100644 --- a/pkg/controller/certmanager/related_images_test.go +++ b/pkg/controller/certmanager/related_images_test.go @@ -1,44 +1,90 @@ package certmanager import ( - "os" "testing" ) func Test_certManagerImage(t *testing.T) { - type args struct { - defaultImage string - relatedImageEnvVar string - } tests := []struct { - name string - args args - want string + name string + envVar string + defaultImage string + envVarValue string + want string }{ { - name: "Use default image on empty RELATED_IMAGE_CERT_MANAGER_CONTROLLER variable", - args: args{ - defaultImage: testUpstreamCertManagerControllerImage, - relatedImageEnvVar: "", - }, - want: testUpstreamCertManagerControllerImage, + name: "controller: use default on empty env var", + envVar: "RELATED_IMAGE_CERT_MANAGER_CONTROLLER", + defaultImage: testUpstreamCertManagerControllerImage, + envVarValue: "", + want: testUpstreamCertManagerControllerImage, + }, + { + name: "controller: use override on non-empty env var", + envVar: "RELATED_IMAGE_CERT_MANAGER_CONTROLLER", + defaultImage: testUpstreamCertManagerControllerImage, + envVarValue: "registry.redhat.io/cert-manager/cert-manager-operator-1.5-rhel-8:latest", + want: "registry.redhat.io/cert-manager/cert-manager-operator-1.5-rhel-8:latest", + }, + { + name: "webhook: use default on empty env var", + envVar: "RELATED_IMAGE_CERT_MANAGER_WEBHOOK", + defaultImage: "quay.io/jetstack/cert-manager-webhook:latest", + envVarValue: "", + want: "quay.io/jetstack/cert-manager-webhook:latest", + }, + { + name: "webhook: use override on non-empty env var", + envVar: "RELATED_IMAGE_CERT_MANAGER_WEBHOOK", + defaultImage: "quay.io/jetstack/cert-manager-webhook:latest", + envVarValue: "registry.redhat.io/cert-manager/cert-manager-webhook-rhel-8:latest", + want: "registry.redhat.io/cert-manager/cert-manager-webhook-rhel-8:latest", + }, + { + name: "cainjector: use default on empty env var", + envVar: "RELATED_IMAGE_CERT_MANAGER_CA_INJECTOR", + defaultImage: "quay.io/jetstack/cert-manager-cainjector:latest", + envVarValue: "", + want: "quay.io/jetstack/cert-manager-cainjector:latest", }, { - name: "Use related image on non-empty RELATED_IMAGE_CERT_MANAGER_CONTROLLER variable", - args: args{ - defaultImage: testUpstreamCertManagerControllerImage, - relatedImageEnvVar: "registry.redhat.io/cert-manager/cert-manager-operator-1.5-rhel-8:latest", - }, - want: "registry.redhat.io/cert-manager/cert-manager-operator-1.5-rhel-8:latest", + name: "cainjector: use override on non-empty env var", + envVar: "RELATED_IMAGE_CERT_MANAGER_CA_INJECTOR", + defaultImage: "quay.io/jetstack/cert-manager-cainjector:latest", + envVarValue: "registry.redhat.io/cert-manager/cert-manager-cainjector-rhel-8:latest", + want: "registry.redhat.io/cert-manager/cert-manager-cainjector-rhel-8:latest", + }, + { + name: "acmesolver: use default on empty env var", + envVar: "RELATED_IMAGE_CERT_MANAGER_ACMESOLVER", + defaultImage: "quay.io/jetstack/cert-manager-acmesolver:latest", + envVarValue: "", + want: "quay.io/jetstack/cert-manager-acmesolver:latest", + }, + { + name: "acmesolver: use override on non-empty env var", + envVar: "RELATED_IMAGE_CERT_MANAGER_ACMESOLVER", + defaultImage: "quay.io/jetstack/cert-manager-acmesolver:latest", + envVarValue: "registry.redhat.io/cert-manager/cert-manager-acmesolver-rhel-8:latest", + want: "registry.redhat.io/cert-manager/cert-manager-acmesolver-rhel-8:latest", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - os.Setenv("RELATED_IMAGE_CERT_MANAGER_CONTROLLER", tt.args.relatedImageEnvVar) - if got := certManagerImage(tt.args.defaultImage); got != tt.want { + t.Setenv(tt.envVar, tt.envVarValue) + if got := certManagerImage(tt.defaultImage); got != tt.want { t.Errorf("certManagerImage() = %v, want %v", got, tt.want) } - os.Unsetenv("RELATED_IMAGE_CERT_MANAGER_CONTROLLER") }) } } + +func Test_certManagerImageUnknownImage(t *testing.T) { + t.Setenv("RELATED_IMAGE_CERT_MANAGER_CONTROLLER", "override:latest") + + defaultImage := "quay.io/some-other/image:v1.0" + got := certManagerImage(defaultImage) + if got != defaultImage { + t.Errorf("certManagerImage() = %v, want %v", got, defaultImage) + } +} diff --git a/pkg/controller/common/utils_test.go b/pkg/controller/common/utils_test.go index d4117d34d..0368921ab 100644 --- a/pkg/controller/common/utils_test.go +++ b/pkg/controller/common/utils_test.go @@ -8,9 +8,18 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" "sigs.k8s.io/controller-runtime/pkg/client" ) +func TestUpdateName(t *testing.T) { + cm := &corev1.ConfigMap{} + UpdateName(cm, "test-name") + assert.Equal(t, "test-name", cm.Name) +} + // TestUpdateNamespace provides table-driven tests for UpdateNamespace(obj, newNamespace). func TestUpdateNamespace(t *testing.T) { tests := []struct { @@ -292,3 +301,36 @@ func TestAddAnnotation(t *testing.T) { }) } } + +func TestDecodeObjBytes_ValidConfigMap(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + codecs := serializer.NewCodecFactory(scheme) + gv := schema.GroupVersion{Group: "", Version: "v1"} + + yamlBytes := []byte(`apiVersion: v1 +kind: ConfigMap +metadata: + name: decoded-cm + namespace: test-ns +data: + key: value +`) + + cm := DecodeObjBytes[*corev1.ConfigMap](codecs, gv, yamlBytes) + require.NotNil(t, cm) + assert.Equal(t, "decoded-cm", cm.Name) + assert.Equal(t, "test-ns", cm.Namespace) + assert.Equal(t, "value", cm.Data["key"]) +} + +func TestDecodeObjBytes_InvalidBytes(t *testing.T) { + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + codecs := serializer.NewCodecFactory(scheme) + gv := schema.GroupVersion{Group: "", Version: "v1"} + + require.Panics(t, func() { + DecodeObjBytes[*corev1.ConfigMap](codecs, gv, []byte("not valid yaml or json {{{")) + }) +} diff --git a/pkg/controller/istiocsr/utils_test.go b/pkg/controller/istiocsr/utils_test.go index d141e20bc..b8f9e88b8 100644 --- a/pkg/controller/istiocsr/utils_test.go +++ b/pkg/controller/istiocsr/utils_test.go @@ -13,6 +13,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + certmanagermetav1 "github.com/cert-manager/cert-manager/pkg/apis/meta/v1" + + "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" ) // baseDeployment returns a minimal deployment for spec comparison tests. @@ -529,3 +532,107 @@ func TestNetworkPolicySpecModified(t *testing.T) { }) } } + +func TestValidateIstioCSRConfig(t *testing.T) { + tests := []struct { + name string + istiocsr *v1alpha1.IstioCSR + wantErr bool + wantErrMsg string + }{ + { + name: "empty IstioCSRConfig", + istiocsr: &v1alpha1.IstioCSR{ + Spec: v1alpha1.IstioCSRSpec{}, + }, + wantErr: true, + wantErrMsg: "istioCSRConfig config cannot be empty", + }, + { + name: "empty IstiodTLSConfig", + istiocsr: &v1alpha1.IstioCSR{ + Spec: v1alpha1.IstioCSRSpec{ + IstioCSRConfig: v1alpha1.IstioCSRConfig{ + CertManager: v1alpha1.CertManagerConfig{ + IssuerRef: certmanagermetav1.ObjectReference{ + Name: "test", + Kind: "issuer", + }, + }, + Istio: v1alpha1.IstioConfig{ + Namespace: "istio-system", + Revisions: []string{"default"}, + }, + }, + }, + }, + wantErr: true, + wantErrMsg: "istiodTLSConfig config cannot be empty", + }, + { + name: "empty Istio config", + istiocsr: &v1alpha1.IstioCSR{ + Spec: v1alpha1.IstioCSRSpec{ + IstioCSRConfig: v1alpha1.IstioCSRConfig{ + IstiodTLSConfig: v1alpha1.IstiodTLSConfig{ + TrustDomain: "cluster.local", + PrivateKeySize: 2048, + CertificateDuration: &metav1.Duration{Duration: DefaultCertificateDuration}, + }, + CertManager: v1alpha1.CertManagerConfig{ + IssuerRef: certmanagermetav1.ObjectReference{ + Name: "test", + Kind: "issuer", + }, + }, + }, + }, + }, + wantErr: true, + wantErrMsg: "istio config cannot be empty", + }, + { + name: "empty CertManager config", + istiocsr: &v1alpha1.IstioCSR{ + Spec: v1alpha1.IstioCSRSpec{ + IstioCSRConfig: v1alpha1.IstioCSRConfig{ + IstiodTLSConfig: v1alpha1.IstiodTLSConfig{ + TrustDomain: "cluster.local", + PrivateKeySize: 2048, + CertificateDuration: &metav1.Duration{Duration: DefaultCertificateDuration}, + }, + Istio: v1alpha1.IstioConfig{ + Namespace: "istio-system", + Revisions: []string{"default"}, + }, + }, + }, + }, + wantErr: true, + wantErrMsg: "certManager config cannot be empty", + }, + { + name: "valid config passes", + istiocsr: testIstioCSR(), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateIstioCSRConfig(tt.istiocsr) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("validateIstioCSRConfig() err = %q, want substring %q", err.Error(), tt.wantErrMsg) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/pkg/controller/trustmanager/utils_test.go b/pkg/controller/trustmanager/utils_test.go index a113ace02..0ebb8765b 100644 --- a/pkg/controller/trustmanager/utils_test.go +++ b/pkg/controller/trustmanager/utils_test.go @@ -354,3 +354,64 @@ func TestDecodeServiceAccountObjBytes(t *testing.T) { }) } } + +func TestManagedAnnotationsModified(t *testing.T) { + tests := []struct { + name string + desiredAnnotations map[string]string + currentAnnotations map[string]string + wantModified bool + }{ + { + name: "identical annotations not modified", + desiredAnnotations: map[string]string{"key": "value"}, + currentAnnotations: map[string]string{"key": "value"}, + wantModified: false, + }, + { + name: "missing managed annotation is modified", + desiredAnnotations: map[string]string{"managed": "value"}, + currentAnnotations: map[string]string{"other": "value"}, + wantModified: true, + }, + { + name: "changed managed annotation is modified", + desiredAnnotations: map[string]string{"key": "desired"}, + currentAnnotations: map[string]string{"key": "tampered"}, + wantModified: true, + }, + { + name: "extra annotation on existing is allowed", + desiredAnnotations: map[string]string{"managed": "value"}, + currentAnnotations: map[string]string{"managed": "value", "extra": "ok"}, + wantModified: false, + }, + { + name: "nil desired annotations not modified", + desiredAnnotations: nil, + currentAnnotations: map[string]string{"any": "value"}, + wantModified: false, + }, + { + name: "nil existing annotations with desired is modified", + desiredAnnotations: map[string]string{"key": "value"}, + currentAnnotations: nil, + wantModified: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + desired := testTrustManager().Build() + desired.SetAnnotations(tt.desiredAnnotations) + + existing := testTrustManager().Build() + existing.SetAnnotations(tt.currentAnnotations) + + got := managedAnnotationsModified(desired, existing) + if got != tt.wantModified { + t.Errorf("expected modified=%v, got %v", tt.wantModified, got) + } + }) + } +} diff --git a/pkg/controller/trustmanager/webhooks_test.go b/pkg/controller/trustmanager/webhooks_test.go index 17841c685..c357bb550 100644 --- a/pkg/controller/trustmanager/webhooks_test.go +++ b/pkg/controller/trustmanager/webhooks_test.go @@ -197,6 +197,41 @@ func TestValidatingWebhookConfigReconciliation(t *testing.T) { wantExistsCount: 1, wantPatchCount: 1, }, + { + name: "apply when existing has rules drift", + preReq: func(r *Reconciler, m *fakes.FakeCtrlClient) { + m.ExistsCalls(func(ctx context.Context, key client.ObjectKey, obj client.Object) (bool, error) { + vwc := getValidatingWebhookConfigObject(testResourceLabels(), testResourceAnnotations()) + vwc.Webhooks[0].Rules = []admissionregistrationv1.RuleWithOperations{ + { + Operations: []admissionregistrationv1.OperationType{admissionregistrationv1.Create}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"tampered.io"}, + APIVersions: []string{"v1"}, + Resources: []string{"fakes"}, + }, + }, + } + vwc.DeepCopyInto(obj.(*admissionregistrationv1.ValidatingWebhookConfiguration)) + return true, nil + }) + }, + wantExistsCount: 1, + wantPatchCount: 1, + }, + { + name: "apply when existing has admission review versions drift", + preReq: func(r *Reconciler, m *fakes.FakeCtrlClient) { + m.ExistsCalls(func(ctx context.Context, key client.ObjectKey, obj client.Object) (bool, error) { + vwc := getValidatingWebhookConfigObject(testResourceLabels(), testResourceAnnotations()) + vwc.Webhooks[0].AdmissionReviewVersions = []string{"v1beta1"} + vwc.DeepCopyInto(obj.(*admissionregistrationv1.ValidatingWebhookConfiguration)) + return true, nil + }) + }, + wantExistsCount: 1, + wantPatchCount: 1, + }, { name: "exists error propagates", preReq: func(r *Reconciler, m *fakes.FakeCtrlClient) { diff --git a/pkg/features/features_test.go b/pkg/features/features_test.go index b064b67d0..0011e3321 100644 --- a/pkg/features/features_test.go +++ b/pkg/features/features_test.go @@ -293,3 +293,54 @@ func TestIsTrustManagerFeatureGateEnabled(t *testing.T) { }) } } + +// TestIsIstioCSRFeatureGateEnabled covers the IstioCSR operator featuregate +// (--unsupported-addon-features). IstioCSR is GA and enabled by default, +// so it does not depend on cluster FeatureSet (unlike TrustManager). +func TestIsIstioCSRFeatureGateEnabled(t *testing.T) { + defer func() { + // IstioCSR defaults to true; restore after test + _ = SetupWithFlagValue("IstioCSR=true") + }() + + tests := []struct { + name string + prep func(t *testing.T) + assert func(t *testing.T) + }{ + { + name: "returns true when operator featuregate is on", + prep: func(t *testing.T) { + t.Helper() + require.NoError(t, SetupWithFlagValue("IstioCSR=true")) + }, + assert: func(t *testing.T) { + t.Helper() + assert.True(t, IsIstioCSRFeatureGateEnabled()) + }, + }, + { + name: "returns false when operator featuregate is off", + prep: func(t *testing.T) { + t.Helper() + require.NoError(t, SetupWithFlagValue("IstioCSR=false")) + }, + assert: func(t *testing.T) { + t.Helper() + assert.False(t, IsIstioCSRFeatureGateEnabled()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.prep(t) + tt.assert(t) + }) + } +} + +func TestSetupWithFlagValue_invalidFlag(t *testing.T) { + err := SetupWithFlagValue("InvalidFeature=true") + require.Error(t, err) +} diff --git a/pkg/operator/operatorclient/operatorclient_test.go b/pkg/operator/operatorclient/operatorclient_test.go new file mode 100644 index 000000000..d0412def3 --- /dev/null +++ b/pkg/operator/operatorclient/operatorclient_test.go @@ -0,0 +1,369 @@ +package operatorclient + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + operatorv1 "github.com/openshift/api/operator/v1" + applyoperatorv1 "github.com/openshift/client-go/operator/applyconfigurations/operator/v1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ktesting "k8s.io/client-go/testing" + "k8s.io/utils/clock" + + "github.com/openshift/cert-manager-operator/api/operator/v1alpha1" + fakeclientset "github.com/openshift/cert-manager-operator/pkg/operator/clientset/versioned/fake" + informers "github.com/openshift/cert-manager-operator/pkg/operator/informers/externalversions" +) + +func newTestOperatorClient(t *testing.T, objects ...runtime.Object) (OperatorClient, *fakeclientset.Clientset) { + t.Helper() + + fakeClient := fakeclientset.NewClientset(objects...) + factory := informers.NewSharedInformerFactory(fakeClient, 0) + + informer := factory.Operator().V1alpha1().CertManagers().Informer() + for _, obj := range objects { + if err := informer.GetIndexer().Add(obj); err != nil { + t.Fatalf("failed to add object to indexer: %v", err) + } + } + + oc := OperatorClient{ + Informers: factory, + Client: fakeClient.OperatorV1alpha1(), + Clock: clock.RealClock{}, + } + return oc, fakeClient +} + +func newCertManager(opts ...func(*v1alpha1.CertManager)) *v1alpha1.CertManager { + cm := &v1alpha1.CertManager{ + TypeMeta: metav1.TypeMeta{ + Kind: "CertManager", + APIVersion: "operator.openshift.io/v1alpha1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster", + ResourceVersion: "1", + }, + Spec: v1alpha1.CertManagerSpec{ + OperatorSpec: operatorv1.OperatorSpec{ + ManagementState: operatorv1.Managed, + }, + }, + } + for _, fn := range opts { + fn(cm) + } + return cm +} + +func TestGetUnsupportedConfigOverrides(t *testing.T) { + tests := []struct { + name string + rawBytes []byte + expectNil bool + expectErr bool + expectValue *v1alpha1.UnsupportedConfigOverrides + }{ + { + name: "empty raw bytes returns nil config", + rawBytes: nil, + expectNil: true, + }, + { + name: "valid JSON returns parsed UnsupportedConfigOverrides", + rawBytes: []byte(`{"controller":{"args":["--foo","--bar"]}}`), + expectValue: &v1alpha1.UnsupportedConfigOverrides{ + Controller: v1alpha1.UnsupportedConfigOverridesForCertManagerController{ + Args: []string{"--foo", "--bar"}, + }, + }, + }, + { + name: "invalid JSON returns unmarshal error", + rawBytes: []byte(`{not-valid-json`), + expectErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + spec := &operatorv1.OperatorSpec{ + UnsupportedConfigOverrides: runtime.RawExtension{ + Raw: tc.rawBytes, + }, + } + result, err := GetUnsupportedConfigOverrides(spec) + + if tc.expectErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tc.expectNil { + if result != nil { + t.Fatalf("expected nil result, got %+v", result) + } + return + } + + got, _ := json.Marshal(result) + want, _ := json.Marshal(tc.expectValue) + if string(got) != string(want) { + t.Errorf("expected %s, got %s", want, got) + } + }) + } +} + +func TestGetOperatorState(t *testing.T) { + t.Run("success returns spec, status, and resource version", func(t *testing.T) { + cm := newCertManager(func(cm *v1alpha1.CertManager) { + cm.ResourceVersion = "42" + cm.Spec.ManagementState = operatorv1.Unmanaged + }) + oc, _ := newTestOperatorClient(t, cm) + + spec, status, rv, err := oc.GetOperatorState() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rv != "42" { + t.Errorf("expected resource version 42, got %s", rv) + } + if spec.ManagementState != operatorv1.Unmanaged { + t.Errorf("expected ManagementState Unmanaged, got %v", spec.ManagementState) + } + if status == nil { + t.Fatal("expected non-nil status") + } + }) + + t.Run("get error propagates when resource not in lister", func(t *testing.T) { + oc, _ := newTestOperatorClient(t) + + _, _, _, err := oc.GetOperatorState() + if err == nil { + t.Fatal("expected error when no resource exists, got nil") + } + }) +} + +func TestEnsureFinalizer(t *testing.T) { + ctx := context.Background() + + t.Run("adds finalizer when not present", func(t *testing.T) { + cm := newCertManager() + oc, fakeClient := newTestOperatorClient(t, cm) + + if err := oc.EnsureFinalizer(ctx, "test-finalizer"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + updated, err := fakeClient.OperatorV1alpha1().CertManagers().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get updated resource: %v", err) + } + found := false + for _, f := range updated.GetFinalizers() { + if f == "test-finalizer" { + found = true + break + } + } + if !found { + t.Error("expected finalizer 'test-finalizer' to be present on updated resource") + } + }) + + t.Run("no-op when finalizer already present", func(t *testing.T) { + cm := newCertManager(func(cm *v1alpha1.CertManager) { + cm.SetFinalizers([]string{"test-finalizer"}) + }) + oc, fakeClient := newTestOperatorClient(t, cm) + + if err := oc.EnsureFinalizer(ctx, "test-finalizer"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + updated, err := fakeClient.OperatorV1alpha1().CertManagers().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get resource: %v", err) + } + if updated.ResourceVersion != "1" { + t.Errorf("expected no update (resource version 1), got %s", updated.ResourceVersion) + } + }) + + t.Run("save error propagates", func(t *testing.T) { + cm := newCertManager() + oc, fakeClient := newTestOperatorClient(t, cm) + + fakeClient.PrependReactor("update", "certmanagers", + func(action ktesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("simulated update error") + }) + + err := oc.EnsureFinalizer(ctx, "new-finalizer") + if err == nil { + t.Fatal("expected error from save, got nil") + } + if err.Error() != "simulated update error" { + t.Errorf("unexpected error message: %v", err) + } + }) +} + +func TestRemoveFinalizer(t *testing.T) { + ctx := context.Background() + + t.Run("removes finalizer when present", func(t *testing.T) { + cm := newCertManager(func(cm *v1alpha1.CertManager) { + cm.SetFinalizers([]string{"keep-me", "remove-me", "also-keep"}) + }) + oc, fakeClient := newTestOperatorClient(t, cm) + + if err := oc.RemoveFinalizer(ctx, "remove-me"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + updated, err := fakeClient.OperatorV1alpha1().CertManagers().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get updated resource: %v", err) + } + for _, f := range updated.GetFinalizers() { + if f == "remove-me" { + t.Error("finalizer 'remove-me' should have been removed") + } + } + if len(updated.GetFinalizers()) != 2 { + t.Errorf("expected 2 remaining finalizers, got %d", len(updated.GetFinalizers())) + } + }) + + t.Run("no-op when finalizer not present", func(t *testing.T) { + cm := newCertManager(func(cm *v1alpha1.CertManager) { + cm.SetFinalizers([]string{"other-finalizer"}) + }) + oc, fakeClient := newTestOperatorClient(t, cm) + + if err := oc.RemoveFinalizer(ctx, "not-present"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + updated, err := fakeClient.OperatorV1alpha1().CertManagers().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get resource: %v", err) + } + if updated.ResourceVersion != "1" { + t.Errorf("expected no update (resource version 1), got %s", updated.ResourceVersion) + } + }) + + t.Run("save error propagates", func(t *testing.T) { + cm := newCertManager(func(cm *v1alpha1.CertManager) { + cm.SetFinalizers([]string{"test-finalizer"}) + }) + oc, fakeClient := newTestOperatorClient(t, cm) + + fakeClient.PrependReactor("update", "certmanagers", + func(action ktesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("simulated update error") + }) + + err := oc.RemoveFinalizer(ctx, "test-finalizer") + if err == nil { + t.Fatal("expected error from save, got nil") + } + if err.Error() != "simulated update error" { + t.Errorf("unexpected error message: %v", err) + } + }) +} + +func TestApplyOperatorStatus(t *testing.T) { + ctx := context.Background() + + t.Run("nil desiredConfiguration returns error", func(t *testing.T) { + cm := newCertManager() + oc, _ := newTestOperatorClient(t, cm) + + err := oc.ApplyOperatorStatus(ctx, "test-manager", nil) + if err == nil { + t.Fatal("expected error for nil desiredConfiguration, got nil") + } + expected := "applyConfiguration must have a value" + if err.Error() != expected { + t.Errorf("expected error %q, got %q", expected, err.Error()) + } + }) + + t.Run("not-found error creates new status", func(t *testing.T) { + // No CertManager resource exists in the fake client. + oc, _ := newTestOperatorClient(t) + + desired := applyoperatorv1.OperatorStatus() + err := oc.ApplyOperatorStatus(ctx, "test-manager", desired) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("get error (non-NotFound) propagates", func(t *testing.T) { + cm := newCertManager() + oc, fakeClient := newTestOperatorClient(t, cm) + + fakeClient.PrependReactor("get", "certmanagers", + func(action ktesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("simulated server error") + }) + + desired := applyoperatorv1.OperatorStatus() + err := oc.ApplyOperatorStatus(ctx, "test-manager", desired) + if err == nil { + t.Fatal("expected error, got nil") + } + if expected := "unable to get operator configuration: simulated server error"; err.Error() != expected { + t.Errorf("expected error %q, got %q", expected, err.Error()) + } + }) + + t.Run("deep-equal status skips update", func(t *testing.T) { + cm := newCertManager() + oc, fakeClient := newTestOperatorClient(t, cm) + + // Apply once to establish the baseline. + desired := applyoperatorv1.OperatorStatus() + if err := oc.ApplyOperatorStatus(ctx, "test-manager", desired); err != nil { + t.Fatalf("first apply failed: %v", err) + } + + // Track whether a second apply-status call is made. + applyStatusCalled := false + fakeClient.PrependReactor("patch", "certmanagers", + func(action ktesting.Action) (bool, runtime.Object, error) { + applyStatusCalled = true + return false, nil, nil + }) + + // Apply the same status again -- should be a no-op. + desired2 := applyoperatorv1.OperatorStatus() + if err := oc.ApplyOperatorStatus(ctx, "test-manager", desired2); err != nil { + t.Fatalf("second apply failed: %v", err) + } + + if applyStatusCalled { + t.Error("expected no apply-status call when status is unchanged") + } + }) +} diff --git a/pkg/operator/setup_manager_test.go b/pkg/operator/setup_manager_test.go new file mode 100644 index 000000000..2c9bfc974 --- /dev/null +++ b/pkg/operator/setup_manager_test.go @@ -0,0 +1,216 @@ +package operator + +import ( + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openshift/cert-manager-operator/pkg/controller/common" + "github.com/openshift/cert-manager-operator/pkg/controller/istiocsr" + "github.com/openshift/cert-manager-operator/pkg/controller/trustmanager" +) + +func TestBuildCacheObjectList(t *testing.T) { + t.Run("single controller enabled (IstioCSR)", func(t *testing.T) { + config := ControllerConfig{ + EnableIstioCSR: true, + EnableTrustManager: false, + } + + objectList, err := buildCacheObjectList(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // IstioCSR manages 9 resource types plus 1 CR type. + // Verify at least the Deployment entry exists with the correct label selector. + found := false + for key, byObj := range objectList { + if _, ok := key.(*appsv1.Deployment); ok { + found = true + sel := byObj.Label + if sel == nil { + t.Fatal("expected label selector for Deployment, got nil") + } + selectorStr := sel.String() + if selectorStr == "" { + t.Fatal("expected non-empty label selector for Deployment") + } + // Should match only the IstioCSR label value. + testLabels := map[string]string{common.ManagedResourceLabelKey: istiocsr.RequestEnqueueLabelValue} + if !sel.Matches(labels.Set(testLabels)) { + t.Errorf("expected selector to match IstioCSR label value %q, selector: %s", + istiocsr.RequestEnqueueLabelValue, selectorStr) + } + break + } + } + if !found { + t.Error("expected Deployment entry in cache object list") + } + }) + + t.Run("multiple controllers enabled (labels merge)", func(t *testing.T) { + config := ControllerConfig{ + EnableIstioCSR: true, + EnableTrustManager: true, + } + + objectList, err := buildCacheObjectList(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Deployment is shared between IstioCSR and TrustManager. + // The label selector should match both values via the In operator. + for key, byObj := range objectList { + if _, ok := key.(*appsv1.Deployment); ok { + sel := byObj.Label + if sel == nil { + t.Fatal("expected label selector for Deployment, got nil") + } + + istioLabels := map[string]string{common.ManagedResourceLabelKey: istiocsr.RequestEnqueueLabelValue} + trustLabels := map[string]string{common.ManagedResourceLabelKey: trustmanager.RequestEnqueueLabelValue} + + if !sel.Matches(labels.Set(istioLabels)) { + t.Errorf("expected merged selector to match IstioCSR label, selector: %s", sel.String()) + } + if !sel.Matches(labels.Set(trustLabels)) { + t.Errorf("expected merged selector to match TrustManager label, selector: %s", sel.String()) + } + break + } + } + }) + + t.Run("no controllers enabled", func(t *testing.T) { + config := ControllerConfig{ + EnableIstioCSR: false, + EnableTrustManager: false, + } + + objectList, err := buildCacheObjectList(config) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(objectList) != 0 { + t.Errorf("expected empty object list when no controllers enabled, got %d entries", len(objectList)) + } + }) +} + +func TestAddControllerCacheConfig(t *testing.T) { + t.Run("adds new resource type", func(t *testing.T) { + objectList := make(map[client.Object]cache.ByObject) + resources := []client.Object{&appsv1.Deployment{}, &corev1.Service{}} + + err := addControllerCacheConfig(objectList, "test-controller", resources) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(objectList) != 2 { + t.Errorf("expected 2 entries, got %d", len(objectList)) + } + + // Verify the Deployment entry has the correct label selector. + _, byObj, found := findExistingCacheEntry(objectList, &appsv1.Deployment{}) + if !found { + t.Fatal("expected Deployment entry in object list") + } + testLabels := map[string]string{common.ManagedResourceLabelKey: "test-controller"} + if !byObj.Label.Matches(labels.Set(testLabels)) { + t.Errorf("expected selector to match label value %q, selector: %s", "test-controller", byObj.Label.String()) + } + }) + + t.Run("merges existing resource type with In operator", func(t *testing.T) { + objectList := make(map[client.Object]cache.ByObject) + resources1 := []client.Object{&appsv1.Deployment{}} + resources2 := []client.Object{&appsv1.Deployment{}} + + err := addControllerCacheConfig(objectList, "controller-a", resources1) + if err != nil { + t.Fatalf("first addControllerCacheConfig failed: %v", err) + } + + err = addControllerCacheConfig(objectList, "controller-b", resources2) + if err != nil { + t.Fatalf("second addControllerCacheConfig failed: %v", err) + } + + // Should still be 1 entry (merged), not 2. + if len(objectList) != 1 { + t.Errorf("expected 1 merged entry, got %d", len(objectList)) + } + + _, byObj, found := findExistingCacheEntry(objectList, &appsv1.Deployment{}) + if !found { + t.Fatal("expected Deployment entry in object list") + } + + // Selector should match both label values. + labelsA := map[string]string{common.ManagedResourceLabelKey: "controller-a"} + labelsB := map[string]string{common.ManagedResourceLabelKey: "controller-b"} + if !byObj.Label.Matches(labels.Set(labelsA)) { + t.Errorf("expected merged selector to match controller-a, selector: %s", byObj.Label.String()) + } + if !byObj.Label.Matches(labels.Set(labelsB)) { + t.Errorf("expected merged selector to match controller-b, selector: %s", byObj.Label.String()) + } + + // Should NOT match a label value that was never added. + labelsC := map[string]string{common.ManagedResourceLabelKey: "controller-c"} + if byObj.Label.Matches(labels.Set(labelsC)) { + t.Errorf("expected merged selector NOT to match controller-c, selector: %s", byObj.Label.String()) + } + }) +} + +func TestFindExistingCacheEntry(t *testing.T) { + t.Run("returns entry when type found", func(t *testing.T) { + objectList := map[client.Object]cache.ByObject{ + &appsv1.Deployment{}: {}, + &corev1.Service{}: {}, + &rbacv1.ClusterRole{}: {}, + } + + key, _, found := findExistingCacheEntry(objectList, &appsv1.Deployment{}) + if !found { + t.Fatal("expected to find Deployment entry") + } + if _, ok := key.(*appsv1.Deployment); !ok { + t.Errorf("expected key to be *appsv1.Deployment, got %T", key) + } + }) + + t.Run("returns not-found when type absent", func(t *testing.T) { + objectList := map[client.Object]cache.ByObject{ + &appsv1.Deployment{}: {}, + &corev1.Service{}: {}, + } + + _, _, found := findExistingCacheEntry(objectList, &rbacv1.ClusterRole{}) + if found { + t.Error("expected not-found for ClusterRole, but it was found") + } + }) + + t.Run("distinguishes different pointer types of same kind", func(t *testing.T) { + objectList := map[client.Object]cache.ByObject{ + &rbacv1.ClusterRole{}: {}, + } + + _, _, found := findExistingCacheEntry(objectList, &rbacv1.ClusterRoleBinding{}) + if found { + t.Error("expected not-found for ClusterRoleBinding when only ClusterRole exists") + } + }) +}