diff --git a/controllers/configuration/ca_bundle_watcher.go b/controllers/configuration/ca_bundle_watcher.go new file mode 100644 index 000000000..63cac439b --- /dev/null +++ b/controllers/configuration/ca_bundle_watcher.go @@ -0,0 +1,162 @@ +/* +Copyright 2026 Red Hat, Inc. + +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 configuration + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/tools/record" + "k8s.io/utils/ptr" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + CABundleConfigMapName = "threescale-ca-bundle" + CABundleConfigMapKey = "ca-bundle.crt" +) + +// CAValidationError is returned when the CA bundle ConfigMap exists but its +// contents cannot be used to build a valid certificate pool. +type CAValidationError struct { + Reason string + Message string + Err error +} + +func (e *CAValidationError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %s: %v", e.Reason, e.Message, e.Err) + } + return fmt.Sprintf("%s: %s", e.Reason, e.Message) +} + +func (e *CAValidationError) Unwrap() error { + return e.Err +} + +const ( + CAValidationReasonMissingSecret = "MissingCASecret" + CAValidationReasonMissingKey = "MissingCAKey" + CAValidationReasonInvalidFormat = "InvalidCAFormat" +) + +// CABundleWatcher watches the threescale-ca-bundle ConfigMap and writes the +// parsed *tls.Config to the package-level variable via SetTLSConfig on every +// successful reconcile. +type CABundleWatcher struct { + client.Client + Recorder record.EventRecorder + Namespace string +} + +// SetupWithManager registers the controller with the manager. +func (r *CABundleWatcher) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + Named("cabundlewatcher"). + WithOptions(controller.Options{NeedLeaderElection: ptr.To(false)}). + For(&corev1.ConfigMap{}, builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + return e.Object.GetName() == CABundleConfigMapName && + e.Object.GetNamespace() == r.Namespace + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return e.ObjectNew.GetName() == CABundleConfigMapName && + e.ObjectNew.GetNamespace() == r.Namespace + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return e.Object.GetName() == CABundleConfigMapName && + e.Object.GetNamespace() == r.Namespace + }, + GenericFunc: func(e event.GenericEvent) bool { + return e.Object.GetName() == CABundleConfigMapName && + e.Object.GetNamespace() == r.Namespace + }, + })). + WithLogConstructor(func(_ *reconcile.Request) logr.Logger { + return mgr.GetLogger().WithValues("controller", "cabundlewatcher") + }). + Complete(r) +} + +// Reconcile fetches the CA bundle ConfigMap and updates the package-level +// TLS config. On an invalid bundle, the error is logged and recorded as a Warning event on +// the ConfigMap; the existing TLS config is left unchanged so capability +// controllers continue to operate with the last known good CA. +func (r *CABundleWatcher) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := ctrl.LoggerFrom(ctx) + + cm := &corev1.ConfigMap{} + err := r.Get(ctx, client.ObjectKey{Namespace: r.Namespace, Name: CABundleConfigMapName}, cm) + if err != nil { + if apierrors.IsNotFound(err) { + SetTLSConfig(nil) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + tlsConfig, parseErr := parseBundleFromConfigMap(cm) + if parseErr != nil { + logger.Error(parseErr, "CA bundle ConfigMap contains an invalid certificate bundle; keeping previous TLS config") + if r.Recorder != nil { + r.Recorder.Eventf(cm, corev1.EventTypeWarning, "InvalidCABundle", "%v", parseErr) + } + return ctrl.Result{}, nil + } + + SetTLSConfig(tlsConfig) + return ctrl.Result{}, nil +} + +// parseBundleFromConfigMap parses the CA bundle from a ConfigMap and returns a +// *tls.Config with the custom RootCAs pool set. Returns nil and an error if +// the ConfigMap key is absent or the PEM data is invalid. +func parseBundleFromConfigMap(cm *corev1.ConfigMap) (*tls.Config, error) { + val, exists := cm.Data[CABundleConfigMapKey] + if !exists { + return nil, nil // no bundle configured — use system roots + } + + if len(val) == 0 { + return nil, &CAValidationError{ + Reason: CAValidationReasonInvalidFormat, + Message: fmt.Sprintf("Key %q in ConfigMap %s/%s is empty", CABundleConfigMapKey, cm.Namespace, cm.Name), + } + } + + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM([]byte(val)) { + return nil, &CAValidationError{ + Reason: CAValidationReasonInvalidFormat, + Message: "No valid PEM-encoded certificates found in CA bundle", + } + } + + return &tls.Config{RootCAs: certPool}, nil +} diff --git a/controllers/configuration/tls_config.go b/controllers/configuration/tls_config.go new file mode 100644 index 000000000..d48fa6805 --- /dev/null +++ b/controllers/configuration/tls_config.go @@ -0,0 +1,47 @@ +/* +Copyright 2026 Red Hat, Inc. + +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 configuration + +import ( + "crypto/tls" + "sync" +) + +// currentTLSConfig is the package-level TLS configuration derived from the +// most recently reconciled threescale-ca-bundle ConfigMap. +// nil means "no custom CA bundle" — callers should use system roots. +var currentTLSConfig *tls.Config + +var tlsConfigMu sync.RWMutex + +// SetTLSConfig replaces the current package-level TLS config. +// Pass nil to revert to system roots (e.g. when the ConfigMap is deleted). +func SetTLSConfig(cfg *tls.Config) { + tlsConfigMu.Lock() + currentTLSConfig = cfg + tlsConfigMu.Unlock() +} + +// GetTLSConfig returns the current package-level TLS config. +// Returns nil if no bundle has been reconciled yet, or after the ConfigMap +// has been deleted. Callers must not mutate the returned *tls.Config. +func GetTLSConfig() *tls.Config { + tlsConfigMu.RLock() + cfg := currentTLSConfig + tlsConfigMu.RUnlock() + return cfg +} diff --git a/main.go b/main.go index 75559a402..f433db2e2 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,7 @@ import ( capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" appscontroller "github.com/3scale/3scale-operator/controllers/apps" capabilitiescontroller "github.com/3scale/3scale-operator/controllers/capabilities" + configurationcontroller "github.com/3scale/3scale-operator/controllers/configuration" "github.com/3scale/3scale-operator/pkg/reconcilers" "github.com/3scale/3scale-operator/version" "github.com/getkin/kin-openapi/openapi3" @@ -161,6 +162,16 @@ func main() { os.Exit(1) } + watcher := &configurationcontroller.CABundleWatcher{ + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorderFor("CABundleWatcher"), + Namespace: operatorInstallationNamespace, + } + if err = watcher.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "CABundleWatcher") + os.Exit(1) + } + secretLabelSelector, err := apimachinerymetav1.ParseToLabelSelector("apimanager.apps.3scale.net/watched-by=apimanager") if err != nil { setupLog.Error(err, "unable parse apimanager secrets label") diff --git a/pkg/controller/helper/threescale_api.go b/pkg/controller/helper/threescale_api.go index 515aeeb01..31f0c5955 100644 --- a/pkg/controller/helper/threescale_api.go +++ b/pkg/controller/helper/threescale_api.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" + configuration "github.com/3scale/3scale-operator/controllers/configuration" "github.com/3scale/3scale-operator/pkg/helper" threescaleapi "github.com/3scale/3scale-porta-go-client/client" @@ -19,12 +20,15 @@ type ProviderAccount struct { Token string } -// PortaClient instantiates porta_client.ThreeScaleClient from ProviderAccount object +// PortaClient instantiates a ThreeScaleClient from a ProviderAccount. +// When insecureSkipVerify is true, the CA bundle is ignored and an insecure +// client is used instead. func PortaClient(providerAccount *ProviderAccount, insecureSkipVerify bool) (*threescaleapi.ThreeScaleClient, error) { return PortaClientFromURLString(providerAccount.AdminURLStr, providerAccount.Token, insecureSkipVerify) } -// PortaClientFromURLString instantiates porta_client.ThreeScaleClient from url string +// PortaClientFromURLString instantiates a ThreeScaleClient from an admin URL string +// and access token. When insecureSkipVerify is true, the CA bundle is ignored. func PortaClientFromURLString(adminURLStr, token string, insecureSkipVerify bool) (*threescaleapi.ThreeScaleClient, error) { adminURL, err := url.Parse(adminURLStr) if err != nil { @@ -33,24 +37,36 @@ func PortaClientFromURLString(adminURLStr, token string, insecureSkipVerify bool return PortaClientFromURL(adminURL, token, insecureSkipVerify) } -// PortaClientFromURL instantiates porta_client.ThreeScaleClient from admin url object +// PortaClientFromURL instantiates a ThreeScaleClient from an admin URL +// and access token. When insecureSkipVerify is true, the CA bundle is ignored. func PortaClientFromURL(url *url.URL, token string, insecureSkipVerify bool) (*threescaleapi.ThreeScaleClient, error) { + var httpClient *http.Client + if insecureSkipVerify { + httpClient = buildHTTPClient(&tls.Config{InsecureSkipVerify: true}) //nolint:gosec + } else { + httpClient = buildHTTPClient(configuration.GetTLSConfig()) + } adminPortal, err := threescaleapi.NewAdminPortal(url.Scheme, url.Hostname(), helper.PortFromURL(url)) if err != nil { return nil, err } + return threescaleapi.NewThreeScale(adminPortal, token, httpClient), nil +} - // Activated by some env var or Spec param +// buildHTTPClient constructs a fresh *http.Client with the supplied TLS +// configuration. If THREESCALE_DEBUG=1 the verbose transport wrapper is applied. +func buildHTTPClient(tlsConfig *tls.Config) *http.Client { + if tlsConfig == nil { + tlsConfig = &tls.Config{} + } var transport http.RoundTripper = &http.Transport{ Proxy: http.ProxyFromEnvironment, - TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}, + TLSClientConfig: tlsConfig, } - if helper.GetEnvVar(HTTP_VERBOSE_ENVVAR, "0") == "1" { transport = &helper.Transport{Transport: transport} } - - return threescaleapi.NewThreeScale(adminPortal, token, &http.Client{Transport: transport}), nil + return &http.Client{Transport: transport} } // GetInsecureSkipVerifyAnnotation extracts the insecure_skip_verify annotation from an object diff --git a/pkg/controller/helper/threescale_api_test.go b/pkg/controller/helper/threescale_api_test.go index bad6ded7a..1ae568a4a 100644 --- a/pkg/controller/helper/threescale_api_test.go +++ b/pkg/controller/helper/threescale_api_test.go @@ -1,34 +1,123 @@ package helper import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "net/http" + "net/http/httptest" "net/url" + "strings" "testing" + + configuration "github.com/3scale/3scale-operator/controllers/configuration" + threescaleapi "github.com/3scale/3scale-porta-go-client/client" ) -func TestPortaClientInvalidURL(t *testing.T) { +// backendListHandler serves a minimal valid /admin/api/backend_apis.json response. +func backendListHandler(t *testing.T) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(threescaleapi.BackendApiList{}); err != nil { + t.Errorf("failed to encode response: %v", err) + } + }) +} + +// setTLSConfigForTest sets the package-level TLS config and restores the +// previous value when the test completes. Must NOT be called from parallel +// tests — t.Setenv is used as a guard. +func setTLSConfigForTest(t *testing.T, cfg *tls.Config) { + t.Helper() + t.Setenv("_TEST_TLS_GUARD", "1") + prev := configuration.GetTLSConfig() + configuration.SetTLSConfig(cfg) + t.Cleanup(func() { configuration.SetTLSConfig(prev) }) +} + +// TestPortaClientFromAccount_InvalidURL verifies that an unparseable admin URL +// is rejected before any network I/O. +func TestPortaClientFromAccount_InvalidURL(t *testing.T) { providerAccount := &ProviderAccount{AdminURLStr: ":foo", Token: "some token"} _, err := PortaClient(providerAccount, false) assert(t, err != nil, "error should not be nil") } -func TestPortaClient(t *testing.T) { +// TestPortaClientFromAccount_Valid verifies that a valid admin URL produces a +// usable client. +func TestPortaClientFromAccount_Valid(t *testing.T) { providerAccount := &ProviderAccount{AdminURLStr: "http://somedomain.example.com", Token: "some token"} _, err := PortaClient(providerAccount, false) ok(t, err) } -func TestPortaClientFromURLStringInvalidURL(t *testing.T) { - _, err := PortaClientFromURLString(":foo", "some token", false) +// TestPortaClientFromURL_InvalidURL verifies that an empty URL (no +// scheme, no host) is rejected. +func TestPortaClientFromURL_InvalidURL(t *testing.T) { + _, err := PortaClientFromURL(&url.URL{}, "some token", false) assert(t, err != nil, "error should not be nil") } -func TestPortaClientFromURLString(t *testing.T) { - _, err := PortaClientFromURLString("http://somedomain.example.com", "some token", false) +// TestPortaClientFromURL_TLSRejectUntrusted: with no TLS config set, +// a self-signed httptest TLS server must be rejected. +func TestPortaClientFromURL_TLSRejectUntrusted(t *testing.T) { + srv := httptest.NewTLSServer(backendListHandler(t)) + defer srv.Close() + + srvURL, err := url.Parse(srv.URL) ok(t, err) + + setTLSConfigForTest(t, nil) + + c, err := PortaClientFromURL(srvURL, "token", false) + ok(t, err) + + _, reqErr := c.ListBackendApis() + assert(t, reqErr != nil, "expected TLS certificate error, got nil") + assert(t, strings.Contains(reqErr.Error(), "certificate"), "expected certificate error, got: %v", reqErr) } -func TestPortaClientFromURL(t *testing.T) { - url := &url.URL{} - _, err := PortaClientFromURL(url, "some token", false) - assert(t, err != nil, "error should not be nil") +// TestPortaClientFromURL_TLSMatchingCA: setting the package-level TLS +// config to trust the server's own CA must allow a successful request. +func TestPortaClientFromURL_TLSMatchingCA(t *testing.T) { + srv := httptest.NewTLSServer(backendListHandler(t)) + defer srv.Close() + + certPool := x509.NewCertPool() + for _, cert := range srv.TLS.Certificates { + for _, c := range cert.Certificate { + parsedCert, err := x509.ParseCertificate(c) + ok(t, err) + certPool.AddCert(parsedCert) + } + } + setTLSConfigForTest(t, &tls.Config{RootCAs: certPool}) + + srvURL, err := url.Parse(srv.URL) + ok(t, err) + + c, err := PortaClientFromURL(srvURL, "token", false) + ok(t, err) + + _, reqErr := c.ListBackendApis() + ok(t, reqErr) +} + +// TestPortaClientFromURL_InsecureSkipVerify: insecureSkipVerify=true +// must accept an untrusted server certificate regardless of the TLS config. +func TestPortaClientFromURL_InsecureSkipVerify(t *testing.T) { + srv := httptest.NewTLSServer(backendListHandler(t)) + defer srv.Close() + + srvURL, err := url.Parse(srv.URL) + ok(t, err) + + setTLSConfigForTest(t, nil) + + c, err := PortaClientFromURL(srvURL, "token", true) + ok(t, err) + + _, reqErr := c.ListBackendApis() + ok(t, reqErr) } diff --git a/test/unitcontrollers/activedoc_controller_test.go b/test/unitcontrollers/activedoc_controller_test.go new file mode 100644 index 000000000..fbcbfa7a9 --- /dev/null +++ b/test/unitcontrollers/activedoc_controller_test.go @@ -0,0 +1,72 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + 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" +) + +// TestActiveDocReconciler_Reconcile is a table-driven test suite that verifies +// ActiveDocReconciler behaviour under an invalid CA bundle. +func TestActiveDocReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // ActiveDocReconciler.reconcileSpec() calls LookupProviderAccount and then PortaClientFromAccount after + // checkExternalRefs. SystemName must be pre-set so that SetDefaults() + // returns false and no early requeue occurs. + name: "InvalidCABundle", + objects: []runtime.Object{ + func() *capabilitiesv1beta1.ActiveDoc { + systemName := "test" + return &capabilitiesv1beta1.ActiveDoc{ + ObjectMeta: metav1.ObjectMeta{Name: "test-activedoc", Namespace: caTestNamespace}, + Spec: capabilitiesv1beta1.ActiveDocSpec{ + Name: "test", + SystemName: &systemName, + }, + } + }(), + providerAccountSecret(), + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1beta1.ActiveDoc{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-activedoc"}, cr); err != nil { + t.Fatalf("get ActiveDoc: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1beta1.ActiveDocFailedConditionType) + return cond != nil && cond.Status == corev1.ConditionTrue + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.ActiveDocReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-activedoc")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +} diff --git a/test/unitcontrollers/application_controller_test.go b/test/unitcontrollers/application_controller_test.go new file mode 100644 index 000000000..e480abc73 --- /dev/null +++ b/test/unitcontrollers/application_controller_test.go @@ -0,0 +1,151 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +const appFinalizer = "application.capabilities.3scale.net/finalizer" + +// TestApplicationReconciler_Reconcile is a table-driven test suite that verifies +// ApplicationReconciler behaviour under an invalid CA bundle. +func TestApplicationReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // ApplicationReconciler calls LookupProviderAccount and then PortaClientFromAccount after the metadata guard + // (finalizer + ownerRef). The CR is pre-seeded with the finalizer and + // the DeveloperAccount ownerRef so that reconcileMetadata() returns + // false and the first Reconcile call proceeds directly to + // LookupProviderAccount → PortaClientFromAccount. + name: "InvalidCABundle", + objects: []runtime.Object{ + func() *capabilitiesv1beta1.Application { + a := &capabilitiesv1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: caTestNamespace, + }, + Spec: capabilitiesv1beta1.ApplicationSpec{ + AccountCR: &corev1.LocalObjectReference{Name: "test-account"}, + ProductCR: &corev1.LocalObjectReference{Name: "test-product"}, + ApplicationPlanName: "basic", + Name: "test", + Description: "test", + }, + } + controllerutil.AddFinalizer(a, appFinalizer) + // Seed the ownerRef so reconcileMetadata returns false. + a.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: capabilitiesv1beta1.GroupVersion.String(), + Kind: "DeveloperAccount", + Name: "test-account", + UID: "test-account-uid", + }, + } + return a + }(), + &capabilitiesv1beta1.DeveloperAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-account", + Namespace: caTestNamespace, + UID: "test-account-uid", + }, + Spec: capabilitiesv1beta1.DeveloperAccountSpec{OrgName: "testorg"}, + }, + &capabilitiesv1beta1.Product{ + ObjectMeta: metav1.ObjectMeta{Name: "test-product", Namespace: caTestNamespace}, + Spec: capabilitiesv1beta1.ProductSpec{Name: "test", SystemName: "test"}, + }, + providerAccountSecret(), + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1beta1.Application{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-app"}, cr); err != nil { + t.Fatalf("get Application: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1beta1.ApplicationReadyConditionType) + return cond != nil && cond.Status == corev1.ConditionFalse && cond.Message != "" + }, + }, + { + // During deletion, ApplicationReconciler must return an error and + // leave the finalizer in place when the CA bundle is invalid. + name: "DeletionPath_InvalidCABundle", + objects: []runtime.Object{ + func() *capabilitiesv1beta1.Application { + now := metav1.Now() + return &capabilitiesv1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-app", + Namespace: caTestNamespace, + Finalizers: []string{appFinalizer}, + DeletionTimestamp: &now, + }, + Spec: capabilitiesv1beta1.ApplicationSpec{ + AccountCR: &corev1.LocalObjectReference{Name: "test-account"}, + }, + Status: capabilitiesv1beta1.ApplicationStatus{ + ID: ptr.To(int64(1)), + }, + } + }(), + &capabilitiesv1beta1.DeveloperAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-account", + Namespace: caTestNamespace, + }, + Status: capabilitiesv1beta1.DeveloperAccountStatus{ + ID: ptr.To(int64(1)), + }, + }, + providerAccountSecret(), + }, + conditionCheck: func(err error, cl client.Client) bool { + if err == nil { + t.Error("expected reconcile to return an error for invalid CA bundle during deletion") + return false + } + cr := &capabilitiesv1beta1.Application{} + if getErr := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-app"}, cr); getErr != nil { + t.Fatalf("get Application: %v", getErr) + } + return controllerutil.ContainsFinalizer(cr, appFinalizer) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.ApplicationReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-app")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +} diff --git a/test/unitcontrollers/applicationauth_controller_test.go b/test/unitcontrollers/applicationauth_controller_test.go new file mode 100644 index 000000000..a5d854ace --- /dev/null +++ b/test/unitcontrollers/applicationauth_controller_test.go @@ -0,0 +1,87 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + 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" +) + +// TestApplicationAuthReconciler_Reconcile is a table-driven test suite that +// verifies ApplicationAuthReconciler behaviour under an invalid CA bundle. +func TestApplicationAuthReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // ApplicationAuthReconciler has no finalizer guard. All dependencies + // (Application, DeveloperAccount, Product, provider-account secret) are + // pre-seeded so the reconciler reaches PortaClientFromAccount on the first call. + name: "InvalidCABundle", + objects: []runtime.Object{ + &capabilitiesv1beta1.ApplicationAuth{ + ObjectMeta: metav1.ObjectMeta{Name: "test-appauth", Namespace: caTestNamespace}, + Spec: capabilitiesv1beta1.ApplicationAuthSpec{ + ApplicationCRName: "test-app", + AuthSecretRef: &corev1.LocalObjectReference{Name: "auth-secret"}, + }, + }, + &capabilitiesv1beta1.Application{ + ObjectMeta: metav1.ObjectMeta{Name: "test-app", Namespace: caTestNamespace}, + Spec: capabilitiesv1beta1.ApplicationSpec{ + AccountCR: &corev1.LocalObjectReference{Name: "test-account"}, + ProductCR: &corev1.LocalObjectReference{Name: "test-product"}, + ApplicationPlanName: "basic", + Name: "test", + Description: "test", + }, + }, + &capabilitiesv1beta1.DeveloperAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "test-account", Namespace: caTestNamespace}, + Spec: capabilitiesv1beta1.DeveloperAccountSpec{OrgName: "testorg"}, + }, + &capabilitiesv1beta1.Product{ + ObjectMeta: metav1.ObjectMeta{Name: "test-product", Namespace: caTestNamespace}, + Spec: capabilitiesv1beta1.ProductSpec{Name: "test", SystemName: "test"}, + }, + providerAccountSecret(), + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1beta1.ApplicationAuth{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-appauth"}, cr); err != nil { + t.Fatalf("get ApplicationAuth: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1beta1.ApplicationAuthFailedConditionType) + return cond != nil && cond.Status == corev1.ConditionTrue && cond.Message != "" + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.ApplicationAuthReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-appauth")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +} diff --git a/test/unitcontrollers/backend_controller_test.go b/test/unitcontrollers/backend_controller_test.go new file mode 100644 index 000000000..a6c943a85 --- /dev/null +++ b/test/unitcontrollers/backend_controller_test.go @@ -0,0 +1,82 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + 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/controller/controllerutil" +) + +// TestBackendReconciler_Reconcile is a table-driven test suite that verifies +// BackendReconciler behaviour under an invalid CA bundle. +func TestBackendReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // BackendReconciler.reconcile() calls LookupProviderAccount and then PortaClientFromAccount after the finalizer + // guard. The CR is pre-seeded with the finalizer and a complete Metrics + // map (including "hits") so that SetDefaults() returns false and no + // early requeue occurs. + name: "InvalidCABundle", + objects: []runtime.Object{ + func() *capabilitiesv1beta1.Backend { + b := &capabilitiesv1beta1.Backend{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-backend", + Namespace: caTestNamespace, + }, + Spec: capabilitiesv1beta1.BackendSpec{ + Name: "test", + SystemName: "test", + PrivateBaseURL: "https://backend.example.com", + Metrics: map[string]capabilitiesv1beta1.MetricSpec{ + "hits": {Name: "Hits", Unit: "hit", Description: "Number of API hits"}, + }, + }, + } + controllerutil.AddFinalizer(b, "backend.capabilities.3scale.net/finalizer") + return b + }(), + providerAccountSecret(), + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1beta1.Backend{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-backend"}, cr); err != nil { + t.Fatalf("get Backend: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1beta1.BackendFailedConditionType) + return cond != nil && cond.Status == corev1.ConditionTrue + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.BackendReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-backend")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +} diff --git a/test/unitcontrollers/ca_test_helpers_test.go b/test/unitcontrollers/ca_test_helpers_test.go new file mode 100644 index 000000000..0ad6cb6db --- /dev/null +++ b/test/unitcontrollers/ca_test_helpers_test.go @@ -0,0 +1,97 @@ +package test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "testing" + + capabilitiesv1alpha1 "github.com/3scale/3scale-operator/apis/capabilities/v1alpha1" + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + configuration "github.com/3scale/3scale-operator/controllers/configuration" + "github.com/3scale/3scale-operator/pkg/reconcilers" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + fakeclientset "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + fakectrlclient "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const caTestNamespace = "operator-unittest" + +// providerAccountSecret returns the well-known secret that LookupProviderAccount +// reads when no ProviderAccountRef is set on a CR. +func providerAccountSecret() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "threescale-provider-account", + Namespace: caTestNamespace, + }, + Data: map[string][]byte{ + "adminURL": []byte("https://3scale-admin.example.com"), + "token": []byte("test-token"), + }, + Type: corev1.SecretTypeOpaque, + } +} + +// setupCATestReconciler builds a BaseReconciler backed by a fake client +// pre-seeded with the supplied objects. +func setupCATestReconciler(t *testing.T, objects ...runtime.Object) *reconcilers.BaseReconciler { + t.Helper() + s := scheme.Scheme + if err := capabilitiesv1beta1.AddToScheme(s); err != nil { + t.Fatalf("AddToScheme capabilitiesv1beta1: %v", err) + } + if err := capabilitiesv1alpha1.AddToScheme(s); err != nil { + t.Fatalf("AddToScheme capabilitiesv1alpha1: %v", err) + } + + var clientObjs []client.Object + for _, o := range objects { + if co, ok := o.(client.Object); ok { + clientObjs = append(clientObjs, co) + } + } + + cl := fakectrlclient.NewClientBuilder(). + WithScheme(s). + WithRuntimeObjects(objects...). + WithStatusSubresource(clientObjs...). + Build() + + clientset := fakeclientset.NewSimpleClientset() + recorder := record.NewFakeRecorder(100) + base := reconcilers.NewBaseReconciler( + context.Background(), cl, s, cl, + ctrl.Log.WithName("ca-provider-test"), + clientset.Discovery(), recorder, + ) + + return base +} + +// setupCAWithFailingTLS installs a *tls.Config with an empty RootCAs pool, +// causing every outbound TLS connection to fail. The previous config is +// restored automatically when the test completes. +// +// IMPORTANT: must NOT be called from parallel tests — t.Setenv is used as a guard. +func setupCAWithFailingTLS(t *testing.T) { + t.Helper() + t.Setenv("_TEST_CA_GUARD", "1") + badTLSConfig := &tls.Config{RootCAs: x509.NewCertPool()} + + prev := configuration.GetTLSConfig() + configuration.SetTLSConfig(badTLSConfig) + t.Cleanup(func() { configuration.SetTLSConfig(prev) }) +} + +func reqFor(ns, name string) reconcile.Request { + return reconcile.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: name}} +} diff --git a/test/unitcontrollers/custompolicydefinition_controller_test.go b/test/unitcontrollers/custompolicydefinition_controller_test.go new file mode 100644 index 000000000..c896e08f2 --- /dev/null +++ b/test/unitcontrollers/custompolicydefinition_controller_test.go @@ -0,0 +1,74 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + 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" +) + +// TestCustomPolicyDefinitionReconciler_Reconcile is a table-driven test suite +// that verifies CustomPolicyDefinitionReconciler behaviour under an invalid CA bundle. +func TestCustomPolicyDefinitionReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // CustomPolicyDefinitionReconciler.reconcileSpec() calls PortaClientFromAccount + // directly with no finalizer guard. + name: "InvalidCABundle", + objects: []runtime.Object{ + &capabilitiesv1beta1.CustomPolicyDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cpd", Namespace: caTestNamespace}, + Spec: capabilitiesv1beta1.CustomPolicyDefinitionSpec{ + Name: "test", + Version: "0.1.0", + Schema: capabilitiesv1beta1.CustomPolicySchemaSpec{ + Name: "test", + Version: "0.1.0", + Summary: "test", + Schema: "http://json-schema.org/draft-07/schema#", + }, + }, + }, + providerAccountSecret(), + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1beta1.CustomPolicyDefinition{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-cpd"}, cr); err != nil { + t.Fatalf("get CustomPolicyDefinition: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1beta1.CustomPolicyDefinitionFailedConditionType) + return cond != nil && cond.Status == corev1.ConditionTrue + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.CustomPolicyDefinitionReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-cpd")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +} diff --git a/test/unitcontrollers/developeraccount_controller_test.go b/test/unitcontrollers/developeraccount_controller_test.go new file mode 100644 index 000000000..14f21d1c3 --- /dev/null +++ b/test/unitcontrollers/developeraccount_controller_test.go @@ -0,0 +1,83 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// TestDeveloperAccountReconciler_Reconcile is a table-driven test suite that +// verifies DeveloperAccountReconciler behaviour under an invalid CA bundle. +func TestDeveloperAccountReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // DeveloperAccountReconciler.reconcileSpec() calls LookupProviderAccount and then PortaClientFromAccount after + // the metadata guard (finalizer). The CR is pre-seeded with the finalizer + // already set. + name: "InvalidCABundle", + objects: []runtime.Object{ + func() *capabilitiesv1beta1.DeveloperAccount { + a := &capabilitiesv1beta1.DeveloperAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-account", + Namespace: caTestNamespace, + // accountID annotation in sync with Status.ID so + // reconcileMetadata() returns false and reconcileSpec runs. + Annotations: map[string]string{"accountID": "42"}, + }, + Spec: capabilitiesv1beta1.DeveloperAccountSpec{OrgName: "testorg"}, + // Status.ID non-nil so findDevAccountByID makes an HTTP call + // that hits the failing transport — exercising the TLS error path. + Status: capabilitiesv1beta1.DeveloperAccountStatus{ + ID: ptr.To(int64(42)), + }, + } + controllerutil.AddFinalizer(a, "developeraccount.capabilities.3scale.net/finalizer") + return a + }(), + providerAccountSecret(), + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1beta1.DeveloperAccount{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-account"}, cr); err != nil { + t.Fatalf("get DeveloperAccount: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1beta1.DeveloperAccountFailedConditionType) + return cond != nil && cond.Status == corev1.ConditionTrue + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.DeveloperAccountReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-account")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +} diff --git a/test/unitcontrollers/developeruser_controller_test.go b/test/unitcontrollers/developeruser_controller_test.go new file mode 100644 index 000000000..9cfbcead9 --- /dev/null +++ b/test/unitcontrollers/developeruser_controller_test.go @@ -0,0 +1,119 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + "github.com/3scale/3scale-operator/pkg/apispkg/common" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +// TestDeveloperUserReconciler_Reconcile is a table-driven test suite that +// verifies DeveloperUserReconciler behaviour under an invalid CA bundle. +func TestDeveloperUserReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // DeveloperUserReconciler.reconcileSpec() calls LookupProviderAccount and then PortaClientFromAccount after: + // 1. the metadata guard (finalizer) + // 2. the ownerRef guard (EnsureOwnerReference) + // 3. findParentAccount — the parent DeveloperAccount must be IsReady() + // + // The DeveloperUser is pre-seeded with finalizer + ownerRef already + // present, and the DeveloperAccount is pre-seeded with + // DeveloperAccountReadyConditionType=True. + name: "InvalidCABundle", + objects: []runtime.Object{ + func() *capabilitiesv1beta1.DeveloperUser { + u := &capabilitiesv1beta1.DeveloperUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: caTestNamespace, + }, + Spec: capabilitiesv1beta1.DeveloperUserSpec{ + Username: "testuser", + Email: "testuser@example.com", + PasswordCredentialsRef: corev1.SecretReference{ + Name: "user-password", + Namespace: caTestNamespace, + }, + DeveloperAccountRef: corev1.LocalObjectReference{Name: "test-account"}, + }, + } + controllerutil.AddFinalizer(u, "developeruser.capabilities.3scale.net/finalizer") + u.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: capabilitiesv1beta1.GroupVersion.String(), + Kind: "DeveloperAccount", + Name: "test-account", + UID: "test-account-uid", + }, + } + return u + }(), + func() *capabilitiesv1beta1.DeveloperAccount { + return &capabilitiesv1beta1.DeveloperAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-account", + Namespace: caTestNamespace, + UID: "test-account-uid", + }, + Spec: capabilitiesv1beta1.DeveloperAccountSpec{OrgName: "testorg"}, + Status: capabilitiesv1beta1.DeveloperAccountStatus{ + // ID non-nil so findDevUserByUsernameAndEmail can dereference it + // without a nil pointer panic; the HTTP call then hits the + // failing transport — exercising the TLS error path. + ID: ptr.To(int64(42)), + Conditions: common.Conditions{ + { + Type: capabilitiesv1beta1.DeveloperAccountReadyConditionType, + Status: corev1.ConditionTrue, + }, + }, + }, + } + }(), + providerAccountSecret(), + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1beta1.DeveloperUser{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-user"}, cr); err != nil { + t.Fatalf("get DeveloperUser: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1beta1.DeveloperUserFailedConditionType) + return cond != nil && cond.Status == corev1.ConditionTrue + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.DeveloperUserReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-user")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +} diff --git a/test/unitcontrollers/product_controller_test.go b/test/unitcontrollers/product_controller_test.go new file mode 100644 index 000000000..73e4931cb --- /dev/null +++ b/test/unitcontrollers/product_controller_test.go @@ -0,0 +1,89 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + 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/controller/controllerutil" +) + +// TestProductReconciler_Reconcile is a table-driven test suite that verifies +// ProductReconciler behaviour under an invalid CA bundle. +func TestProductReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // ProductReconciler.reconcile() calls LookupProviderAccount and then PortaClientFromAccount after the finalizer + // guard. The CR is pre-seeded with the finalizer, a "hits" metric, and + // the required "apicast" policy so that SetDefaults() returns false and + // no early requeue occurs. + name: "InvalidCABundle", + objects: []runtime.Object{ + func() *capabilitiesv1beta1.Product { + p := &capabilitiesv1beta1.Product{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-product", + Namespace: caTestNamespace, + }, + Spec: capabilitiesv1beta1.ProductSpec{ + Name: "test", + SystemName: "test", + Metrics: map[string]capabilitiesv1beta1.MetricSpec{ + "hits": {Name: "Hits", Unit: "hit", Description: "Number of API hits"}, + }, + Policies: []capabilitiesv1beta1.PolicyConfig{ + { + Name: "apicast", + Version: "builtin", + Configuration: runtime.RawExtension{Raw: []byte(`{}`)}, + Enabled: true, + }, + }, + }, + } + controllerutil.AddFinalizer(p, "product.capabilities.3scale.net/finalizer") + return p + }(), + providerAccountSecret(), + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1beta1.Product{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-product"}, cr); err != nil { + t.Fatalf("get Product: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1beta1.ProductFailedConditionType) + return cond != nil && cond.Status == corev1.ConditionTrue + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.ProductReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-product")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +} diff --git a/test/unitcontrollers/proxyconfigpromote_controller_test.go b/test/unitcontrollers/proxyconfigpromote_controller_test.go new file mode 100644 index 000000000..f40b19832 --- /dev/null +++ b/test/unitcontrollers/proxyconfigpromote_controller_test.go @@ -0,0 +1,73 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + 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" +) + +// TestProxyConfigPromoteReconciler_Reconcile is a table-driven test suite that +// verifies ProxyConfigPromoteReconciler behaviour under an invalid CA bundle. +func TestProxyConfigPromoteReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // ProxyConfigPromoteReconciler calls LookupProviderAccount and then PortaClientFromAccount after fetching the + // Product and resolving the provider account. No finalizer guard. The + // Product and provider-account secret are pre-seeded so the reconciler + // reaches PortaClientFromAccount on the first call. + name: "InvalidCABundle", + objects: []runtime.Object{ + &capabilitiesv1beta1.ProxyConfigPromote{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pcp", Namespace: caTestNamespace}, + Spec: capabilitiesv1beta1.ProxyConfigPromoteSpec{ + ProductCRName: "test-product", + }, + }, + &capabilitiesv1beta1.Product{ + ObjectMeta: metav1.ObjectMeta{Name: "test-product", Namespace: caTestNamespace}, + Spec: capabilitiesv1beta1.ProductSpec{Name: "test", SystemName: "test"}, + }, + providerAccountSecret(), + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1beta1.ProxyConfigPromote{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-pcp"}, cr); err != nil { + t.Fatalf("get ProxyConfigPromote: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1beta1.ProxyPromoteConfigFailedConditionType) + return cond != nil && cond.Status == corev1.ConditionTrue && cond.Message != "" + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.ProxyConfigPromoteReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-pcp")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +} diff --git a/test/unitcontrollers/tenant_controller_test.go b/test/unitcontrollers/tenant_controller_test.go new file mode 100644 index 000000000..cb1d51452 --- /dev/null +++ b/test/unitcontrollers/tenant_controller_test.go @@ -0,0 +1,99 @@ +package test + +import ( + "context" + "testing" + + capabilitiesv1alpha1 "github.com/3scale/3scale-operator/apis/capabilities/v1alpha1" + capabilitiescontrollers "github.com/3scale/3scale-operator/controllers/capabilities" + 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/controller/controllerutil" +) + +// TestTenantReconciler_Reconcile is a table-driven test suite that verifies +// TenantReconciler behaviour under an invalid CA bundle. +func TestTenantReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + objects []runtime.Object + conditionCheck func(err error, cl client.Client) bool + }{ + { + // TenantReconciler calls setupPortaClient (which calls PortaClientFromURL) + // as the very first action after fetching the CR — no guards to bypass. + // The CA error is absorbed by reconcileStatus() and returned as + // {Requeue: true}, nil, so the error is checked via the status + // condition rather than the return value. + // + // The CR is pre-seeded with the tenant finalizer so that + // reconcileMetadata() returns false, and the reconciler proceeds to + // internalReconciler.Run() where the first 3scale API call hits the + // failing transport. + name: "InvalidCABundle", + objects: []runtime.Object{ + func() *capabilitiesv1alpha1.Tenant { + ten := &capabilitiesv1alpha1.Tenant{ + ObjectMeta: metav1.ObjectMeta{Name: "test-tenant", Namespace: caTestNamespace}, + Spec: capabilitiesv1alpha1.TenantSpec{ + Username: "admin", + Email: "admin@example.com", + OrganizationName: "testorg", + SystemMasterUrl: "https://master.example.com", + MasterCredentialsRef: corev1.SecretReference{ + Name: "master-credentials", + Namespace: caTestNamespace, + }, + TenantSecretRef: corev1.SecretReference{ + Name: "tenant-secret", + Namespace: caTestNamespace, + }, + PasswordCredentialsRef: corev1.SecretReference{ + Name: "password-credentials", + Namespace: caTestNamespace, + }, + }, + } + controllerutil.AddFinalizer(ten, "tenant.capabilities.3scale.net/finalizer") + return ten + }(), + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "master-credentials", Namespace: caTestNamespace}, + Data: map[string][]byte{"MASTER_ACCESS_TOKEN": []byte("test-token")}, + Type: corev1.SecretTypeOpaque, + }, + }, + conditionCheck: func(_ error, cl client.Client) bool { + cr := &capabilitiesv1alpha1.Tenant{} + if err := cl.Get(context.Background(), types.NamespacedName{Namespace: caTestNamespace, Name: "test-tenant"}, cr); err != nil { + t.Fatalf("get Tenant: %v", err) + } + cond := cr.Status.Conditions.GetCondition(capabilitiesv1alpha1.TenantReadyConditionType) + return cond != nil && cond.Status == corev1.ConditionFalse && cond.Message != "" + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + base := setupCATestReconciler(t, tc.objects...) + setupCAWithFailingTLS(t) + r := &capabilitiescontrollers.TenantReconciler{BaseReconciler: base} + _, err := r.Reconcile(context.Background(), reqFor(caTestNamespace, "test-tenant")) + + if tc.conditionCheck != nil { + if !tc.conditionCheck(err, base.Client()) { + t.Error("expected CA validation error to be visible via status condition but it was not") + } + return + } + + if err == nil { + t.Fatal("CA error was not surfaced via return value and no conditionCheck is configured") + } + }) + } +}