From 7947424814f66361b905fe199e76cc432e4ab3b0 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Thu, 16 Jul 2026 14:17:33 +0200 Subject: [PATCH 01/21] Add e2e test for proxy validation degraded condition Verify that setting spec.proxy.httpsProxy to an unreachable host on the operator Authentication CR causes ProxyConfigControllerDegraded to become True, propagating to ClusterOperator Degraded=True. The test is gated behind the AuthenticationComponentProxy feature gate and registered in the OTE serial/operator suite. --- .../main.go | 11 ++ test/e2e-component-proxy/component_proxy.go | 121 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 test/e2e-component-proxy/component_proxy.go diff --git a/cmd/cluster-authentication-operator-tests-ext/main.go b/cmd/cluster-authentication-operator-tests-ext/main.go index 98af19620..af486fdca 100644 --- a/cmd/cluster-authentication-operator-tests-ext/main.go +++ b/cmd/cluster-authentication-operator-tests-ext/main.go @@ -13,6 +13,7 @@ import ( "github.com/openshift/cluster-authentication-operator/pkg/version" _ "github.com/openshift/cluster-authentication-operator/test/e2e" + _ "github.com/openshift/cluster-authentication-operator/test/e2e-component-proxy" _ "github.com/openshift/cluster-authentication-operator/test/e2e-encryption" _ "github.com/openshift/cluster-authentication-operator/test/e2e-encryption-kms" _ "github.com/openshift/cluster-authentication-operator/test/e2e-encryption-perf" @@ -85,6 +86,16 @@ func prepareOperatorTestsRegistry() (*oteextension.Registry, error) { }, }) + // The following suite runs component-proxy tests that require the + // AuthenticationComponentProxy feature gate (TechPreviewNoUpgrade). + extension.AddSuite(oteextension.Suite{ + Name: "openshift/cluster-authentication-operator/component-proxy/serial", + Parallelism: 1, + Qualifiers: []string{ + `name.contains("[ComponentProxy]")`, + }, + }) + // The following suite runs tests that are disruptive to the cluster. extension.AddSuite(oteextension.Suite{ Name: "openshift/cluster-authentication-operator/operator/disruptive", diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go new file mode 100644 index 000000000..a0c8a7812 --- /dev/null +++ b/test/e2e-component-proxy/component_proxy.go @@ -0,0 +1,121 @@ +package component_proxy + +import ( + "context" + "time" + + g "github.com/onsi/ginkgo/v2" + o "github.com/onsi/gomega" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + + "github.com/openshift/api/features" + operatorv1 "github.com/openshift/api/operator/v1" + "github.com/openshift/library-go/pkg/operator/v1helpers" + + test "github.com/openshift/cluster-authentication-operator/test/library" +) + +var _ = g.Describe("[sig-auth] authentication operator", func() { + g.It("[Serial][Operator][ComponentProxy] should set Degraded when spec.proxy points to an unreachable proxy", func() { + testDegradedOnBadProxyURL() + }) +}) + +func testDegradedOnBadProxyURL() { + ctx := context.Background() + t := g.GinkgoTB() + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + checkFeatureGateOrSkip(ctx, clients) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Saving original proxy config for cleanup") + operatorAuth, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + originalProxy := operatorAuth.Spec.Proxy.DeepCopy() + + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: restoring original proxy config") + fresh, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + g.GinkgoWriter.Printf("cleanup: failed to get operator auth: %v\n", err) + return + } + if originalProxy != nil { + fresh.Spec.Proxy = *originalProxy + } else { + fresh.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + } + if _, err := clients.OperatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { + g.GinkgoWriter.Printf("cleanup: failed to restore proxy: %v\n", err) + return + } + + g.GinkgoWriter.Println("cleanup: waiting for ProxyConfigControllerDegraded to clear") + if err := wait.PollUntilContextTimeout(ctx, 10*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { + config, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + return false, nil + } + cond := v1helpers.FindOperatorCondition(config.Status.Conditions, "ProxyConfigControllerDegraded") + return cond == nil || cond.Status != operatorv1.ConditionTrue, nil + }); err != nil { + g.GinkgoWriter.Printf("cleanup: ProxyConfigControllerDegraded did not clear: %v\n", err) + } + + g.GinkgoWriter.Println("cleanup: waiting for operator to stabilize") + if err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication"); err != nil { + g.GinkgoWriter.Printf("cleanup: operator did not recover: %v\n", err) + } + }) + + g.By("Setting spec.proxy.httpsProxy to an unreachable host") + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: "http://does-not-exist.invalid:3128", + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for ProxyConfigControllerDegraded=True on the operator CR") + var lastCondition *operatorv1.OperatorCondition + err = wait.PollUntilContextTimeout(ctx, 10*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { + config, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + g.GinkgoWriter.Printf("failed to get operator auth: %v\n", err) + return false, nil + } + lastCondition = v1helpers.FindOperatorCondition(config.Status.Conditions, "ProxyConfigControllerDegraded") + return lastCondition != nil && lastCondition.Status == operatorv1.ConditionTrue, nil + }) + o.Expect(err).NotTo(o.HaveOccurred(), "ProxyConfigControllerDegraded never became True") + o.Expect(lastCondition).NotTo(o.BeNil()) + g.GinkgoWriter.Printf("ProxyConfigControllerDegraded: status=%s reason=%s message=%s\n", lastCondition.Status, lastCondition.Reason, lastCondition.Message) + + g.By("Verifying ClusterOperator authentication is Degraded") + err = test.WaitForClusterOperatorDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) +} + +func checkFeatureGateOrSkip(ctx context.Context, clients *test.TestClients) { + featureGates, err := clients.ConfigClient.ConfigV1().FeatureGates().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + if len(featureGates.Status.FeatureGates) != 1 { + g.Fail("multiple feature gate versions detected") + } + + for _, gate := range featureGates.Status.FeatureGates[0].Enabled { + if gate.Name == features.FeatureGateAuthenticationComponentProxy { + return + } + } + + g.Skip("feature gate " + string(features.FeatureGateAuthenticationComponentProxy) + " is not enabled") +} From 5accbaec3f51dc04ebb7b9ca1b96713db9a5b776 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Fri, 17 Jul 2026 14:04:33 +0200 Subject: [PATCH 02/21] Add e2e test for IdPEndpointUnreachable warning event Add C2 test: deploy a Squid proxy, configure spec.proxy to point at it, add a fake OpenID IdP with an unresolvable issuer URL, and verify the ProxyConfigController emits an IdPEndpointUnreachable Warning event without going Degraded. Also add stub helper functions in test/library/proxy.go for future proxy e2e infrastructure (DeploySquidProxy, DeployProxyNetworkPolicies, etc.). Use WaitForOperatorToPickUpChanges in both C1 and C2 cleanup to avoid racing with stale operator status. --- test/e2e-component-proxy/component_proxy.go | 157 ++++++++++++++++++-- test/library/proxy.go | 53 +++++++ 2 files changed, 196 insertions(+), 14 deletions(-) create mode 100644 test/library/proxy.go diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index a0c8a7812..3f2313fd5 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -2,14 +2,17 @@ package component_proxy import ( "context" + "fmt" "time" g "github.com/onsi/ginkgo/v2" o "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" + configv1 "github.com/openshift/api/config/v1" "github.com/openshift/api/features" operatorv1 "github.com/openshift/api/operator/v1" "github.com/openshift/library-go/pkg/operator/v1helpers" @@ -21,6 +24,9 @@ var _ = g.Describe("[sig-auth] authentication operator", func() { g.It("[Serial][Operator][ComponentProxy] should set Degraded when spec.proxy points to an unreachable proxy", func() { testDegradedOnBadProxyURL() }) + g.It("[Serial][Operator][ComponentProxy] should emit IdPEndpointUnreachable warning when IdP is unreachable through proxy", func() { + testWarningOnUnreachableIdP() + }) }) func testDegradedOnBadProxyURL() { @@ -58,20 +64,8 @@ func testDegradedOnBadProxyURL() { return } - g.GinkgoWriter.Println("cleanup: waiting for ProxyConfigControllerDegraded to clear") - if err := wait.PollUntilContextTimeout(ctx, 10*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { - config, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) - if err != nil { - return false, nil - } - cond := v1helpers.FindOperatorCondition(config.Status.Conditions, "ProxyConfigControllerDegraded") - return cond == nil || cond.Status != operatorv1.ConditionTrue, nil - }); err != nil { - g.GinkgoWriter.Printf("cleanup: ProxyConfigControllerDegraded did not clear: %v\n", err) - } - - g.GinkgoWriter.Println("cleanup: waiting for operator to stabilize") - if err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication"); err != nil { + g.GinkgoWriter.Println("cleanup: waiting for operator to pick up changes and stabilize") + if err := test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication"); err != nil { g.GinkgoWriter.Printf("cleanup: operator did not recover: %v\n", err) } }) @@ -103,6 +97,141 @@ func testDegradedOnBadProxyURL() { o.Expect(err).NotTo(o.HaveOccurred()) } +func testWarningOnUnreachableIdP() { + ctx := context.Background() + t := g.GinkgoTB() + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + checkFeatureGateOrSkip(ctx, clients) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + proxyURL, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing Squid proxy") + proxyCleanup() + }) + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) + + g.By("Saving original proxy config for cleanup") + operatorAuth, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + originalProxy := operatorAuth.Spec.Proxy.DeepCopy() + + const ( + fakeIDPName = "e2e-unreachable-idp" + fakeIDPSecretName = "e2e-unreachable-idp-secret" + ) + + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing fake IdP from OAuth config") + test.CleanIDPConfigByName(t, clients.ConfigClient.ConfigV1().OAuths(), fakeIDPName) + + g.GinkgoWriter.Println("cleaning up: deleting fake IdP secret") + _ = clients.KubeClient.CoreV1().Secrets("openshift-config").Delete(ctx, fakeIDPSecretName, metav1.DeleteOptions{}) + + g.GinkgoWriter.Println("cleaning up: restoring original proxy config") + fresh, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + g.GinkgoWriter.Printf("cleanup: failed to get operator auth: %v\n", err) + return + } + if originalProxy != nil { + fresh.Spec.Proxy = *originalProxy + } else { + fresh.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + } + if _, err := clients.OperatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { + g.GinkgoWriter.Printf("cleanup: failed to restore proxy: %v\n", err) + return + } + + g.GinkgoWriter.Println("cleanup: waiting for operator to pick up changes and stabilize") + if err := test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication"); err != nil { + g.GinkgoWriter.Printf("cleanup: operator did not recover: %v\n", err) + } + }) + + g.By("Creating fake IdP client secret in openshift-config") + _, err = clients.KubeClient.CoreV1().Secrets("openshift-config").Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: fakeIDPSecretName, + Labels: test.CAOE2ETestLabels(), + }, + Data: map[string][]byte{ + "clientSecret": []byte("fake-secret"), + }, + }, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Adding fake OpenID IdP to OAuth config") + oauthConfig, err := clients.ConfigClient.ConfigV1().OAuths().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + oauthCopy := oauthConfig.DeepCopy() + oauthCopy.Spec.IdentityProviders = append(oauthCopy.Spec.IdentityProviders, configv1.IdentityProvider{ + Name: fakeIDPName, + MappingMethod: configv1.MappingMethodClaim, + IdentityProviderConfig: configv1.IdentityProviderConfig{ + Type: configv1.IdentityProviderTypeOpenID, + OpenID: &configv1.OpenIDIdentityProvider{ + ClientID: "fake-client", + ClientSecret: configv1.SecretNameReference{ + Name: fakeIDPSecretName, + }, + Issuer: "https://unreachable-idp.invalid", + Claims: configv1.OpenIDClaims{ + PreferredUsername: []string{"preferred_username"}, + }, + }, + }, + }) + _, err = clients.ConfigClient.ConfigV1().OAuths().Update(ctx, oauthCopy, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Setting component-scoped proxy pointing to the Squid instance") + startTime := time.Now() + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: proxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for IdPEndpointUnreachable warning event") + err = wait.PollUntilContextTimeout(ctx, 10*time.Second, 10*time.Minute, true, func(ctx context.Context) (bool, error) { + events, err := clients.KubeClient.CoreV1().Events("openshift-authentication-operator").List(ctx, metav1.ListOptions{ + FieldSelector: "reason=IdPEndpointUnreachable", + }) + if err != nil { + g.GinkgoWriter.Printf("failed to list events: %v\n", err) + return false, nil + } + for _, event := range events.Items { + eventTime := event.LastTimestamp.Time + if eventTime.IsZero() { + eventTime = event.EventTime.Time + } + if event.Type == "Warning" && eventTime.After(startTime) { + g.GinkgoWriter.Printf("found IdPEndpointUnreachable event: %s\n", event.Message) + return true, nil + } + } + return false, nil + }) + o.Expect(err).NotTo(o.HaveOccurred(), "IdPEndpointUnreachable warning event was not emitted") + + g.By("Verifying operator is NOT Degraded") + ok, conditions, checkErr := test.CheckClusterOperatorStatus(t, ctx, clients.ConfigClient.ConfigV1(), "authentication", + configv1.ClusterOperatorStatusCondition{Type: configv1.OperatorDegraded, Status: configv1.ConditionFalse}, + ) + o.Expect(checkErr).NotTo(o.HaveOccurred()) + o.Expect(ok).To(o.BeTrue(), fmt.Sprintf("operator should NOT be degraded, conditions: %v", conditions)) +} + func checkFeatureGateOrSkip(ctx context.Context, clients *test.TestClients) { featureGates, err := clients.ConfigClient.ConfigV1().FeatureGates().Get(ctx, "cluster", metav1.GetOptions{}) o.Expect(err).NotTo(o.HaveOccurred()) diff --git a/test/library/proxy.go b/test/library/proxy.go new file mode 100644 index 000000000..dea090188 --- /dev/null +++ b/test/library/proxy.go @@ -0,0 +1,53 @@ +package library + +import ( + "testing" + "time" + + configv1 "github.com/openshift/api/config/v1" + configclient "github.com/openshift/client-go/config/clientset/versioned" + "k8s.io/client-go/kubernetes" +) + +// DeploySquidProxy deploys a Squid forward proxy in a new namespace. +// Returns the in-cluster proxy URL, namespace name, and cleanup function. +func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyURL string, namespace string, cleanup func()) { + panic("not implemented") +} + +// DeployProxyNetworkPolicies blocks auth namespaces from reaching Keycloak directly. +// Only allows traffic through the proxy. Returns cleanup function. +func DeployProxyNetworkPolicies(t testing.TB, kubeClient kubernetes.Interface, proxyNamespace, keycloakNamespace string) func() { + panic("not implemented") +} + +// GetOAuthServerProxyEnvVars reads proxy env vars from the oauth-openshift Deployment. +// Returns map with keys: HTTP_PROXY, HTTPS_PROXY, NO_PROXY. +func GetOAuthServerProxyEnvVars(t testing.TB, kubeClient kubernetes.Interface) map[string]string { + panic("not implemented") +} + +// GetSquidProxyLogs reads the Squid proxy pod logs for verifying CONNECT entries. +func GetSquidProxyLogs(t testing.TB, kubeClient kubernetes.Interface, namespace string) string { + panic("not implemented") +} + +// WaitForSquidProxyTraffic polls Squid logs until traffic is detected. Returns error on timeout. +func WaitForSquidProxyTraffic(t testing.TB, kubeClient kubernetes.Interface, namespace string, timeout time.Duration) error { + panic("not implemented") +} + +// VerifyOAuthServerDeploymentProxyConfig asserts env vars + volumes/mounts on the OAuth server Deployment. +func VerifyOAuthServerDeploymentProxyConfig(t testing.TB, kubeClient kubernetes.Interface, expectedProxyURL, trustedCAConfigMap string) { + panic("not implemented") +} + +// VerifyTrustedCAConfigMapSynced checks that the trustedCA ConfigMap was synced to openshift-authentication. +func VerifyTrustedCAConfigMapSynced(t testing.TB, kubeClient kubernetes.Interface, configMapName string) { + panic("not implemented") +} + +// CheckFeatureGateEnabledOrSkip skips the test if the given feature gate is not enabled. +func CheckFeatureGateEnabledOrSkip(t testing.TB, configClient *configclient.Clientset, featureGateName configv1.FeatureGateName) { + panic("not implemented") +} From 067d1b05e80b475905efc7f4eef50e3b4ddd6922 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Mon, 20 Jul 2026 12:59:07 +0200 Subject: [PATCH 03/21] Refactor e2e proxy tests: extract SaveAndRestoreProxyConfig helper, add A1 test Move proxy config save/restore logic into a shared test helper in test/library/proxy.go. Add the A1 test (OIDC IdP validation through component proxy) and clean up C1/C2 tests to use the shared helper and exported CheckFeatureGateEnabledOrSkip. Remove redundant nil check in C1 test. --- test/e2e-component-proxy/component_proxy.go | 139 +++++++++++--------- test/library/proxy.go | 40 ++++++ 2 files changed, 116 insertions(+), 63 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index 3f2313fd5..2241ebf29 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -21,6 +21,9 @@ import ( ) var _ = g.Describe("[sig-auth] authentication operator", func() { + g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy", func() { + testOIDCIdPThroughComponentProxy() + }) g.It("[Serial][Operator][ComponentProxy] should set Degraded when spec.proxy points to an unreachable proxy", func() { testDegradedOnBadProxyURL() }) @@ -29,47 +32,86 @@ var _ = g.Describe("[sig-auth] authentication operator", func() { }) }) -func testDegradedOnBadProxyURL() { +func testOIDCIdPThroughComponentProxy() { ctx := context.Background() t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) g.By("Creating test clients") clients := test.NewTestClients(t) - checkFeatureGateOrSkip(ctx, clients) + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) g.By("Waiting for authentication operator to be stable before test") err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") o.Expect(err).NotTo(o.HaveOccurred()) - g.By("Saving original proxy config for cleanup") - operatorAuth, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + g.By("Deploying Squid forward proxy") + proxyURL, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing Squid proxy") + proxyCleanup() + }) + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) + + g.By("Saving original proxy config and setting component-scoped proxy") + operatorAuth, proxyCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyCleanup) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: proxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) o.Expect(err).NotTo(o.HaveOccurred()) - originalProxy := operatorAuth.Spec.Proxy.DeepCopy() - g.DeferCleanup(func() { - g.GinkgoWriter.Println("cleaning up: restoring original proxy config") - fresh, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) - if err != nil { - g.GinkgoWriter.Printf("cleanup: failed to get operator auth: %v\n", err) - return - } - if originalProxy != nil { - fresh.Spec.Proxy = *originalProxy - } else { - fresh.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} - } - if _, err := clients.OperatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { - g.GinkgoWriter.Printf("cleanup: failed to restore proxy: %v\n", err) - return + g.By("Deploying Keycloak and adding OIDC IdP (operator uses proxy for discovery)") + kcClient, idpName, keycloakCleanups := test.AddKeycloakIDP(t, kubeConfig, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() } + })) + g.GinkgoWriter.Printf("Keycloak issuer URL: %s\n", kcClient.IssuerURL()) + g.GinkgoWriter.Printf("IdP name: %s\n", idpName) - g.GinkgoWriter.Println("cleanup: waiting for operator to pick up changes and stabilize") - if err := test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication"); err != nil { - g.GinkgoWriter.Printf("cleanup: operator did not recover: %v\n", err) - } + g.By("Deploying NetworkPolicy to restrict Keycloak ingress to proxy namespace only") + keycloakNamespace := extractNamespaceFromIDPName(idpName) + networkPolicyCleanup := test.DeployProxyNetworkPolicies(t, clients.KubeClient, proxyNamespace, keycloakNamespace) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing proxy NetworkPolicies") + networkPolicyCleanup() }) + g.By("Verifying operator is Available and not Degraded") + err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying OAuth server deployment has proxy env vars") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, proxyURL, "") + + g.By("Verifying traffic went through the Squid proxy") + err = test.WaitForSquidProxyTraffic(t, clients.KubeClient, proxyNamespace, 5*time.Minute) + o.Expect(err).NotTo(o.HaveOccurred()) +} + +func testDegradedOnBadProxyURL() { + ctx := context.Background() + t := g.GinkgoTB() + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Saving original proxy config for cleanup") + operatorAuth, proxyCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyCleanup) + g.By("Setting spec.proxy.httpsProxy to an unreachable host") operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ HTTPSProxy: "http://does-not-exist.invalid:3128", @@ -89,7 +131,6 @@ func testDegradedOnBadProxyURL() { return lastCondition != nil && lastCondition.Status == operatorv1.ConditionTrue, nil }) o.Expect(err).NotTo(o.HaveOccurred(), "ProxyConfigControllerDegraded never became True") - o.Expect(lastCondition).NotTo(o.BeNil()) g.GinkgoWriter.Printf("ProxyConfigControllerDegraded: status=%s reason=%s message=%s\n", lastCondition.Status, lastCondition.Reason, lastCondition.Message) g.By("Verifying ClusterOperator authentication is Degraded") @@ -104,7 +145,7 @@ func testWarningOnUnreachableIdP() { g.By("Creating test clients") clients := test.NewTestClients(t) - checkFeatureGateOrSkip(ctx, clients) + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) g.By("Waiting for authentication operator to be stable before test") err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") @@ -119,9 +160,7 @@ func testWarningOnUnreachableIdP() { g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) g.By("Saving original proxy config for cleanup") - operatorAuth, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) - o.Expect(err).NotTo(o.HaveOccurred()) - originalProxy := operatorAuth.Spec.Proxy.DeepCopy() + operatorAuth, proxyCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) const ( fakeIDPName = "e2e-unreachable-idp" @@ -135,26 +174,7 @@ func testWarningOnUnreachableIdP() { g.GinkgoWriter.Println("cleaning up: deleting fake IdP secret") _ = clients.KubeClient.CoreV1().Secrets("openshift-config").Delete(ctx, fakeIDPSecretName, metav1.DeleteOptions{}) - g.GinkgoWriter.Println("cleaning up: restoring original proxy config") - fresh, err := clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) - if err != nil { - g.GinkgoWriter.Printf("cleanup: failed to get operator auth: %v\n", err) - return - } - if originalProxy != nil { - fresh.Spec.Proxy = *originalProxy - } else { - fresh.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} - } - if _, err := clients.OperatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { - g.GinkgoWriter.Printf("cleanup: failed to restore proxy: %v\n", err) - return - } - - g.GinkgoWriter.Println("cleanup: waiting for operator to pick up changes and stabilize") - if err := test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication"); err != nil { - g.GinkgoWriter.Printf("cleanup: operator did not recover: %v\n", err) - } + proxyCleanup() }) g.By("Creating fake IdP client secret in openshift-config") @@ -232,19 +252,12 @@ func testWarningOnUnreachableIdP() { o.Expect(ok).To(o.BeTrue(), fmt.Sprintf("operator should NOT be degraded, conditions: %v", conditions)) } -func checkFeatureGateOrSkip(ctx context.Context, clients *test.TestClients) { - featureGates, err := clients.ConfigClient.ConfigV1().FeatureGates().Get(ctx, "cluster", metav1.GetOptions{}) - o.Expect(err).NotTo(o.HaveOccurred()) - - if len(featureGates.Status.FeatureGates) != 1 { - g.Fail("multiple feature gate versions detected") - } - - for _, gate := range featureGates.Status.FeatureGates[0].Enabled { - if gate.Name == features.FeatureGateAuthenticationComponentProxy { - return - } +func extractNamespaceFromIDPName(idpName string) string { + // AddKeycloakIDP generates idpName as "keycloak-test-" + // where namespace is the test namespace created by deployPod + const prefix = "keycloak-test-" + if len(idpName) > len(prefix) { + return idpName[len(prefix):] } - - g.Skip("feature gate " + string(features.FeatureGateAuthenticationComponentProxy) + " is not enabled") + return idpName } diff --git a/test/library/proxy.go b/test/library/proxy.go index dea090188..3201a742f 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -1,14 +1,54 @@ package library import ( + "context" "testing" "time" configv1 "github.com/openshift/api/config/v1" + operatorv1 "github.com/openshift/api/operator/v1" configclient "github.com/openshift/client-go/config/clientset/versioned" + operatorclient "github.com/openshift/client-go/operator/clientset/versioned" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) +// SaveAndRestoreProxyConfig snapshots the current spec.proxy on the operator +// Authentication CR and returns a cleanup function that restores it and waits +// for the operator to reconcile. +func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clientset, configClient *configclient.Clientset) (operatorAuth *operatorv1.Authentication, cleanup func()) { + ctx := context.TODO() + + auth, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get operator authentication CR: %v", err) + } + originalProxy := auth.Spec.Proxy.DeepCopy() + + return auth, func() { + t.Log("cleaning up: restoring original proxy config") + fresh, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: failed to get operator auth: %v", err) + return + } + if originalProxy != nil { + fresh.Spec.Proxy = *originalProxy + } else { + fresh.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + } + if _, err := operatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: failed to restore proxy: %v", err) + return + } + t.Log("cleanup: waiting for operator to pick up changes and stabilize") + if err := WaitForOperatorToPickUpChanges(t, configClient.ConfigV1(), "authentication"); err != nil { + t.Logf("cleanup: operator did not recover: %v", err) + } + } +} + // DeploySquidProxy deploys a Squid forward proxy in a new namespace. // Returns the in-cluster proxy URL, namespace name, and cleanup function. func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyURL string, namespace string, cleanup func()) { From 41078a89e741af366a209317020e35c644d88a26 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Tue, 21 Jul 2026 11:31:22 +0200 Subject: [PATCH 04/21] Add A1/A2 e2e tests, split DeployKeycloak from AddKeycloakIDP Add Group A proxy e2e tests: - A1: validate OIDC IdP through component proxy, with and without trustedCA (two g.It variants sharing testOIDCIdPThroughComponentProxy) - A2: verify operator falls back gracefully on spec.proxy removal Split AddKeycloakIDP into DeployKeycloak + AddKeycloakOIDCIdP so tests can control ordering (deploy Keycloak, apply NetworkPolicy, set proxy, then register IdP). AddKeycloakIDP remains as a convenience wrapper. Simplify DeploySquidProxy to always generate TLS internally and return the CA PEM bytes. Update CheckFeatureGateEnabledOrSkip to replace the local checkFeatureGateOrSkip in all tests. --- test/e2e-component-proxy/component_proxy.go | 164 ++++++++++++++++---- test/library/keycloakidp.go | 116 ++++++++------ test/library/proxy.go | 9 +- 3 files changed, 208 insertions(+), 81 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index 2241ebf29..4fccd72f2 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -22,7 +22,13 @@ import ( var _ = g.Describe("[sig-auth] authentication operator", func() { g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy", func() { - testOIDCIdPThroughComponentProxy() + testOIDCIdPThroughComponentProxy(false) + }) + g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy with trustedCAs", func() { + testOIDCIdPThroughComponentProxy(true) + }) + g.It("[Serial][Operator][ComponentProxy] should fall back on spec.proxy removal", func() { + testFallbackOnProxyRemoval() }) g.It("[Serial][Operator][ComponentProxy] should set Degraded when spec.proxy points to an unreachable proxy", func() { testDegradedOnBadProxyURL() @@ -32,7 +38,7 @@ var _ = g.Describe("[sig-auth] authentication operator", func() { }) }) -func testOIDCIdPThroughComponentProxy() { +func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { ctx := context.Background() t := g.GinkgoTB() kubeConfig := test.NewClientConfigForTest(t) @@ -47,54 +53,158 @@ func testOIDCIdPThroughComponentProxy() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyURL, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + proxyURL, caCertPEM, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") proxyCleanup() }) g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) - g.By("Saving original proxy config and setting component-scoped proxy") - operatorAuth, proxyCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) - g.DeferCleanup(proxyCleanup) - - operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ - HTTPSProxy: proxyURL, + const trustedCAConfigMapName = "e2e-proxy-ca" + if withTrustedCA { + g.By("Creating trustedCA ConfigMap in openshift-config") + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: trustedCAConfigMapName, + Labels: test.CAOE2ETestLabels(), + }, + Data: map[string]string{ + "ca-bundle.crt": string(caCertPEM), + }, + }, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing trustedCA ConfigMap") + _ = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, trustedCAConfigMapName, metav1.DeleteOptions{}) + }) } - _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) - o.Expect(err).NotTo(o.HaveOccurred()) - g.By("Deploying Keycloak and adding OIDC IdP (operator uses proxy for discovery)") - kcClient, idpName, keycloakCleanups := test.AddKeycloakIDP(t, kubeConfig, false) + g.By("Deploying Keycloak (without registering IdP yet)") + kcSetup := test.DeployKeycloak(t, kubeConfig) g.DeferCleanup(test.IDPCleanupWrapper(func() { - g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") - for _, cleanup := range keycloakCleanups { + g.GinkgoWriter.Println("cleaning up: removing Keycloak") + for _, cleanup := range kcSetup.Cleanups { cleanup() } })) - g.GinkgoWriter.Printf("Keycloak issuer URL: %s\n", kcClient.IssuerURL()) - g.GinkgoWriter.Printf("IdP name: %s\n", idpName) + g.GinkgoWriter.Printf("Keycloak issuer URL: %s\n", kcSetup.IssuerURL) + g.GinkgoWriter.Printf("Keycloak namespace: %s\n", kcSetup.Namespace) g.By("Deploying NetworkPolicy to restrict Keycloak ingress to proxy namespace only") - keycloakNamespace := extractNamespaceFromIDPName(idpName) - networkPolicyCleanup := test.DeployProxyNetworkPolicies(t, clients.KubeClient, proxyNamespace, keycloakNamespace) + networkPolicyCleanup := test.DeployProxyNetworkPolicies(t, clients.KubeClient, proxyNamespace, kcSetup.Namespace) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing proxy NetworkPolicies") networkPolicyCleanup() }) + g.By("Setting component-scoped proxy") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyRestore) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: proxyURL, + } + if withTrustedCA { + operatorAuth.Spec.Proxy.TrustedCA = operatorv1.AuthenticationConfigMapReference{Name: trustedCAConfigMapName} + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Registering Keycloak as OIDC IdP (operator discovers it through the proxy)") + idpCleanups := test.AddKeycloakOIDCIdP(t, kubeConfig, kcSetup, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing OIDC IdP") + for _, cleanup := range idpCleanups { + cleanup() + } + })) + g.By("Verifying operator is Available and not Degraded") err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") o.Expect(err).NotTo(o.HaveOccurred()) - g.By("Verifying OAuth server deployment has proxy env vars") - test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, proxyURL, "") + g.By("Verifying OAuth server deployment state") + trustedCAName := "" + if withTrustedCA { + trustedCAName = trustedCAConfigMapName + } + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, proxyURL, trustedCAName) + + if withTrustedCA { + g.By("Verifying trustedCA ConfigMap was synced to openshift-authentication") + test.VerifyTrustedCAConfigMapSynced(t, clients.KubeClient, trustedCAConfigMapName) + } g.By("Verifying traffic went through the Squid proxy") err = test.WaitForSquidProxyTraffic(t, clients.KubeClient, proxyNamespace, 5*time.Minute) o.Expect(err).NotTo(o.HaveOccurred()) } +// No NetworkPolicy is deployed here intentionally: after proxy removal the +// operator must fall back to direct connectivity, so Keycloak must remain +// reachable without a proxy. +func testFallbackOnProxyRemoval() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + + g.By("Creating test clients") + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + proxyURL, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing Squid proxy") + proxyCleanup() + }) + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) + + g.By("Saving original proxy config and setting component-scoped proxy") + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyRestore) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: proxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Keycloak and adding OIDC IdP") + _, _, keycloakCleanups := test.AddKeycloakIDP(t, kubeConfig, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + g.By("Verifying operator is stable with proxy configured") + err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Removing spec.proxy from Authentication CR") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to pick up proxy removal and stabilize") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying proxy env vars are no longer set on OAuth server deployment") + envVars := test.GetOAuthServerProxyEnvVars(t, clients.KubeClient) + o.Expect(envVars).NotTo(o.HaveKey("HTTPS_PROXY"), + fmt.Sprintf("HTTPS_PROXY should not be set after proxy removal, got env vars: %v", envVars)) +} + func testDegradedOnBadProxyURL() { ctx := context.Background() t := g.GinkgoTB() @@ -152,7 +262,7 @@ func testWarningOnUnreachableIdP() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyURL, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + proxyURL, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") proxyCleanup() @@ -251,13 +361,3 @@ func testWarningOnUnreachableIdP() { o.Expect(checkErr).NotTo(o.HaveOccurred()) o.Expect(ok).To(o.BeTrue(), fmt.Sprintf("operator should NOT be degraded, conditions: %v", conditions)) } - -func extractNamespaceFromIDPName(idpName string) string { - // AddKeycloakIDP generates idpName as "keycloak-test-" - // where namespace is the test namespace created by deployPod - const prefix = "keycloak-test-" - if len(idpName) > len(prefix) { - return idpName[len(prefix):] - } - return idpName -} diff --git a/test/library/keycloakidp.go b/test/library/keycloakidp.go index 8d3b25487..e0a570881 100644 --- a/test/library/keycloakidp.go +++ b/test/library/keycloakidp.go @@ -27,20 +27,28 @@ import ( routev1client "github.com/openshift/client-go/route/clientset/versioned/typed/route/v1" ) -func AddKeycloakIDP( - t testing.TB, - kubeconfig *rest.Config, - directOIDC bool, -) (kcClient *KeycloakClient, idpName string, cleanups []func()) { +// KeycloakSetup holds the results of deploying Keycloak, before the IdP is +// registered in OpenShift. Use AddKeycloakOIDCIdP to register the IdP. +type KeycloakSetup struct { + Client *KeycloakClient + IDPName string + Namespace string + ClientID string + ClientSecret string + IssuerURL string + Cleanups []func() +} + +// DeployKeycloak deploys Keycloak in a test namespace, configures a client and +// group mapper, and returns a KeycloakSetup. The IdP is NOT registered in +// OpenShift — call AddKeycloakOIDCIdP separately when ready. +func DeployKeycloak(t testing.TB, kubeconfig *rest.Config) *KeycloakSetup { kubeClients, err := kubernetes.NewForConfig(kubeconfig) require.NoError(t, err) routeClient, err := routev1client.NewForConfig(kubeconfig) require.NoError(t, err) - configClient, err := configv1client.NewForConfig(kubeconfig) - require.NoError(t, err) - readinessProbe := corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ @@ -66,7 +74,6 @@ func AddKeycloakIDP( "keycloak", "quay.io/keycloak/keycloak:25.0", []corev1.EnvVar{ - // configure password for Keycloak root user {Name: "KEYCLOAK_ADMIN", Value: "admin"}, {Name: "KEYCLOAK_ADMIN_PASSWORD", Value: "password"}, {Name: "KC_HEALTH_ENABLED", Value: "true"}, @@ -105,10 +112,15 @@ func AddKeycloakIDP( true, "/opt/keycloak/bin/kc.sh", "start-dev", ) - cleanups = []func(){cleanup} + + setup := &KeycloakSetup{ + IDPName: fmt.Sprintf("keycloak-test-%s", nsName), + Namespace: nsName, + Cleanups: []func(){cleanup}, + } defer func() { if err != nil { - for _, c := range cleanups { + for _, c := range setup.Cleanups { c() } } @@ -119,19 +131,13 @@ func AddKeycloakIDP( transport, err := rest.TransportFor(kubeconfig) require.NoError(t, err) - openshiftIDPName := fmt.Sprintf("keycloak-test-%s", nsName) - keycloakURL := keycloakBaseURL + "/realms/master" + setup.IssuerURL = keycloakURL - // create a keycloak REST client and authenticate to the API - kcClient = KeycloakClientFor(t, transport, keycloakURL, "master") + setup.Client = KeycloakClientFor(t, transport, keycloakURL, "master") - // even though configured via env vars and even though we checked Keycloak reports - // ready on /health/ready, it still appears that we may need some time to log in properly - // In resource-constrained CI environments with parallel test execution, Keycloak can take - // 40-60+ seconds to fully initialize its admin API even after passing readiness probes err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - err := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password") + err := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password") if err != nil { t.Logf("failed to authenticate to Keycloak: %v", err) return false, nil @@ -140,17 +146,16 @@ func AddKeycloakIDP( }) require.NoError(t, err) - clientList, err := kcClient.ListClients() + clientList, err := setup.Client.ListClients() require.NoError(t, err) - var adminClientId, passwdClientId, passwdClientClientId string + var adminClientId, passwdClientId string for _, c := range clientList { if clientID := c["clientId"].(string); clientID == "admin-cli" { adminClientId = c["id"].(string) } else if len(c["redirectUris"].([]interface{})) > 0 { - // just reuse one other client that's already there passwdClientId = c["id"].(string) - passwdClientClientId = clientID + setup.ClientID = clientID } if len(passwdClientId) > 0 && len(adminClientId) > 0 { @@ -158,14 +163,11 @@ func AddKeycloakIDP( } } - // change the client's access token timeout just in case we need it for the test - // Wrap in retry logic as Keycloak may still be unstable after initial authentication err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - err := kcClient.UpdateClientAccessTokenTimeout(adminClientId, 60*30) + err := setup.Client.UpdateClientAccessTokenTimeout(adminClientId, 60*30) if err != nil { t.Logf("failed to update client access token timeout: %v, retrying", err) - // Re-authenticate in case the connection was dropped - if authErr := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { + if authErr := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { t.Logf("failed to re-authenticate: %v", authErr) } return false, nil @@ -174,19 +176,15 @@ func AddKeycloakIDP( }) require.NoError(t, err) - // reauthenticate for a new, longer-lived token - err = kcClient.AuthenticatePassword("admin-cli", "", "admin", "password") + err = setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password") require.NoError(t, err) - // Regenerate client secret with retry logic for Keycloak stability - var clientSecret string err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { var err error - clientSecret, err = kcClient.RegenerateClientSecret(passwdClientId) + setup.ClientSecret, err = setup.Client.RegenerateClientSecret(passwdClientId) if err != nil { t.Logf("failed to regenerate client secret: %v, retrying", err) - // Re-authenticate in case the connection was dropped - if authErr := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { + if authErr := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { t.Logf("failed to re-authenticate: %v", authErr) } return false, nil @@ -195,14 +193,12 @@ func AddKeycloakIDP( }) require.NoError(t, err) - // Create client group mapper with retry logic const groupsClaimName = "groups" err = wait.PollUntilContextTimeout(context.Background(), 5*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { - err := kcClient.CreateClientGroupMapper(passwdClientId, "test-groups-mapper", groupsClaimName) + err := setup.Client.CreateClientGroupMapper(passwdClientId, "test-groups-mapper", groupsClaimName) if err != nil { t.Logf("failed to create client group mapper: %v, retrying", err) - // Re-authenticate in case the connection was dropped - if authErr := kcClient.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { + if authErr := setup.Client.AuthenticatePassword("admin-cli", "", "admin", "password"); authErr != nil { t.Logf("failed to re-authenticate: %v", authErr) } return false, nil @@ -211,22 +207,50 @@ func AddKeycloakIDP( }) require.NoError(t, err) + return setup +} + +// AddKeycloakOIDCIdP registers the Keycloak instance from a KeycloakSetup as an +// OIDC identity provider in OpenShift. When directOIDC is true, secrets and CA +// are created but the IdP is not added to the OAuth config. +func AddKeycloakOIDCIdP(t testing.TB, kubeconfig *rest.Config, setup *KeycloakSetup, directOIDC bool) []func() { + kubeClients, err := kubernetes.NewForConfig(kubeconfig) + require.NoError(t, err) + + configClient, err := configv1client.NewForConfig(kubeconfig) + require.NoError(t, err) + idpCleans, err := addOIDCIDentityProvider(t, kubeClients, configClient, - passwdClientClientId, clientSecret, - openshiftIDPName, - keycloakURL, + setup.ClientID, setup.ClientSecret, + setup.IDPName, + setup.IssuerURL, configv1.OpenIDClaims{ PreferredUsername: []string{"preferred_username"}, - Groups: []configv1.OpenIDClaim{groupsClaimName}, + Groups: []configv1.OpenIDClaim{"groups"}, }, directOIDC, ) - cleanups = append(cleanups, idpCleans...) require.NoError(t, err, "failed to configure the identity provider") - return kcClient, openshiftIDPName, cleanups + return idpCleans +} + +// AddKeycloakIDP deploys Keycloak and registers it as an OIDC IdP in one call. +// This is a convenience wrapper around DeployKeycloak + AddKeycloakOIDCIdP. +func AddKeycloakIDP( + t testing.TB, + kubeconfig *rest.Config, + directOIDC bool, +) (kcClient *KeycloakClient, idpName string, cleanups []func()) { + setup := DeployKeycloak(t, kubeconfig) + cleanups = setup.Cleanups + + idpCleans := AddKeycloakOIDCIdP(t, kubeconfig, setup, directOIDC) + cleanups = append(cleanups, idpCleans...) + + return setup.Client, setup.IDPName, cleanups } type KeycloakClient struct { diff --git a/test/library/proxy.go b/test/library/proxy.go index 3201a742f..c2ecdb275 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -49,9 +49,12 @@ func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clie } } -// DeploySquidProxy deploys a Squid forward proxy in a new namespace. -// Returns the in-cluster proxy URL, namespace name, and cleanup function. -func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyURL string, namespace string, cleanup func()) { +// DeploySquidProxy deploys a Squid forward proxy with TLS enabled. It +// generates a self-signed CA and serving certificate internally. When +// namespace is empty, a new namespace is created. Returns the HTTPS proxy +// URL, the PEM-encoded CA certificate (for use in trustedCA ConfigMaps), +// the namespace name, and a cleanup function. +func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyURL string, caCertPEM []byte, namespace string, cleanup func()) { panic("not implemented") } From 9e94239df4316db8ef77382937a16eef44c5ae44 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Tue, 21 Jul 2026 11:54:22 +0200 Subject: [PATCH 05/21] DeploySquidProxy returns host:port, callers choose http/https scheme DeploySquidProxy now listens on both HTTP and HTTPS and returns the raw host:port. Callers prepend http:// (no trustedCA) or https:// (with trustedCA) to construct the proxy URL. This lets the A1 test cover both cases with a single helper. --- test/e2e-component-proxy/component_proxy.go | 25 +++++++++++++-------- test/library/proxy.go | 12 +++++----- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index 4fccd72f2..da323d423 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -53,15 +53,17 @@ func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyURL, caCertPEM, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + proxyHostPort, caCertPEM, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") proxyCleanup() }) - g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) + var proxyURL string const trustedCAConfigMapName = "e2e-proxy-ca" if withTrustedCA { + proxyURL = "https://" + proxyHostPort + g.By("Creating trustedCA ConfigMap in openshift-config") _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ @@ -77,7 +79,10 @@ func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { g.GinkgoWriter.Println("cleaning up: removing trustedCA ConfigMap") _ = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, trustedCAConfigMapName, metav1.DeleteOptions{}) }) + } else { + proxyURL = "http://" + proxyHostPort } + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) g.By("Deploying Keycloak (without registering IdP yet)") kcSetup := test.DeployKeycloak(t, kubeConfig) @@ -158,11 +163,12 @@ func testFallbackOnProxyRemoval() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyURL, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + proxyHostPort, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") proxyCleanup() }) + proxyURL := "http://" + proxyHostPort g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) g.By("Saving original proxy config and setting component-scoped proxy") @@ -219,8 +225,8 @@ func testDegradedOnBadProxyURL() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Saving original proxy config for cleanup") - operatorAuth, proxyCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) - g.DeferCleanup(proxyCleanup) + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyRestore) g.By("Setting spec.proxy.httpsProxy to an unreachable host") operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ @@ -262,15 +268,16 @@ func testWarningOnUnreachableIdP() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyURL, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + proxyHostPort, _, _, squidCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") - proxyCleanup() + squidCleanup() }) + proxyURL := "http://" + proxyHostPort g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) g.By("Saving original proxy config for cleanup") - operatorAuth, proxyCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) const ( fakeIDPName = "e2e-unreachable-idp" @@ -284,7 +291,7 @@ func testWarningOnUnreachableIdP() { g.GinkgoWriter.Println("cleaning up: deleting fake IdP secret") _ = clients.KubeClient.CoreV1().Secrets("openshift-config").Delete(ctx, fakeIDPSecretName, metav1.DeleteOptions{}) - proxyCleanup() + proxyRestore() }) g.By("Creating fake IdP client secret in openshift-config") diff --git a/test/library/proxy.go b/test/library/proxy.go index c2ecdb275..19b1d69f8 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -49,12 +49,12 @@ func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clie } } -// DeploySquidProxy deploys a Squid forward proxy with TLS enabled. It -// generates a self-signed CA and serving certificate internally. When -// namespace is empty, a new namespace is created. Returns the HTTPS proxy -// URL, the PEM-encoded CA certificate (for use in trustedCA ConfigMaps), -// the namespace name, and a cleanup function. -func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyURL string, caCertPEM []byte, namespace string, cleanup func()) { +// DeploySquidProxy deploys a Squid forward proxy that listens on both plain +// HTTP and HTTPS. It generates a self-signed CA and serving certificate +// internally. Returns the proxy host:port (callers prepend http:// or +// https:// as needed), the PEM-encoded CA certificate (for trustedCA +// ConfigMaps when using https), the namespace name, and a cleanup function. +func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyHostPort string, caCertPEM []byte, namespace string, cleanup func()) { panic("not implemented") } From b3c4d2e4161682f1aa16e6f6e50791a0abc496b1 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Tue, 21 Jul 2026 12:18:20 +0200 Subject: [PATCH 06/21] A2 test: use TLS proxy with trustedCA, verify mount is removed after proxy removal --- test/e2e-component-proxy/component_proxy.go | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index da323d423..cbdaecd6e 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -163,20 +163,38 @@ func testFallbackOnProxyRemoval() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyHostPort, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + proxyHostPort, caCertPEM, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") proxyCleanup() }) - proxyURL := "http://" + proxyHostPort + proxyURL := "https://" + proxyHostPort g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) - g.By("Saving original proxy config and setting component-scoped proxy") + g.By("Creating trustedCA ConfigMap in openshift-config") + const trustedCAConfigMapName = "e2e-proxy-ca" + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: trustedCAConfigMapName, + Labels: test.CAOE2ETestLabels(), + }, + Data: map[string]string{ + "ca-bundle.crt": string(caCertPEM), + }, + }, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing trustedCA ConfigMap") + _ = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, trustedCAConfigMapName, metav1.DeleteOptions{}) + }) + + g.By("Saving original proxy config and setting component-scoped proxy with trustedCA") operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) g.DeferCleanup(proxyRestore) operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ HTTPSProxy: proxyURL, + TrustedCA: operatorv1.AuthenticationConfigMapReference{Name: trustedCAConfigMapName}, } _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) o.Expect(err).NotTo(o.HaveOccurred()) @@ -190,7 +208,7 @@ func testFallbackOnProxyRemoval() { } })) - g.By("Verifying operator is stable with proxy configured") + g.By("Verifying operator is stable with proxy and trustedCA configured") err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") o.Expect(err).NotTo(o.HaveOccurred()) @@ -209,6 +227,9 @@ func testFallbackOnProxyRemoval() { envVars := test.GetOAuthServerProxyEnvVars(t, clients.KubeClient) o.Expect(envVars).NotTo(o.HaveKey("HTTPS_PROXY"), fmt.Sprintf("HTTPS_PROXY should not be set after proxy removal, got env vars: %v", envVars)) + + g.By("Verifying trustedCA volume is no longer mounted on OAuth server deployment") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", "") } func testDegradedOnBadProxyURL() { From 6646c1c577f4a9be1b32c2f9ff619835f91aeb95 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Tue, 21 Jul 2026 12:21:03 +0200 Subject: [PATCH 07/21] C2 test: verify IdP reachability check went through Squid proxy --- test/e2e-component-proxy/component_proxy.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index cbdaecd6e..2dd67c24b 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -289,7 +289,7 @@ func testWarningOnUnreachableIdP() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyHostPort, _, _, squidCleanup := test.DeploySquidProxy(t, clients.KubeClient) + proxyHostPort, _, proxyNamespace, squidCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") squidCleanup() @@ -382,6 +382,10 @@ func testWarningOnUnreachableIdP() { }) o.Expect(err).NotTo(o.HaveOccurred(), "IdPEndpointUnreachable warning event was not emitted") + g.By("Verifying the request went through the Squid proxy") + err = test.WaitForSquidProxyTraffic(t, clients.KubeClient, proxyNamespace, 2*time.Minute) + o.Expect(err).NotTo(o.HaveOccurred()) + g.By("Verifying operator is NOT Degraded") ok, conditions, checkErr := test.CheckClusterOperatorStatus(t, ctx, clients.ConfigClient.ConfigV1(), "authentication", configv1.ClusterOperatorStatusCondition{Type: configv1.OperatorDegraded, Status: configv1.ConditionFalse}, From 33ccd14e231863cbc11ccae7742be675a01a3b11 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Wed, 22 Jul 2026 10:48:58 +0200 Subject: [PATCH 08/21] Make the test suite disruptive --- cmd/cluster-authentication-operator-tests-ext/main.go | 9 +++++---- test/library/keycloakidp.go | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/cluster-authentication-operator-tests-ext/main.go b/cmd/cluster-authentication-operator-tests-ext/main.go index af486fdca..d5d714568 100644 --- a/cmd/cluster-authentication-operator-tests-ext/main.go +++ b/cmd/cluster-authentication-operator-tests-ext/main.go @@ -86,11 +86,12 @@ func prepareOperatorTestsRegistry() (*oteextension.Registry, error) { }, }) - // The following suite runs component-proxy tests that require the - // AuthenticationComponentProxy feature gate (TechPreviewNoUpgrade). + // ClusterStability set to Disruptive: component-proxy tests intentionally + // degrade the authentication operator to validate error handling. extension.AddSuite(oteextension.Suite{ - Name: "openshift/cluster-authentication-operator/component-proxy/serial", - Parallelism: 1, + Name: "openshift/cluster-authentication-operator/component-proxy/disruptive", + Parallelism: 1, + ClusterStability: oteextension.ClusterStabilityDisruptive, Qualifiers: []string{ `name.contains("[ComponentProxy]")`, }, diff --git a/test/library/keycloakidp.go b/test/library/keycloakidp.go index e0a570881..418a14167 100644 --- a/test/library/keycloakidp.go +++ b/test/library/keycloakidp.go @@ -228,7 +228,7 @@ func AddKeycloakOIDCIdP(t testing.TB, kubeconfig *rest.Config, setup *KeycloakSe setup.IssuerURL, configv1.OpenIDClaims{ PreferredUsername: []string{"preferred_username"}, - Groups: []configv1.OpenIDClaim{"groups"}, + Groups: []configv1.OpenIDClaim{"groups"}, }, directOIDC, ) From 744d1ef29b0e22433598010a0ebe73895aeac040 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Wed, 22 Jul 2026 12:39:36 +0200 Subject: [PATCH 09/21] VerifyOAuthServerDeploymentProxyConfig: accept explicit expected values Accept expectedHTTPProxy, expectedHTTPSProxy, expectedNoProxy, and expectTrustedCAVolume so callers specify exactly what to assert. Empty strings mean the env var should be absent. --- test/e2e-component-proxy/component_proxy.go | 10 +++------- test/library/proxy.go | 8 ++++++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index 2dd67c24b..7a1c9ebdf 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -128,12 +128,8 @@ func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") o.Expect(err).NotTo(o.HaveOccurred()) - g.By("Verifying OAuth server deployment state") - trustedCAName := "" - if withTrustedCA { - trustedCAName = trustedCAConfigMapName - } - test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, proxyURL, trustedCAName) + g.By("Verifying OAuth server deployment has proxy env vars and trustedCA volume/mount") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", proxyURL, "", withTrustedCA) if withTrustedCA { g.By("Verifying trustedCA ConfigMap was synced to openshift-authentication") @@ -229,7 +225,7 @@ func testFallbackOnProxyRemoval() { fmt.Sprintf("HTTPS_PROXY should not be set after proxy removal, got env vars: %v", envVars)) g.By("Verifying trustedCA volume is no longer mounted on OAuth server deployment") - test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", "") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", "", "", false) } func testDegradedOnBadProxyURL() { diff --git a/test/library/proxy.go b/test/library/proxy.go index 19b1d69f8..3a8820ad0 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -80,8 +80,12 @@ func WaitForSquidProxyTraffic(t testing.TB, kubeClient kubernetes.Interface, nam panic("not implemented") } -// VerifyOAuthServerDeploymentProxyConfig asserts env vars + volumes/mounts on the OAuth server Deployment. -func VerifyOAuthServerDeploymentProxyConfig(t testing.TB, kubeClient kubernetes.Interface, expectedProxyURL, trustedCAConfigMap string) { +// VerifyOAuthServerDeploymentProxyConfig asserts that the OAuth server +// deployment has the expected proxy env vars and trustedCA volume/mount. +// Empty expected values assert the corresponding env var is absent. +// When expectTrustedCAVolume is true, the v4-0-config-system-auth-proxy-ca +// volume and mount must exist; when false, they must be absent. +func VerifyOAuthServerDeploymentProxyConfig(t testing.TB, kubeClient kubernetes.Interface, expectedHTTPProxy, expectedHTTPSProxy, expectedNoProxy string, expectTrustedCAVolume bool) { panic("not implemented") } From 683713a761d3971993329a45320f818c170290d4 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Wed, 22 Jul 2026 16:41:29 +0200 Subject: [PATCH 10/21] Implement proxy e2e test helpers and clean up test code - Implement DeploySquidProxy, DeployProxyNetworkPolicies, GetSquidProxyLogs, WaitForSquidProxyTraffic, VerifyOAuthServerDeploymentProxyConfig, VerifyTrustedCAConfigMapSynced, and CheckFeatureGateEnabledOrSkip in the test library - Wrap DeploySquidProxy cleanup with sync.OnceFunc to prevent double-cleanup when the internal failure defer and DeferCleanup both fire - Simplify VerifyOAuthServerDeploymentProxyConfig to compare env var values directly (proxy env vars are always set, just empty when unset) - Remove redundant GetOAuthServerProxyEnvVars call in testFallbackOnProxyRemoval --- test/e2e-component-proxy/component_proxy.go | 8 +- test/library/proxy.go | 411 ++++++++++++++++++-- 2 files changed, 388 insertions(+), 31 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index 7a1c9ebdf..ca17e229c 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -3,6 +3,7 @@ package component_proxy import ( "context" "fmt" + "sync" "time" g "github.com/onsi/ginkgo/v2" @@ -219,12 +220,7 @@ func testFallbackOnProxyRemoval() { err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") o.Expect(err).NotTo(o.HaveOccurred()) - g.By("Verifying proxy env vars are no longer set on OAuth server deployment") - envVars := test.GetOAuthServerProxyEnvVars(t, clients.KubeClient) - o.Expect(envVars).NotTo(o.HaveKey("HTTPS_PROXY"), - fmt.Sprintf("HTTPS_PROXY should not be set after proxy removal, got env vars: %v", envVars)) - - g.By("Verifying trustedCA volume is no longer mounted on OAuth server deployment") + g.By("Verifying proxy env vars and trustedCA volume are no longer set on OAuth server deployment") test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", "", "", false) } diff --git a/test/library/proxy.go b/test/library/proxy.go index 3a8820ad0..a4cbfcf81 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -2,16 +2,40 @@ package library import ( "context" + "crypto/x509" + "encoding/pem" + "fmt" + "strings" + "sync" "testing" "time" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + watchtools "k8s.io/client-go/tools/watch" + "k8s.io/utils/ptr" + configv1 "github.com/openshift/api/config/v1" operatorv1 "github.com/openshift/api/operator/v1" configclient "github.com/openshift/client-go/config/clientset/versioned" operatorclient "github.com/openshift/client-go/operator/clientset/versioned" +) - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" +const ( + squidImage = "docker.io/ubuntu/squid:7.2-26.04_edge" + squidHTTPPort = int32(3128) + squidHTTPSPort = int32(3129) + squidServiceName = "squid-proxy" + + componentProxyCAConfigMapName = "v4-0-config-system-auth-proxy-ca" ) // SaveAndRestoreProxyConfig snapshots the current spec.proxy on the operator @@ -50,51 +74,388 @@ func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clie } // DeploySquidProxy deploys a Squid forward proxy that listens on both plain -// HTTP and HTTPS. It generates a self-signed CA and serving certificate -// internally. Returns the proxy host:port (callers prepend http:// or -// https:// as needed), the PEM-encoded CA certificate (for trustedCA -// ConfigMaps when using https), the namespace name, and a cleanup function. +// HTTP (port 3128) and HTTPS (port 3129). It generates a self-signed CA and +// serving certificate internally. Returns the proxy host:port (callers prepend +// http:// or https:// as needed), the PEM-encoded CA certificate (for +// trustedCA ConfigMaps when using https), the namespace name, and a cleanup +// function. func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyHostPort string, caCertPEM []byte, namespace string, cleanup func()) { - panic("not implemented") + ctx := context.TODO() + + namespace = NewTestNamespaceBuilder("e2e-proxy-"). + WithBaselinePSaEnforcement(). + WithLabels(CAOE2ETestLabels()). + Create(t, kubeClient.CoreV1().Namespaces()) + + cleanup = sync.OnceFunc(func() { + if err := kubeClient.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{}); err != nil { + t.Logf("error cleaning up proxy namespace %q: %v", namespace, err) + } + }) + + defer func() { + if t.Failed() { + cleanup() + } + }() + + ca := NewCertificateAuthorityCertificate(t, nil) + serviceDNS := fmt.Sprintf("%s.%s.svc.cluster.local", squidServiceName, namespace) + serverCert := NewServerCertificate(t, ca, serviceDNS) + + caCertPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Certificate.Raw}) + serverCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverCert.Certificate.Raw}) + + serverKeyDER, err := x509.MarshalPKCS8PrivateKey(serverCert.PrivateKey) + if err != nil { + t.Fatalf("failed to marshal server private key: %v", err) + } + serverKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: serverKeyDER}) + + squidConfig := fmt.Sprintf(`http_port %d +https_port %d cert=/etc/squid/tls/tls.crt key=/etc/squid/tls/tls.key +acl all src all +http_access allow all +access_log stdio:/dev/stdout +cache_log stdio:/dev/stderr +cache deny all +buffered_logs off +`, squidHTTPPort, squidHTTPSPort) + + _, err = kubeClient.CoreV1().ConfigMaps(namespace).Create(ctx, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "squid-config"}, + Data: map[string]string{"squid.conf": squidConfig}, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid config: %v", err) + } + + _, err = kubeClient.CoreV1().Secrets(namespace).Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "squid-tls"}, + Data: map[string][]byte{ + "tls.crt": serverCertPEM, + "tls.key": serverKeyPEM, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid TLS secret: %v", err) + } + + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: squidServiceName, + Labels: map[string]string{"app": squidServiceName}, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To(int32(1)), + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": squidServiceName}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": squidServiceName}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "squid", + Image: squidImage, + Ports: []corev1.ContainerPort{ + {ContainerPort: squidHTTPPort, Protocol: corev1.ProtocolTCP}, + {ContainerPort: squidHTTPSPort, Protocol: corev1.ProtocolTCP}, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "squid-config", + MountPath: "/etc/squid/squid.conf", + SubPath: "squid.conf", + }, + { + Name: "squid-tls", + MountPath: "/etc/squid/tls", + ReadOnly: true, + }, + }, + ReadinessProbe: &corev1.Probe{ + ProbeHandler: corev1.ProbeHandler{ + TCPSocket: &corev1.TCPSocketAction{ + Port: intstr.FromInt32(squidHTTPPort), + }, + }, + InitialDelaySeconds: 5, + PeriodSeconds: 5, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "squid-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "squid-config", + }, + }, + }, + }, + { + Name: "squid-tls", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "squid-tls", + }, + }, + }, + }, + }, + }, + }, + } + + _, err = kubeClient.AppsV1().Deployments(namespace).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid deployment: %v", err) + } + + _, err = kubeClient.CoreV1().Services(namespace).Create(ctx, &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: squidServiceName, + Labels: map[string]string{"app": squidServiceName}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": squidServiceName}, + Ports: []corev1.ServicePort{ + { + Name: "http", + Port: squidHTTPPort, + TargetPort: intstr.FromInt32(squidHTTPPort), + Protocol: corev1.ProtocolTCP, + }, + { + Name: "https", + Port: squidHTTPSPort, + TargetPort: intstr.FromInt32(squidHTTPSPort), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create squid service: %v", err) + } + + t.Logf("waiting for squid proxy deployment in %s to be ready", namespace) + timeLimitedCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + _, err = watchtools.UntilWithSync(timeLimitedCtx, + cache.NewListWatchFromClient( + kubeClient.AppsV1().RESTClient(), "deployments", namespace, + fields.OneTermEqualSelector("metadata.name", squidServiceName)), + &appsv1.Deployment{}, + nil, + func(event watch.Event) (bool, error) { + d := event.Object.(*appsv1.Deployment) + return d.Status.ReadyReplicas > 0, nil + }, + ) + if err != nil { + t.Fatalf("squid proxy deployment did not become ready: %v", err) + } + + proxyHostPort = fmt.Sprintf("%s.%s.svc.cluster.local:%d", squidServiceName, namespace, squidHTTPPort) + t.Logf("squid proxy deployed, host:port = %s", proxyHostPort) + return proxyHostPort, caCertPEM, namespace, cleanup } -// DeployProxyNetworkPolicies blocks auth namespaces from reaching Keycloak directly. -// Only allows traffic through the proxy. Returns cleanup function. +// DeployProxyNetworkPolicies creates a NetworkPolicy on the Keycloak namespace +// that restricts ingress to only the proxy namespace. This ensures auth +// components can only reach Keycloak through the proxy. +// +// Note: egress policies on auth namespaces are not created because the +// operator-managed NetworkPolicies already have allow-all egress rules that +// cannot be overridden additively. func DeployProxyNetworkPolicies(t testing.TB, kubeClient kubernetes.Interface, proxyNamespace, keycloakNamespace string) func() { - panic("not implemented") -} + ctx := context.TODO() -// GetOAuthServerProxyEnvVars reads proxy env vars from the oauth-openshift Deployment. -// Returns map with keys: HTTP_PROXY, HTTPS_PROXY, NO_PROXY. -func GetOAuthServerProxyEnvVars(t testing.TB, kubeClient kubernetes.Interface) map[string]string { - panic("not implemented") + keycloakPolicy := &networkingv1.NetworkPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: "proxy-e2e-allow-only-from-proxy", + Namespace: keycloakNamespace, + Labels: CAOE2ETestLabels(), + }, + Spec: networkingv1.NetworkPolicySpec{ + PodSelector: metav1.LabelSelector{}, + PolicyTypes: []networkingv1.PolicyType{networkingv1.PolicyTypeIngress}, + Ingress: []networkingv1.NetworkPolicyIngressRule{ + { + From: []networkingv1.NetworkPolicyPeer{ + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "kubernetes.io/metadata.name": proxyNamespace, + }, + }, + }, + }, + }, + }, + }, + } + + _, err := kubeClient.NetworkingV1().NetworkPolicies(keycloakNamespace).Create(ctx, keycloakPolicy, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create NetworkPolicy in %s: %v", keycloakNamespace, err) + } + t.Logf("created NetworkPolicy proxy-e2e-allow-only-from-proxy in %s", keycloakNamespace) + + return func() { + if err := kubeClient.NetworkingV1().NetworkPolicies(keycloakNamespace).Delete(ctx, "proxy-e2e-allow-only-from-proxy", metav1.DeleteOptions{}); err != nil { + t.Logf("error cleaning up NetworkPolicy in %s: %v", keycloakNamespace, err) + } + } } -// GetSquidProxyLogs reads the Squid proxy pod logs for verifying CONNECT entries. +// GetSquidProxyLogs reads the logs from the Squid proxy pod in the given namespace. func GetSquidProxyLogs(t testing.TB, kubeClient kubernetes.Interface, namespace string) string { - panic("not implemented") + ctx := context.TODO() + + pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("app=%s", squidServiceName), + }) + if err != nil { + t.Fatalf("failed to list squid pods in %s: %v", namespace, err) + } + if len(pods.Items) == 0 { + t.Fatalf("no squid proxy pods found in namespace %s", namespace) + } + + logBytes, err := kubeClient.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{}).DoRaw(ctx) + if err != nil { + t.Fatalf("failed to get squid pod logs: %v", err) + } + + return string(logBytes) } -// WaitForSquidProxyTraffic polls Squid logs until traffic is detected. Returns error on timeout. +// WaitForSquidProxyTraffic polls the Squid proxy logs until it sees CONNECT or +// TCP_ entries, indicating traffic went through the proxy. func WaitForSquidProxyTraffic(t testing.TB, kubeClient kubernetes.Interface, namespace string, timeout time.Duration) error { - panic("not implemented") + t.Logf("waiting up to %s for traffic in squid proxy logs", timeout) + return wait.PollUntilContextTimeout(context.TODO(), 10*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + logs := GetSquidProxyLogs(t, kubeClient, namespace) + if strings.Contains(logs, "CONNECT") || strings.Contains(logs, "TCP_") { + t.Logf("detected proxy traffic in squid logs") + return true, nil + } + return false, nil + }) } // VerifyOAuthServerDeploymentProxyConfig asserts that the OAuth server -// deployment has the expected proxy env vars and trustedCA volume/mount. -// Empty expected values assert the corresponding env var is absent. +// deployment has the expected proxy env var values and trustedCA volume/mount. +// Proxy env vars are always set; pass empty string to assert an unset proxy. // When expectTrustedCAVolume is true, the v4-0-config-system-auth-proxy-ca // volume and mount must exist; when false, they must be absent. func VerifyOAuthServerDeploymentProxyConfig(t testing.TB, kubeClient kubernetes.Interface, expectedHTTPProxy, expectedHTTPSProxy, expectedNoProxy string, expectTrustedCAVolume bool) { - panic("not implemented") + ctx := context.TODO() + + var deployment *appsv1.Deployment + err := wait.PollUntilContextTimeout(ctx, 10*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + var err error + deployment, err = kubeClient.AppsV1().Deployments("openshift-authentication").Get(ctx, "oauth-openshift", metav1.GetOptions{}) + if err != nil { + t.Logf("failed to get oauth-openshift deployment: %v", err) + return false, nil + } + + envVars := make(map[string]string) + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, env := range container.Env { + switch env.Name { + case "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY": + envVars[env.Name] = env.Value + } + } + } + + if envVars["HTTP_PROXY"] != expectedHTTPProxy || + envVars["HTTPS_PROXY"] != expectedHTTPSProxy || + envVars["NO_PROXY"] != expectedNoProxy { + return false, nil + } + + if matchTrustedCAVolume(deployment, expectTrustedCAVolume) { + return true, nil + } + return false, nil + }) + if err != nil { + t.Fatalf("OAuth server deployment proxy config did not match expected values within timeout") + } } -// VerifyTrustedCAConfigMapSynced checks that the trustedCA ConfigMap was synced to openshift-authentication. +func matchTrustedCAVolume(deployment *appsv1.Deployment, expectPresent bool) bool { + foundVolume := false + for _, vol := range deployment.Spec.Template.Spec.Volumes { + if vol.ConfigMap != nil && vol.ConfigMap.Name == componentProxyCAConfigMapName { + foundVolume = true + break + } + } + + foundMount := false + for _, container := range deployment.Spec.Template.Spec.Containers { + for _, mount := range container.VolumeMounts { + if mount.Name == componentProxyCAConfigMapName { + foundMount = true + break + } + } + } + + if expectPresent { + return foundVolume && foundMount + } + return !foundVolume && !foundMount +} + +// VerifyTrustedCAConfigMapSynced checks that the trustedCA ConfigMap has been +// synced to the openshift-authentication namespace under the operator's +// hardcoded name (v4-0-config-system-auth-proxy-ca). func VerifyTrustedCAConfigMapSynced(t testing.TB, kubeClient kubernetes.Interface, configMapName string) { - panic("not implemented") + ctx := context.TODO() + + err := wait.PollUntilContextTimeout(ctx, 10*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + cm, err := kubeClient.CoreV1().ConfigMaps("openshift-authentication").Get(ctx, componentProxyCAConfigMapName, metav1.GetOptions{}) + if err != nil { + return false, nil + } + return len(cm.Data) > 0, nil + }) + if err != nil { + t.Fatalf("trustedCA ConfigMap %s was not synced to openshift-authentication as %s within timeout", configMapName, componentProxyCAConfigMapName) + } } // CheckFeatureGateEnabledOrSkip skips the test if the given feature gate is not enabled. func CheckFeatureGateEnabledOrSkip(t testing.TB, configClient *configclient.Clientset, featureGateName configv1.FeatureGateName) { - panic("not implemented") + ctx := context.TODO() + + featureGates, err := configClient.ConfigV1().FeatureGates().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get feature gates: %v", err) + } + + if len(featureGates.Status.FeatureGates) != 1 { + t.Fatalf("multiple feature gate versions detected — cluster may be upgrading") + } + + for _, gate := range featureGates.Status.FeatureGates[0].Enabled { + if gate.Name == featureGateName { + t.Logf("feature gate %s is enabled", featureGateName) + return + } + } + + t.Skipf("skipping: feature gate %s is not enabled", featureGateName) } From 335baca830d543364b7eafba288240fb42fd1503 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Wed, 22 Jul 2026 22:46:02 +0200 Subject: [PATCH 11/21] DeploySquidProxy: return both HTTP and HTTPS URLs, fix Squid 7 TLS config Return separate httpProxyURL and httpsProxyURL from DeploySquidProxy. Fix Squid 7 TLS syntax (tls-cert=/tls-key= instead of cert=/key=), add pid_filename /tmp/squid.pid for restricted PSA, pin Squid image to 7.2-26.04_edge. --- test/e2e-component-proxy/component_proxy.go | 15 ++++++------ test/library/proxy.go | 26 +++++++++++++-------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index ca17e229c..209a131b8 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -3,7 +3,6 @@ package component_proxy import ( "context" "fmt" - "sync" "time" g "github.com/onsi/ginkgo/v2" @@ -54,7 +53,7 @@ func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyHostPort, caCertPEM, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + httpProxyURL, httpsProxyURL, caCertPEM, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") proxyCleanup() @@ -63,7 +62,7 @@ func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { var proxyURL string const trustedCAConfigMapName = "e2e-proxy-ca" if withTrustedCA { - proxyURL = "https://" + proxyHostPort + proxyURL = httpsProxyURL g.By("Creating trustedCA ConfigMap in openshift-config") _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, &corev1.ConfigMap{ @@ -81,7 +80,7 @@ func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { _ = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, trustedCAConfigMapName, metav1.DeleteOptions{}) }) } else { - proxyURL = "http://" + proxyHostPort + proxyURL = httpProxyURL } g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) @@ -160,12 +159,12 @@ func testFallbackOnProxyRemoval() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyHostPort, caCertPEM, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + _, httpsProxyURL, caCertPEM, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") proxyCleanup() }) - proxyURL := "https://" + proxyHostPort + proxyURL := httpsProxyURL g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) g.By("Creating trustedCA ConfigMap in openshift-config") @@ -281,12 +280,12 @@ func testWarningOnUnreachableIdP() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - proxyHostPort, _, proxyNamespace, squidCleanup := test.DeploySquidProxy(t, clients.KubeClient) + httpProxyURL, _, _, proxyNamespace, squidCleanup := test.DeploySquidProxy(t, clients.KubeClient) g.DeferCleanup(func() { g.GinkgoWriter.Println("cleaning up: removing Squid proxy") squidCleanup() }) - proxyURL := "http://" + proxyHostPort + proxyURL := httpProxyURL g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) g.By("Saving original proxy config for cleanup") diff --git a/test/library/proxy.go b/test/library/proxy.go index a4cbfcf81..f85415d4f 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -75,11 +75,10 @@ func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clie // DeploySquidProxy deploys a Squid forward proxy that listens on both plain // HTTP (port 3128) and HTTPS (port 3129). It generates a self-signed CA and -// serving certificate internally. Returns the proxy host:port (callers prepend -// http:// or https:// as needed), the PEM-encoded CA certificate (for -// trustedCA ConfigMaps when using https), the namespace name, and a cleanup -// function. -func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyHostPort string, caCertPEM []byte, namespace string, cleanup func()) { +// serving certificate internally. Returns the HTTP and HTTPS proxy URLs, +// the PEM-encoded CA certificate (for trustedCA ConfigMaps when using https), +// the namespace name, and a cleanup function. +func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (httpProxyURL, httpsProxyURL string, caCertPEM []byte, namespace string, cleanup func()) { ctx := context.TODO() namespace = NewTestNamespaceBuilder("e2e-proxy-"). @@ -93,8 +92,9 @@ func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyHostP } }) + success := false defer func() { - if t.Failed() { + if !success { cleanup() } }() @@ -113,7 +113,8 @@ func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (proxyHostP serverKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: serverKeyDER}) squidConfig := fmt.Sprintf(`http_port %d -https_port %d cert=/etc/squid/tls/tls.crt key=/etc/squid/tls/tls.key +https_port %d tls-cert=/etc/squid/tls/tls.crt tls-key=/etc/squid/tls/tls.key +pid_filename /tmp/squid.pid acl all src all http_access allow all access_log stdio:/dev/stdout @@ -262,9 +263,14 @@ buffered_logs off t.Fatalf("squid proxy deployment did not become ready: %v", err) } - proxyHostPort = fmt.Sprintf("%s.%s.svc.cluster.local:%d", squidServiceName, namespace, squidHTTPPort) - t.Logf("squid proxy deployed, host:port = %s", proxyHostPort) - return proxyHostPort, caCertPEM, namespace, cleanup + success = true + + serviceHost := fmt.Sprintf("%s.%s.svc.cluster.local", squidServiceName, namespace) + httpProxyURL = fmt.Sprintf("http://%s:%d", serviceHost, squidHTTPPort) + httpsProxyURL = fmt.Sprintf("https://%s:%d", serviceHost, squidHTTPSPort) + success = true + t.Logf("squid proxy deployed: http=%s https=%s", httpProxyURL, httpsProxyURL) + return httpProxyURL, httpsProxyURL, caCertPEM, namespace, cleanup } // DeployProxyNetworkPolicies creates a NetworkPolicy on the Keycloak namespace From eec1d41ba52527b342822bd43f96f5299b4fd47b Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Thu, 23 Jul 2026 14:08:23 +0200 Subject: [PATCH 12/21] Make A1 passing --- test/e2e-component-proxy/component_proxy.go | 2 +- test/library/client.go | 4 + test/library/idpdeployment.go | 20 +++-- test/library/proxy.go | 85 ++++++++++++++++----- 4 files changed, 83 insertions(+), 28 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index 209a131b8..90ca6645f 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -129,7 +129,7 @@ func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Verifying OAuth server deployment has proxy env vars and trustedCA volume/mount") - test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", proxyURL, "", withTrustedCA) + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", proxyURL, ".cluster.local,.svc,127.0.0.1,localhost", withTrustedCA) if withTrustedCA { g.By("Verifying trustedCA ConfigMap was synced to openshift-authentication") diff --git a/test/library/client.go b/test/library/client.go index 2397cb231..c23a41db3 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -40,6 +40,10 @@ func NewClientConfigForTest(t testing.TB) *rest.Config { } require.NoError(t, err) + + config.QPS = 40 + config.Burst = 60 + return config } diff --git a/test/library/idpdeployment.go b/test/library/idpdeployment.go index 7c6e082b4..53369425a 100644 --- a/test/library/idpdeployment.go +++ b/test/library/idpdeployment.go @@ -307,6 +307,15 @@ func addOIDCIDentityProvider( directExternalOIDC bool) ([]func(), error) { var cleanups []func() + success := false + defer func() { + if !success { + for _, c := range cleanups { + c() + } + } + }() + secretName := idpName + "-secret" _, err := kubeClients.CoreV1().Secrets("openshift-config").Create(context.TODO(), &corev1.Secret{ @@ -321,7 +330,7 @@ func addOIDCIDentityProvider( metav1.CreateOptions{}, ) if err != nil { - return cleanups, fmt.Errorf("failed to create keycloak client secret: %v", err) + return nil, fmt.Errorf("failed to create keycloak client secret: %v", err) } cleanups = append(cleanups, func() { if err := kubeClients.CoreV1().Secrets("openshift-config").Delete(context.TODO(), secretName, metav1.DeleteOptions{}); err != nil { @@ -330,7 +339,6 @@ func addOIDCIDentityProvider( }) caCMName := idpName + "-ca" - // configure the default ingress CA as the CA for the IdP in the openshift-config NS cleanups = append(cleanups, SyncDefaultIngressCAToConfig(t, kubeClients.CoreV1(), caCMName)) if !directExternalOIDC { @@ -354,14 +362,14 @@ func addOIDCIDentityProvider( }, }, }) + cleanups = append(cleanups, idpClean...) if err != nil { - return cleanups, fmt.Errorf("failed to add identity provider to oauth server: %v", err) + return nil, fmt.Errorf("failed to add identity provider to oauth server: %v", err) } - - cleanups = append(cleanups, idpClean...) } - return cleanups, err + success = true + return cleanups, nil } func addIdentityProvider(t testing.TB, configClient *configv1client.ConfigV1Client, idp *configv1.IdentityProvider) ([]func(), error) { diff --git a/test/library/proxy.go b/test/library/proxy.go index f85415d4f..89e2323cf 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -52,18 +52,25 @@ func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clie return auth, func() { t.Log("cleaning up: restoring original proxy config") - fresh, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { + fresh, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + if err != nil { + t.Logf("cleanup: failed to get operator auth: %v", err) + return false, nil + } + if originalProxy != nil { + fresh.Spec.Proxy = *originalProxy + } else { + fresh.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + } + if _, err := operatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { + t.Logf("cleanup: failed to update operator auth (will retry): %v", err) + return false, nil + } + return true, nil + }) if err != nil { - t.Logf("cleanup: failed to get operator auth: %v", err) - return - } - if originalProxy != nil { - fresh.Spec.Proxy = *originalProxy - } else { - fresh.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} - } - if _, err := operatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { - t.Logf("cleanup: failed to restore proxy: %v", err) + t.Logf("cleanup: failed to restore proxy config: %v", err) return } t.Log("cleanup: waiting for operator to pick up changes and stabilize") @@ -117,8 +124,8 @@ https_port %d tls-cert=/etc/squid/tls/tls.crt tls-key=/etc/squid/tls/tls.key pid_filename /tmp/squid.pid acl all src all http_access allow all -access_log stdio:/dev/stdout -cache_log stdio:/dev/stderr +access_log /tmp/squid/access.log +cache_log /tmp/squid/cache.log cache deny all buffered_logs off `, squidHTTPPort, squidHTTPSPort) @@ -176,6 +183,10 @@ buffered_logs off MountPath: "/etc/squid/tls", ReadOnly: true, }, + { + Name: "squid-logs", + MountPath: "/tmp/squid", + }, }, ReadinessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ @@ -187,6 +198,17 @@ buffered_logs off PeriodSeconds: 5, }, }, + { + Name: "log", + Image: squidImage, + Command: []string{"tail", "-F", "/tmp/squid/access.log"}, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "squid-logs", + MountPath: "/tmp/squid", + }, + }, + }, }, Volumes: []corev1.Volume{ { @@ -207,6 +229,12 @@ buffered_logs off }, }, }, + { + Name: "squid-logs", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + }, }, }, }, @@ -302,6 +330,13 @@ func DeployProxyNetworkPolicies(t testing.TB, kubeClient kubernetes.Interface, p }, }, }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "policy-group.network.openshift.io/ingress": "", + }, + }, + }, }, }, }, @@ -321,26 +356,30 @@ func DeployProxyNetworkPolicies(t testing.TB, kubeClient kubernetes.Interface, p } } -// GetSquidProxyLogs reads the logs from the Squid proxy pod in the given namespace. -func GetSquidProxyLogs(t testing.TB, kubeClient kubernetes.Interface, namespace string) string { +// GetSquidProxyLogs reads the Squid access log from the proxy pod via +// the log sidecar container that tails the access log file. +func GetSquidProxyLogs(kubeClient kubernetes.Interface, namespace string) (string, error) { ctx := context.TODO() pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ LabelSelector: fmt.Sprintf("app=%s", squidServiceName), }) if err != nil { - t.Fatalf("failed to list squid pods in %s: %v", namespace, err) + return "", fmt.Errorf("failed to list squid pods in %s: %w", namespace, err) } if len(pods.Items) == 0 { - t.Fatalf("no squid proxy pods found in namespace %s", namespace) + return "", fmt.Errorf("no squid proxy pods found in namespace %s", namespace) } - logBytes, err := kubeClient.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{}).DoRaw(ctx) + container := "log" + logBytes, err := kubeClient.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{ + Container: container, + }).DoRaw(ctx) if err != nil { - t.Fatalf("failed to get squid pod logs: %v", err) + return "", fmt.Errorf("failed to get logs from container %s: %w", container, err) } - return string(logBytes) + return string(logBytes), nil } // WaitForSquidProxyTraffic polls the Squid proxy logs until it sees CONNECT or @@ -348,7 +387,11 @@ func GetSquidProxyLogs(t testing.TB, kubeClient kubernetes.Interface, namespace func WaitForSquidProxyTraffic(t testing.TB, kubeClient kubernetes.Interface, namespace string, timeout time.Duration) error { t.Logf("waiting up to %s for traffic in squid proxy logs", timeout) return wait.PollUntilContextTimeout(context.TODO(), 10*time.Second, timeout, true, func(ctx context.Context) (bool, error) { - logs := GetSquidProxyLogs(t, kubeClient, namespace) + logs, err := GetSquidProxyLogs(kubeClient, namespace) + if err != nil { + t.Logf("failed to read squid logs: %v", err) + return false, nil + } if strings.Contains(logs, "CONNECT") || strings.Contains(logs, "TCP_") { t.Logf("detected proxy traffic in squid logs") return true, nil From e4697229a4658bb76a9df60764edf581a0d93079 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Thu, 23 Jul 2026 19:49:54 +0200 Subject: [PATCH 13/21] Align test name --- test/e2e-component-proxy/component_proxy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index 90ca6645f..4926a3e63 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -24,7 +24,7 @@ var _ = g.Describe("[sig-auth] authentication operator", func() { g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy", func() { testOIDCIdPThroughComponentProxy(false) }) - g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy with trustedCAs", func() { + g.It("[Serial][Operator][ComponentProxy] should validate OIDC IdP through component proxy with trustedCA", func() { testOIDCIdPThroughComponentProxy(true) }) g.It("[Serial][Operator][ComponentProxy] should fall back on spec.proxy removal", func() { From 93b9dd07bbe660a3403332139bcee896234f5b73 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Thu, 23 Jul 2026 20:11:37 +0200 Subject: [PATCH 14/21] VerifyOAuthServerDeploymentProxyConfig: check NO_PROXY as superset The operator may add extra entries to NO_PROXY beyond the static set (e.g. the kubernetes service IP from KUBERNETES_SERVICE_HOST). Use a superset check so callers only need to assert the entries they care about. --- test/library/proxy.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/library/proxy.go b/test/library/proxy.go index 89e2323cf..7b3995aa9 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -16,6 +16,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/watch" "k8s.io/client-go/kubernetes" @@ -427,9 +428,14 @@ func VerifyOAuthServerDeploymentProxyConfig(t testing.TB, kubeClient kubernetes. } } - if envVars["HTTP_PROXY"] != expectedHTTPProxy || - envVars["HTTPS_PROXY"] != expectedHTTPSProxy || - envVars["NO_PROXY"] != expectedNoProxy { + if envVars["HTTP_PROXY"] != expectedHTTPProxy || envVars["HTTPS_PROXY"] != expectedHTTPSProxy { + return false, nil + } + // Use superset check: the operator may add extra entries to NO_PROXY beyond + // what the caller specifies (e.g. the kubernetes service IP for KUBERNETES_SERVICE_HOST). + actualNoProxy := sets.New[string](strings.Split(envVars["NO_PROXY"], ",")...) + expectedNoProxyEntries := sets.New[string](strings.Split(expectedNoProxy, ",")...) + if !actualNoProxy.IsSuperset(expectedNoProxyEntries) { return false, nil } From 76dbbb7ad66e9bb6aeb3f74a208ec492c474bcac Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Fri, 24 Jul 2026 10:58:14 +0200 Subject: [PATCH 15/21] Simplify A2 test: use plain HTTP proxy, no trustedCA The fallback-on-removal test only needs to verify the operator recovers after spec.proxy is cleared. Using plain HTTP avoids the trustedCA ConfigMap setup and reduces rollout time. --- test/e2e-component-proxy/component_proxy.go | 42 +++++---------------- test/library/proxy.go | 7 +++- 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index 4926a3e63..c620519f4 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -54,10 +54,7 @@ func testOIDCIdPThroughComponentProxy(withTrustedCA bool) { g.By("Deploying Squid forward proxy") httpProxyURL, httpsProxyURL, caCertPEM, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) - g.DeferCleanup(func() { - g.GinkgoWriter.Println("cleaning up: removing Squid proxy") - proxyCleanup() - }) + g.DeferCleanup(proxyCleanup) var proxyURL string const trustedCAConfigMapName = "e2e-proxy-ca" @@ -159,38 +156,16 @@ func testFallbackOnProxyRemoval() { o.Expect(err).NotTo(o.HaveOccurred()) g.By("Deploying Squid forward proxy") - _, httpsProxyURL, caCertPEM, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) - g.DeferCleanup(func() { - g.GinkgoWriter.Println("cleaning up: removing Squid proxy") - proxyCleanup() - }) - proxyURL := httpsProxyURL - g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) - - g.By("Creating trustedCA ConfigMap in openshift-config") - const trustedCAConfigMapName = "e2e-proxy-ca" - _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: trustedCAConfigMapName, - Labels: test.CAOE2ETestLabels(), - }, - Data: map[string]string{ - "ca-bundle.crt": string(caCertPEM), - }, - }, metav1.CreateOptions{}) - o.Expect(err).NotTo(o.HaveOccurred()) - g.DeferCleanup(func() { - g.GinkgoWriter.Println("cleaning up: removing trustedCA ConfigMap") - _ = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, trustedCAConfigMapName, metav1.DeleteOptions{}) - }) + httpProxyURL, _, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + g.GinkgoWriter.Printf("Squid proxy URL: %s\n", httpProxyURL) - g.By("Saving original proxy config and setting component-scoped proxy with trustedCA") + g.By("Saving original proxy config and setting component-scoped proxy") operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) g.DeferCleanup(proxyRestore) operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ - HTTPSProxy: proxyURL, - TrustedCA: operatorv1.AuthenticationConfigMapReference{Name: trustedCAConfigMapName}, + HTTPSProxy: httpProxyURL, } _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) o.Expect(err).NotTo(o.HaveOccurred()) @@ -204,10 +179,11 @@ func testFallbackOnProxyRemoval() { } })) - g.By("Verifying operator is stable with proxy and trustedCA configured") + g.By("Verifying operator is stable with proxy configured") err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") o.Expect(err).NotTo(o.HaveOccurred()) + // Removing spec.proxy causes the operator to contact Keycloak again. g.By("Removing spec.proxy from Authentication CR") operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) o.Expect(err).NotTo(o.HaveOccurred()) @@ -219,7 +195,7 @@ func testFallbackOnProxyRemoval() { err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") o.Expect(err).NotTo(o.HaveOccurred()) - g.By("Verifying proxy env vars and trustedCA volume are no longer set on OAuth server deployment") + g.By("Verifying proxy env vars are no longer set on OAuth server deployment") test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", "", "", false) } diff --git a/test/library/proxy.go b/test/library/proxy.go index 7b3995aa9..7c9cdeab2 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -10,6 +10,8 @@ import ( "testing" "time" + g "github.com/onsi/ginkgo/v2" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" @@ -51,7 +53,7 @@ func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clie } originalProxy := auth.Spec.Proxy.DeepCopy() - return auth, func() { + return auth, sync.OnceFunc(func() { t.Log("cleaning up: restoring original proxy config") err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { fresh, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) @@ -78,7 +80,7 @@ func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clie if err := WaitForOperatorToPickUpChanges(t, configClient.ConfigV1(), "authentication"); err != nil { t.Logf("cleanup: operator did not recover: %v", err) } - } + }) } // DeploySquidProxy deploys a Squid forward proxy that listens on both plain @@ -95,6 +97,7 @@ func DeploySquidProxy(t testing.TB, kubeClient kubernetes.Interface) (httpProxyU Create(t, kubeClient.CoreV1().Namespaces()) cleanup = sync.OnceFunc(func() { + g.GinkgoWriter.Println("cleaning up: removing Squid proxy") if err := kubeClient.CoreV1().Namespaces().Delete(ctx, namespace, metav1.DeleteOptions{}); err != nil { t.Logf("error cleaning up proxy namespace %q: %v", namespace, err) } From 241a381a58f220fcd3d96546586b535f82d8ba8e Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Fri, 24 Jul 2026 11:44:12 +0200 Subject: [PATCH 16/21] C1 test: start with working proxy before switching to bad URL Deploy Squid, set a working proxy, deploy Keycloak+IdP, and verify stability before switching to an unreachable proxy URL. This tests the transition from a healthy proxy config to a broken one. --- test/e2e-component-proxy/component_proxy.go | 33 +++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index c620519f4..e4ee52073 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -202,6 +202,7 @@ func testFallbackOnProxyRemoval() { func testDegradedOnBadProxyURL() { ctx := context.Background() t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) g.By("Creating test clients") clients := test.NewTestClients(t) @@ -212,11 +213,36 @@ func testDegradedOnBadProxyURL() { err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") o.Expect(err).NotTo(o.HaveOccurred()) - g.By("Saving original proxy config for cleanup") + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Saving original proxy config and setting a working proxy") operatorAuth, proxyRestore := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) g.DeferCleanup(proxyRestore) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Keycloak and adding OIDC IdP") + _, _, keycloakCleanups := test.AddKeycloakIDP(t, kubeConfig, false) + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + g.By("Verifying operator is stable with working proxy") + err = test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + g.By("Setting spec.proxy.httpsProxy to an unreachable host") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ HTTPSProxy: "http://does-not-exist.invalid:3128", } @@ -257,10 +283,7 @@ func testWarningOnUnreachableIdP() { g.By("Deploying Squid forward proxy") httpProxyURL, _, _, proxyNamespace, squidCleanup := test.DeploySquidProxy(t, clients.KubeClient) - g.DeferCleanup(func() { - g.GinkgoWriter.Println("cleaning up: removing Squid proxy") - squidCleanup() - }) + g.DeferCleanup(squidCleanup) proxyURL := httpProxyURL g.GinkgoWriter.Printf("Squid proxy URL: %s\n", proxyURL) From 70968f0fb1781c3def023f03e7c3d89e60c1b65c Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Fri, 24 Jul 2026 11:52:59 +0200 Subject: [PATCH 17/21] Fix cleanup and conflict issues in proxy tests - SaveAndRestoreProxyConfig: skip WaitForOperatorToPickUpChanges when the proxy config already matches the original (avoids waiting for Progressing=True that never comes when the test failed before setting the proxy). - C2 test: re-fetch the operator CR before setting the proxy to avoid conflict errors from intervening operator reconciliation. --- test/e2e-component-proxy/component_proxy.go | 2 ++ test/library/proxy.go | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/test/e2e-component-proxy/component_proxy.go b/test/e2e-component-proxy/component_proxy.go index e4ee52073..4262edc9a 100644 --- a/test/e2e-component-proxy/component_proxy.go +++ b/test/e2e-component-proxy/component_proxy.go @@ -343,6 +343,8 @@ func testWarningOnUnreachableIdP() { g.By("Setting component-scoped proxy pointing to the Squid instance") startTime := time.Now() + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ HTTPSProxy: proxyURL, } diff --git a/test/library/proxy.go b/test/library/proxy.go index 7c9cdeab2..ee2582fef 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -5,6 +5,7 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "reflect" "strings" "sync" "testing" @@ -55,27 +56,36 @@ func SaveAndRestoreProxyConfig(t testing.TB, operatorClient *operatorclient.Clie return auth, sync.OnceFunc(func() { t.Log("cleaning up: restoring original proxy config") + var changed bool err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 30*time.Second, true, func(ctx context.Context) (bool, error) { fresh, err := operatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) if err != nil { t.Logf("cleanup: failed to get operator auth: %v", err) return false, nil } + target := operatorv1.AuthenticationProxyConfig{} if originalProxy != nil { - fresh.Spec.Proxy = *originalProxy - } else { - fresh.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + target = *originalProxy } + if reflect.DeepEqual(fresh.Spec.Proxy, target) { + t.Log("cleanup: proxy config already matches original, no update needed") + return true, nil + } + fresh.Spec.Proxy = target if _, err := operatorClient.OperatorV1().Authentications().Update(ctx, fresh, metav1.UpdateOptions{}); err != nil { t.Logf("cleanup: failed to update operator auth (will retry): %v", err) return false, nil } + changed = true return true, nil }) if err != nil { t.Logf("cleanup: failed to restore proxy config: %v", err) return } + if !changed { + return + } t.Log("cleanup: waiting for operator to pick up changes and stabilize") if err := WaitForOperatorToPickUpChanges(t, configClient.ConfigV1(), "authentication"); err != nil { t.Logf("cleanup: operator did not recover: %v", err) From 938c8c148ffe289300a7e2bc2258fc7460ee7802 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Tue, 28 Jul 2026 13:23:19 +0200 Subject: [PATCH 18/21] Switch squid proxy to RHEL10 image, log to stdout Replace docker.io/ubuntu/squid with registry.redhat.io/rhel10/squid:10.2-1784702318. The RHEL image is unprivileged and defaults pid_filename to /run/squid.pid (unwritable); override it to /tmp/squid.pid. Log directly to stdout/stderr instead of files, removing the need for the log-tailing sidecar container and the squid-logs emptyDir volume. GetSquidProxyLogs now reads from the main squid container. --- test/library/proxy.go | 29 ++++------------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/test/library/proxy.go b/test/library/proxy.go index ee2582fef..6b9b4e013 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -34,7 +34,7 @@ import ( ) const ( - squidImage = "docker.io/ubuntu/squid:7.2-26.04_edge" + squidImage = "registry.redhat.io/rhel10/squid:10.2-1784702318" squidHTTPPort = int32(3128) squidHTTPSPort = int32(3129) squidServiceName = "squid-proxy" @@ -138,8 +138,8 @@ https_port %d tls-cert=/etc/squid/tls/tls.crt tls-key=/etc/squid/tls/tls.key pid_filename /tmp/squid.pid acl all src all http_access allow all -access_log /tmp/squid/access.log -cache_log /tmp/squid/cache.log +access_log stdio:/dev/stdout +cache_log stdio:/dev/stderr cache deny all buffered_logs off `, squidHTTPPort, squidHTTPSPort) @@ -197,10 +197,6 @@ buffered_logs off MountPath: "/etc/squid/tls", ReadOnly: true, }, - { - Name: "squid-logs", - MountPath: "/tmp/squid", - }, }, ReadinessProbe: &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ @@ -212,17 +208,6 @@ buffered_logs off PeriodSeconds: 5, }, }, - { - Name: "log", - Image: squidImage, - Command: []string{"tail", "-F", "/tmp/squid/access.log"}, - VolumeMounts: []corev1.VolumeMount{ - { - Name: "squid-logs", - MountPath: "/tmp/squid", - }, - }, - }, }, Volumes: []corev1.Volume{ { @@ -243,12 +228,6 @@ buffered_logs off }, }, }, - { - Name: "squid-logs", - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - }, }, }, }, @@ -385,7 +364,7 @@ func GetSquidProxyLogs(kubeClient kubernetes.Interface, namespace string) (strin return "", fmt.Errorf("no squid proxy pods found in namespace %s", namespace) } - container := "log" + container := "squid" logBytes, err := kubeClient.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{ Container: container, }).DoRaw(ctx) From d0c943e285072352a71f220199fb052835d8d584 Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Tue, 28 Jul 2026 13:30:42 +0200 Subject: [PATCH 19/21] Add GetSquidProxyLogsSince with time-based filtering Add GetSquidProxyLogsSince(kubeClient, namespace, since time.Time) that sets SinceTime on the log request when since is non-zero. GetSquidProxyLogs becomes a thin wrapper passing a zero time. --- test/library/proxy.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/test/library/proxy.go b/test/library/proxy.go index 6b9b4e013..41eaa4bc0 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -349,9 +349,14 @@ func DeployProxyNetworkPolicies(t testing.TB, kubeClient kubernetes.Interface, p } } -// GetSquidProxyLogs reads the Squid access log from the proxy pod via -// the log sidecar container that tails the access log file. +// GetSquidProxyLogs reads all Squid access log entries from the proxy pod. func GetSquidProxyLogs(kubeClient kubernetes.Interface, namespace string) (string, error) { + return GetSquidProxyLogsSince(kubeClient, namespace, time.Time{}) +} + +// GetSquidProxyLogsSince reads the Squid access log from the proxy pod, +// returning only lines with a timestamp not before since. +func GetSquidProxyLogsSince(kubeClient kubernetes.Interface, namespace string, since time.Time) (string, error) { ctx := context.TODO() pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ @@ -364,12 +369,14 @@ func GetSquidProxyLogs(kubeClient kubernetes.Interface, namespace string) (strin return "", fmt.Errorf("no squid proxy pods found in namespace %s", namespace) } - container := "squid" - logBytes, err := kubeClient.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{ - Container: container, - }).DoRaw(ctx) + logOpts := &corev1.PodLogOptions{Container: "squid"} + if !since.IsZero() { + t := metav1.NewTime(since) + logOpts.SinceTime = &t + } + logBytes, err := kubeClient.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, logOpts).DoRaw(ctx) if err != nil { - return "", fmt.Errorf("failed to get logs from container %s: %w", container, err) + return "", fmt.Errorf("failed to get logs from squid container: %w", err) } return string(logBytes), nil From c27edebda314e8167a86751de8b62b89e0eb86dd Mon Sep 17 00:00:00 2001 From: Ondra Kupka Date: Wed, 29 Jul 2026 00:19:39 +0200 Subject: [PATCH 20/21] Support image mirroring in e2e tests for disconnected environments Add image mapping support so e2e tests can use mirrored images when KUBE_TEST_REPO is set. Introduce test/library/image/image.go with GetMappedImages (adapted from openshift/origin) which rewrites image pull specs to point to a target mirror registry. Initialize keycloakImage and squidImage from this mapping at startup, replacing hardcoded pull spec constants. --- test/library/image/image.go | 65 +++++++++++++++++++++++++++++++++++++ test/library/images.go | 27 +++++++++++++++ test/library/keycloakidp.go | 2 +- test/library/proxy.go | 1 - 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 test/library/image/image.go create mode 100644 test/library/images.go diff --git a/test/library/image/image.go b/test/library/image/image.go new file mode 100644 index 000000000..5e0f65c53 --- /dev/null +++ b/test/library/image/image.go @@ -0,0 +1,65 @@ +package image + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "regexp" + "strings" +) + +// GetMappedImages returns the images if they were mapped to the provided +// image repository. The keys of the returned map are the same as the keys +// in originalImages and the values are the equivalent name in the target +// repo. +// +// This is basically a copy of github.com/openshift/origin/test/extended/util/image/image.go +func GetMappedImages(originalImages map[string]int, repo string) map[string]string { + if len(repo) == 0 { + images := make(map[string]string) + for k := range originalImages { + images[k] = k + } + return images + } + configs := make(map[string]string) + reCharSafe := regexp.MustCompile(`[^\w]`) + reDashes := regexp.MustCompile(`-+`) + h := sha256.New() + + const ( + // length of hash in base64-url chosen to minimize possible collisions (64^16 possible) + hashLength = 16 + // maximum length of a Docker spec image tag + maxTagLength = 127 + // when building a tag, there are at most 6 characters in the format (e2e and 3 dashes), + // and we should allow up to 10 digits for the index and additional qualifiers we may add + // in the future + tagFormatCharacters = 6 + 10 + ) + + parts := strings.SplitN(repo, "/", 2) + registry, destination := parts[0], parts[1] + for pullSpec, index := range originalImages { + // Build a new tag with the index, a hash of the image spec (to be unique) and + // shorten and make the pull spec "safe" so it will fit in the tag + h.Reset() + h.Write([]byte(pullSpec)) + hash := base64.RawURLEncoding.EncodeToString(h.Sum(nil))[:hashLength] + shortName := reCharSafe.ReplaceAllLiteralString(pullSpec, "-") + shortName = reDashes.ReplaceAllLiteralString(shortName, "-") + maxLength := maxTagLength - hashLength - tagFormatCharacters + if len(shortName) > maxLength { + shortName = shortName[len(shortName)-maxLength:] + } + var newTag string + if index == -1 { + newTag = fmt.Sprintf("e2e-%s-%s", shortName, hash) + } else { + newTag = fmt.Sprintf("e2e-%d-%s-%s", index, shortName, hash) + } + + configs[pullSpec] = fmt.Sprintf("%s/%s:%s", registry, destination, newTag) + } + return configs +} diff --git a/test/library/images.go b/test/library/images.go new file mode 100644 index 000000000..d19d1ce1a --- /dev/null +++ b/test/library/images.go @@ -0,0 +1,27 @@ +package library + +import ( + "os" + + "github.com/openshift/cluster-authentication-operator/test/library/image" +) + +var ( + keycloakImage string + squidImage string +) + +func init() { + const ( + keycloakPullSpec = "quay.io/keycloak/keycloak:25.0" + squidPullSpec = "registry.redhat.io/rhel10/squid:10.2-1784702318" + ) + + mappedImages := image.GetMappedImages(map[string]int{ + keycloakPullSpec: -1, + squidPullSpec: -1, + }, os.Getenv("KUBE_TEST_REPO")) + + keycloakImage = mappedImages[keycloakPullSpec] + squidImage = mappedImages[squidPullSpec] +} diff --git a/test/library/keycloakidp.go b/test/library/keycloakidp.go index 418a14167..02f9e02e5 100644 --- a/test/library/keycloakidp.go +++ b/test/library/keycloakidp.go @@ -72,7 +72,7 @@ func DeployKeycloak(t testing.TB, kubeconfig *rest.Config) *KeycloakSetup { nsName, keycloakHost, cleanup := deployPod(t, kubeClients, routeClient, "keycloak", - "quay.io/keycloak/keycloak:25.0", + keycloakImage, []corev1.EnvVar{ {Name: "KEYCLOAK_ADMIN", Value: "admin"}, {Name: "KEYCLOAK_ADMIN_PASSWORD", Value: "password"}, diff --git a/test/library/proxy.go b/test/library/proxy.go index 41eaa4bc0..cf06ed7ee 100644 --- a/test/library/proxy.go +++ b/test/library/proxy.go @@ -34,7 +34,6 @@ import ( ) const ( - squidImage = "registry.redhat.io/rhel10/squid:10.2-1784702318" squidHTTPPort = int32(3128) squidHTTPSPort = int32(3129) squidServiceName = "squid-proxy" From d55db888fb0fbd13cdeec0d588a38574e3e8f7f4 Mon Sep 17 00:00:00 2001 From: Evan Hearne Date: Tue, 28 Jul 2026 16:04:04 +0100 Subject: [PATCH 21/21] add oauth-server e2e tests This commit adds 4 tests to test auth proxy config functionality from oauth-server perspective. `testPartialFullProxyEnvVars()` tests environment variables are present when partial and full auth proxy configs are set. `testProxyOIDCLoginFlow()` ensures that when login flow is attempted and proxy is configured, that traffic goes through the proxy for IdP login. It also ensures that when IdP is configured, and proxy config is configured, the cluster will fall back to either cluster-wide proxy or direct IdP connectivity to perform OIDC login flow. `testTrustedCAHotReload()` configures trustedCA and ensures that oauth-server pods are not redeployed when CA file is changed and login flow still works as expected. `testNoProxy()` configures NoProxy and ensures that login flow does not go through the proxy. Helper method `GetSquidProxyLogsSince()` uses SinceTime to filter logs from squid proxy only after the time specified. This ensures string size does not overload when using logs to verify traffic in tests that require multiple login flow attempts. --- .../component_proxy_oidc_login.go | 565 ++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 test/e2e-component-proxy/component_proxy_oidc_login.go diff --git a/test/e2e-component-proxy/component_proxy_oidc_login.go b/test/e2e-component-proxy/component_proxy_oidc_login.go new file mode 100644 index 000000000..a70c798c8 --- /dev/null +++ b/test/e2e-component-proxy/component_proxy_oidc_login.go @@ -0,0 +1,565 @@ +package component_proxy + +import ( + "context" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/url" + "os/exec" + "testing" + "time" + + g "github.com/onsi/ginkgo/v2" + o "github.com/onsi/gomega" + + authnv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/openshift/api/features" + operatorv1 "github.com/openshift/api/operator/v1" + "github.com/openshift/library-go/pkg/oauth/tokenrequest" + "github.com/openshift/library-go/pkg/oauth/tokenrequest/challengehandlers" + + test "github.com/openshift/cluster-authentication-operator/test/library" +) + +var _ = g.Describe("[sig-auth] authentication operator", func() { + g.It("[Serial][Operator][ComponentProxy] should set partial and full env vars when configured", func() { + testPartialFullProxyEnvVars() + }) + g.It("[Serial][Operator][ComponentProxy] should apply proxy config and perform full OIDC login flow", func() { + testProxyOIDCLoginFlow() + }) + g.It("[Serial][Operator][ComponentProxy] should hot-reload mounted CA file on change when spec.proxy.trustedCA is set", func() { + testTrustedCAHotReload() + }) + g.It("[Serial][Operator][ComponentProxy] should bypass proxy for noProxy hosts", func() { + testNoProxy() + }) +}) + +func testPartialFullProxyEnvVars() { + ctx := context.Background() + t := g.GinkgoTB() + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, httpsProxyURL, caPEM, _, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Saving original proxy config") + operatorAuth, proxyConfigCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyConfigCleanup) + + g.By("Setting only httpsProxy in component proxy config") + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying oauth-server has HTTPS_PROXY but not HTTP_PROXY") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, "", httpProxyURL, ".cluster.local,.svc,127.0.0.1,localhost", false) + + configMapName := "e2e-proxy-trusted-ca" + caConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: "openshift-config", + }, + Data: map[string]string{ + "ca-bundle.crt": string(caPEM), + }, + } + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, caConfigMap, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should be able to create trustedCA ConfigMap") + g.DeferCleanup(func() { + if err := clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, configMapName, metav1.DeleteOptions{}); err != nil { + g.GinkgoWriter.Printf("failed to clean up ConfigMap %s: %v\n", configMapName, err) + } + }) + + noProxyHost := "noproxy.example.com" + + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Setting httpProxy, httpsProxy, noProxy, and trustedCA in component proxy config") + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPProxy: httpProxyURL, + HTTPSProxy: httpsProxyURL, + NoProxy: []string{noProxyHost}, + TrustedCA: operatorv1.AuthenticationConfigMapReference{ + Name: configMapName, + }, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Verifying oauth-server has HTTP_PROXY, HTTPS_PROXY, and NO_PROXY with custom entry") + test.VerifyOAuthServerDeploymentProxyConfig(t, clients.KubeClient, httpProxyURL, httpsProxyURL, ".cluster.local,.svc,127.0.0.1,localhost,noproxy.example.com", true) +} + +func testProxyOIDCLoginFlow() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Deploying Keycloak (without registering IdP yet)") + setup := test.DeployKeycloak(t, kubeConfig) + keycloakCleanups := setup.Cleanups + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + kcClient := setup.Client + + g.By("Enabling Direct Access Grants on Keycloak client for ROPC flow") + enableDirectAccessGrants(kcClient, setup.ClientID) + + g.By("Deploying NetworkPolicy to restrict Keycloak ingress to proxy namespace only") + keycloakNamespace := setup.Namespace + networkPolicyCleanup := test.DeployProxyNetworkPolicies(t, clients.KubeClient, proxyNamespace, keycloakNamespace) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing proxy NetworkPolicies") + networkPolicyCleanup() + }) + + g.By("Saving original proxy config and setting component-scoped proxy") + operatorAuth, proxyConfigCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyConfigCleanup) + + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Registering Keycloak as OIDC IdP (operator discovers it through the proxy)") + idpCleans := test.AddKeycloakOIDCIdP(t, kubeConfig, setup, false) + keycloakCleanups = append(keycloakCleanups, idpCleans...) + + g.By("Creating Keycloak test user and group") + group := "ocp-test-proxy-login-group" + o.Expect(kcClient.CreateGroup(group)).To(o.Succeed()) + + username := "proxy-login-test-user" + password := "proxy-login-test-password" + o.Expect(kcClient.CreateUser(username, "", password, []string{group}, nil)).To(o.Succeed()) + + logCutOff := time.Now() + + g.By("Performing full OIDC login flow through component proxy") + assertOIDCLogin(t, kubeConfig, *clients, username, password, group) + + g.By("Verifying traffic went through the Squid proxy") + + issuerURL, err := url.Parse(kcClient.IssuerURL()) + o.Expect(err).NotTo(o.HaveOccurred()) + keycloakHost := issuerURL.Hostname() + + g.By("Waiting for squid logs to settle before checking for proxy traffic") + time.Sleep(2 * time.Minute) + + logs, err := test.GetSquidProxyLogsSince(clients.KubeClient, proxyNamespace, logCutOff) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(logs).To(o.ContainSubstring(keycloakHost), "squid logs should contain keycloak traffic after proxy login") + + g.By("Creating Keycloak test user and group") + group1 := "ocp-test-direct-fallback-group" + o.Expect(kcClient.CreateGroup(group1)).To(o.Succeed()) + + username1 := "direct-fallback-test-user" + password1 := "direct-fallback-test-password" + o.Expect(kcClient.CreateUser(username1, "", password1, []string{group1}, nil)).To(o.Succeed()) + + g.By("Removing component-scoped proxy config") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{} + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy removal") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + logCutoff := time.Now() + + g.By("Performing OIDC login flow via direct IdP connectivity after proxy removal") + assertOIDCLogin(t, kubeConfig, *clients, username1, password1, group1) + + g.By("Waiting for squid logs to settle before checking for absence of proxy traffic") + time.Sleep(2 * time.Minute) + + postRemovalLogs, err := test.GetSquidProxyLogsSince(clients.KubeClient, proxyNamespace, logCutoff) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(postRemovalLogs).NotTo(o.ContainSubstring(keycloakHost), "squid logs after proxy removal should not contain keycloak connect") +} + +func testTrustedCAHotReload() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + _, httpsProxyURL, caFile, proxyNamespace, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Saving original proxy config") + operatorAuth, proxyConfigCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyConfigCleanup) + + g.By("Deploying Keycloak (without registering IdP yet)") + setup := test.DeployKeycloak(t, kubeConfig) + keycloakCleanups := setup.Cleanups + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + kcClient := setup.Client + + g.By("Enabling Direct Access Grants on Keycloak client for ROPC flow") + enableDirectAccessGrants(kcClient, setup.ClientID) + + g.By("Deploying NetworkPolicy to restrict Keycloak ingress to proxy namespace only") + keycloakNamespace := setup.Namespace + networkPolicyCleanup := test.DeployProxyNetworkPolicies(t, clients.KubeClient, proxyNamespace, keycloakNamespace) + g.DeferCleanup(func() { + g.GinkgoWriter.Println("cleaning up: removing proxy NetworkPolicies") + networkPolicyCleanup() + }) + + g.By("Creating config map with trustedCA") + configMapName := "e2e-proxy-trusted-ca" + caConfigMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: "openshift-config", + }, + Data: map[string]string{ + "ca-bundle.crt": string(caFile), + }, + } + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Create(ctx, caConfigMap, metav1.CreateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should be able to create trustedCA ConfigMap") + g.DeferCleanup(func() { + if err := clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Delete(ctx, configMapName, metav1.DeleteOptions{}); err != nil { + g.GinkgoWriter.Printf("failed to clean up ConfigMap %s: %v\n", configMapName, err) + } + }) + + g.By("Setting component-scoped proxy with trustedCA") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpsProxyURL, + TrustedCA: operatorv1.AuthenticationConfigMapReference{ + Name: configMapName, + }, + } + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config with trustedCA") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Registering Keycloak as OIDC IdP (operator discovers it through the proxy)") + idpCleans := test.AddKeycloakOIDCIdP(t, kubeConfig, setup, false) + keycloakCleanups = append(keycloakCleanups, idpCleans...) + + g.By("Creating Keycloak test user and group") + // Re-authenticate: the admin token from initial setup may have expired + // during the operator reconciliation wait (~15 min). + o.Expect(kcClient.AuthenticatePassword("admin-cli", "", "admin", "password")).To(o.Succeed()) + group := "ocp-test-ca-reload-group" + o.Expect(kcClient.CreateGroup(group)).To(o.Succeed()) + + username := "ca-reload-test-user" + password := "ca-reload-test-password" + o.Expect(kcClient.CreateUser(username, "", password, []string{group}, nil)).To(o.Succeed()) + + logCutOff := time.Now() + + g.By("Verifying OIDC login works after setting proxy with trustedCA") + assertOIDCLogin(t, kubeConfig, *clients, username, password, group) + + g.By("Verifying traffic went through the Squid proxy") + issuerURL, err := url.Parse(kcClient.IssuerURL()) + o.Expect(err).NotTo(o.HaveOccurred()) + keycloakHost := issuerURL.Hostname() + + time.Sleep(2 * time.Minute) + logs, err := test.GetSquidProxyLogsSince(clients.KubeClient, proxyNamespace, logCutOff) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(logs).To(o.ContainSubstring(keycloakHost)) + + g.By("Verifying trustedCA ConfigMap is synced to openshift-authentication namespace") + test.VerifyTrustedCAConfigMapSynced(t, clients.KubeClient, configMapName) + + g.By("Recording oauth-server pod names before CA rotation") + oauthServerPodList, err := clients.KubeClient.CoreV1().Pods("openshift-authentication").List(ctx, metav1.ListOptions{LabelSelector: "app=oauth-openshift"}) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(oauthServerPodList.Items).NotTo(o.BeEmpty()) + + podNamesBefore := sets.New[string]() + for _, pod := range oauthServerPodList.Items { + podNamesBefore.Insert(pod.Name) + } + + g.By("Rotating CA: generating new CA and server cert") + newCA := test.NewCertificateAuthorityCertificate(t, nil) + serviceDNS := fmt.Sprintf("squid-proxy.%s.svc.cluster.local", proxyNamespace) + newServerCert := test.NewServerCertificate(t, newCA, serviceDNS) + + newCACertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: newCA.Certificate.Raw}) + newServerCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: newServerCert.Certificate.Raw}) + newServerKeyDER, err := x509.MarshalPKCS8PrivateKey(newServerCert.PrivateKey) + o.Expect(err).NotTo(o.HaveOccurred()) + newServerKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: newServerKeyDER}) + + g.By("Updating squid-tls Secret with rotated cert") + tlsSecret, err := clients.KubeClient.CoreV1().Secrets(proxyNamespace).Get(ctx, "squid-tls", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + tlsSecret.Data["tls.crt"] = newServerCertPEM + tlsSecret.Data["tls.key"] = newServerKeyPEM + _, err = clients.KubeClient.CoreV1().Secrets(proxyNamespace).Update(ctx, tlsSecret, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Reconfiguring Squid to pick up new cert") + squidPods, err := clients.KubeClient.CoreV1().Pods(proxyNamespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app=squid-proxy", + }) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(squidPods.Items).NotTo(o.BeEmpty()) + + reconfigureCmd := exec.Command("oc", "exec", + "-n", proxyNamespace, + squidPods.Items[0].Name, + "-c", "squid", + "--", "/usr/sbin/squid", "-k", "reconfigure", + ) + output, err := reconfigureCmd.CombinedOutput() + o.Expect(err).NotTo(o.HaveOccurred(), "squid reconfigure failed: %s", string(output)) + + g.By("Updating trustedCA ConfigMap with new CA") + cm, err := clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Get(ctx, configMapName, metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + cm.Data["ca-bundle.crt"] = string(newCACertPEM) + _, err = clients.KubeClient.CoreV1().ConfigMaps("openshift-config").Update(ctx, cm, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + logCutOff = time.Now() + + g.By("Verifying OIDC login works after CA rotation") + assertOIDCLogin(t, kubeConfig, *clients, username, password, group) + + g.By("Verifying traffic went through the Squid proxy") + time.Sleep(2 * time.Minute) + logs, err = test.GetSquidProxyLogsSince(clients.KubeClient, proxyNamespace, logCutOff) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(logs).To(o.ContainSubstring(keycloakHost)) + + g.By("Verifying oauth-server pods were NOT redeployed after CA rotation") + oauthServerPodListAfter, err := clients.KubeClient.CoreV1().Pods("openshift-authentication").List(ctx, metav1.ListOptions{LabelSelector: "app=oauth-openshift"}) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(oauthServerPodListAfter.Items).NotTo(o.BeEmpty()) + + podNamesAfter := sets.New[string]() + for _, pod := range oauthServerPodListAfter.Items { + podNamesAfter.Insert(pod.Name) + } + + o.Expect(podNamesAfter.Equal(podNamesBefore)).To(o.BeTrue(), "oauth-server pods should not have been redeployed after CA file change") +} + +func testNoProxy() { + ctx := context.Background() + t := g.GinkgoTB() + kubeConfig := test.NewClientConfigForTest(t) + clients := test.NewTestClients(t) + + test.CheckFeatureGateEnabledOrSkip(t, clients.ConfigClient, features.FeatureGateAuthenticationComponentProxy) + + g.By("Waiting for authentication operator to be stable before test") + err := test.WaitForClusterOperatorAvailableNotProgressingNotDegraded(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Deploying Squid forward proxy") + httpProxyURL, _, _, proxyNS, proxyCleanup := test.DeploySquidProxy(t, clients.KubeClient) + g.DeferCleanup(proxyCleanup) + + g.By("Saving original proxy config") + operatorAuth, proxyConfigCleanup := test.SaveAndRestoreProxyConfig(t, clients.OperatorClient, clients.ConfigClient) + g.DeferCleanup(proxyConfigCleanup) + + g.By("Deploying Keycloak (without registering IdP yet)") + setup := test.DeployKeycloak(t, kubeConfig) + keycloakCleanups := setup.Cleanups + kcClient := setup.Client + g.DeferCleanup(test.IDPCleanupWrapper(func() { + g.GinkgoWriter.Println("cleaning up: removing Keycloak and IdP") + for _, cleanup := range keycloakCleanups { + cleanup() + } + })) + + g.By("Enabling Direct Access Grants on Keycloak client for ROPC flow") + enableDirectAccessGrants(kcClient, setup.ClientID) + + g.By("Adding OIDC IdP") + idpCleans := test.AddKeycloakOIDCIdP(t, kubeConfig, setup, false) + keycloakCleanups = append(keycloakCleanups, idpCleans...) + + issuerURL, err := url.Parse(kcClient.IssuerURL()) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Setting component-scoped proxy with noProxy") + operatorAuth, err = clients.OperatorClient.OperatorV1().Authentications().Get(ctx, "cluster", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + operatorAuth.Spec.Proxy = operatorv1.AuthenticationProxyConfig{ + HTTPSProxy: httpProxyURL, + NoProxy: []string{issuerURL.Hostname()}, + } + + _, err = clients.OperatorClient.OperatorV1().Authentications().Update(ctx, operatorAuth, metav1.UpdateOptions{}) + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Waiting for operator to reconcile proxy config") + err = test.WaitForOperatorToPickUpChanges(t, clients.ConfigClient.ConfigV1(), "authentication") + o.Expect(err).NotTo(o.HaveOccurred()) + + g.By("Creating Keycloak test user and group") + group := "ocp-test-no-proxy-group" + o.Expect(kcClient.CreateGroup(group)).To(o.Succeed()) + + username := "ca-no-proxy-test-user" + password := "ca-no-proxy-test-password" + o.Expect(kcClient.CreateUser(username, "", password, []string{group}, nil)).To(o.Succeed()) + + g.By("Verifying OIDC login works after setting proxy with noProxy") + assertOIDCLogin(t, kubeConfig, *clients, username, password, group) + + keycloakHost := issuerURL.Hostname() + + g.By("Waiting for squid logs to settle before checking for absence of proxy traffic") + time.Sleep(2 * time.Minute) + + logs, err := test.GetSquidProxyLogs(clients.KubeClient, proxyNS) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(logs).NotTo(o.ContainSubstring(keycloakHost), "squid logs should not contain keycloak connect") +} + +func enableDirectAccessGrants(kcClient *test.KeycloakClient, clientID string) { + g.GinkgoHelper() + kcClientObj, err := kcClient.GetClientByClientID(clientID) + o.Expect(err).NotTo(o.HaveOccurred()) + o.Expect(kcClient.UpdateClientDirectAccessGrantsEnabled(kcClientObj["id"].(string), true)).To(o.Succeed()) +} + +func assertOIDCLogin(t testing.TB, kubeConfig *rest.Config, routeClient test.TestClients, username, password, expectedGroup string) { + g.GinkgoHelper() + ctx := context.Background() + + route, err := routeClient.RouteClient.RouteV1().Routes("openshift-authentication").Get(ctx, "oauth-openshift", metav1.GetOptions{}) + o.Expect(err).NotTo(o.HaveOccurred(), "should be able to get the OAuth server route") + oauthServerURL := fmt.Sprintf("https://%s", route.Spec.Host) + + err = wait.PollUntilContextTimeout(ctx, 10*time.Second, 5*time.Minute, true, func(ctx context.Context) (bool, error) { + tokenOpts := tokenrequest.NewRequestTokenOptions(rest.CopyConfig(kubeConfig), false) + tokenOpts, err := tokenOpts.WithChallengeHandlers( + challengehandlers.NewBasicChallengeHandler(oauthServerURL, "", nil, io.Discard, nil, username, password), + ) + if err != nil { + t.Logf("failed to create challenge handler: %v", err) + return false, nil + } + + token, err := tokenOpts.RequestToken() + if err != nil { + t.Logf("failed to request token: %v", err) + return false, nil + } + if token == "" { + t.Log("received empty token") + return false, nil + } + + tokenConfig := rest.AnonymousClientConfig(kubeConfig) + tokenConfig.BearerToken = token + tokenKubeClient, err := kubernetes.NewForConfig(tokenConfig) + if err != nil { + t.Logf("failed to create kube client with token: %v", err) + return false, nil + } + + ssr, err := tokenKubeClient.AuthenticationV1().SelfSubjectReviews().Create(ctx, &authnv1.SelfSubjectReview{}, metav1.CreateOptions{}) + if err != nil { + t.Logf("failed to create SelfSubjectReview: %v", err) + return false, nil + } + + if ssr.Status.UserInfo.Username == "" { + t.Log("SelfSubjectReview returned empty username") + return false, nil + } + + for _, g := range ssr.Status.UserInfo.Groups { + if g == expectedGroup { + return true, nil + } + } + t.Logf("expected group %q not found in groups: %v", expectedGroup, ssr.Status.UserInfo.Groups) + return false, nil + }) + o.Expect(err).NotTo(o.HaveOccurred(), "OIDC login flow should succeed") +}