diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/mcpexternalauthconfig_controller_integration_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/mcpexternalauthconfig_controller_integration_test.go new file mode 100644 index 000000000..62e85b0cd --- /dev/null +++ b/cmd/thv-operator/test-integration/mcp-external-auth/mcpexternalauthconfig_controller_integration_test.go @@ -0,0 +1,486 @@ +// Package controllers contains integration tests for the MCPExternalAuthConfig controller +package controllers + +import ( + "encoding/json" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" +) + +var _ = Describe("MCPExternalAuthConfig Controller Integration Tests", func() { + const ( + timeout = time.Second * 30 + interval = time.Millisecond * 250 + defaultNamespace = "default" + ) + + Context("When creating an MCPExternalAuthConfig with token exchange", Ordered, func() { + var ( + namespace string + authConfigName string + authConfig *mcpv1alpha1.MCPExternalAuthConfig + oauthSecret *corev1.Secret + oauthSecretName string + ) + + BeforeAll(func() { + namespace = defaultNamespace + authConfigName = "test-external-auth" + oauthSecretName = "oauth-test-secret" + + // Create namespace if it doesn't exist + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + _ = k8sClient.Create(ctx, ns) + + // Create OAuth secret first + oauthSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: oauthSecretName, + Namespace: namespace, + }, + StringData: map[string]string{ + "client-secret": "test-secret-value", + }, + } + Expect(k8sClient.Create(ctx, oauthSecret)).Should(Succeed()) + + // Define the MCPExternalAuthConfig resource + authConfig = &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: authConfigName, + Namespace: namespace, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: "tokenExchange", + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client-id", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: oauthSecretName, + Key: "client-secret", + }, + Audience: "mcp-backend", + Scopes: []string{"read", "write"}, + ExternalTokenHeaderName: "X-Upstream-Token", + }, + }, + } + + // Create the MCPExternalAuthConfig + Expect(k8sClient.Create(ctx, authConfig)).Should(Succeed()) + }) + + AfterAll(func() { + // Clean up resources + Expect(k8sClient.Delete(ctx, authConfig)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, oauthSecret)).Should(Succeed()) + }) + + It("Should calculate and set config hash in status", func() { + // Wait for the status to be updated with the config hash + Eventually(func() bool { + updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: authConfigName, + Namespace: namespace, + }, updatedAuthConfig) + if err != nil { + return false + } + // Check if the config hash is set + return updatedAuthConfig.Status.ConfigHash != "" + }, timeout, interval).Should(BeTrue()) + + // Verify the config hash is not empty + updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: authConfigName, + Namespace: namespace, + }, updatedAuthConfig)).Should(Succeed()) + + Expect(updatedAuthConfig.Status.ConfigHash).NotTo(BeEmpty()) + Expect(updatedAuthConfig.Status.ObservedGeneration).To(Equal(updatedAuthConfig.Generation)) + }) + + It("Should have a finalizer added", func() { + updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: authConfigName, + Namespace: namespace, + }, updatedAuthConfig)).Should(Succeed()) + + Expect(updatedAuthConfig.Finalizers).To(ContainElement("mcpexternalauthconfig.toolhive.stacklok.dev/finalizer")) + }) + }) + + Context("When creating an MCPServer with external auth reference", Ordered, func() { + var ( + namespace string + authConfigName string + authConfig *mcpv1alpha1.MCPExternalAuthConfig + mcpServerName string + mcpServer *mcpv1alpha1.MCPServer + oauthSecret *corev1.Secret + oauthSecretName string + configHash string + ) + + BeforeAll(func() { + namespace = defaultNamespace + authConfigName = "test-external-auth-with-server" + mcpServerName = "external-auth-test-server" + oauthSecretName = "oauth-test-secret-2" + + // Create namespace if it doesn't exist + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + _ = k8sClient.Create(ctx, ns) + + // Create OAuth secret + oauthSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: oauthSecretName, + Namespace: namespace, + }, + StringData: map[string]string{ + "client-secret": "test-secret-value-2", + }, + } + Expect(k8sClient.Create(ctx, oauthSecret)).Should(Succeed()) + + // Create MCPExternalAuthConfig + authConfig = &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: authConfigName, + Namespace: namespace, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: "tokenExchange", + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "test-client-id-2", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: oauthSecretName, + Key: "client-secret", + }, + Audience: "mcp-backend-2", + Scopes: []string{"admin", "user"}, + }, + }, + } + Expect(k8sClient.Create(ctx, authConfig)).Should(Succeed()) + + // Wait for the auth config to have a hash + Eventually(func() bool { + updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: authConfigName, + Namespace: namespace, + }, updatedAuthConfig) + if err != nil { + return false + } + configHash = updatedAuthConfig.Status.ConfigHash + return configHash != "" + }, timeout, interval).Should(BeTrue()) + + // Create MCPServer with external auth reference + mcpServer = &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: mcpServerName, + Namespace: namespace, + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "ghcr.io/stackloklabs/mcp-fetch:latest", + Transport: "stdio", + Port: 8080, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: authConfigName, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) + }) + + AfterAll(func() { + // Clean up resources + Expect(k8sClient.Delete(ctx, mcpServer)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, authConfig)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, oauthSecret)).Should(Succeed()) + }) + + It("Should propagate external auth config hash to MCPServer status", func() { + // Wait for the MCPServer status to be updated with the external auth config hash + Eventually(func() bool { + updatedMCPServer := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: mcpServerName, + Namespace: namespace, + }, updatedMCPServer) + if err != nil { + return false + } + // Check if the external auth config hash matches + return updatedMCPServer.Status.ExternalAuthConfigHash == configHash + }, timeout, interval).Should(BeTrue()) + }) + + // It("Should update MCPExternalAuthConfig status with referencing server", func() { + // // Wait for the auth config status to be updated with the referencing server + // Eventually(func() bool { + // updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + // err := k8sClient.Get(ctx, types.NamespacedName{ + // Name: authConfigName, + // Namespace: namespace, + // }, updatedAuthConfig) + // if err != nil { + // return false + // } + // // Check if the server is in the referencing servers list + // for _, server := range updatedAuthConfig.Status.ReferencingServers { + // if server == mcpServerName { + // return true + // } + // } + // return false + // }, timeout, interval).Should(BeTrue()) + // }) + + It("Should create ConfigMap with token exchange configuration", func() { + // Wait for ConfigMap to be created + configMapName := mcpServerName + "-runconfig" + Eventually(func() bool { + configMap := &corev1.ConfigMap{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: configMapName, + Namespace: namespace, + }, configMap) + return err == nil && configMap.Data["runconfig.json"] != "" + }, timeout, interval).Should(BeTrue()) + + // Get the ConfigMap and verify runconfig content + configMap := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: configMapName, + Namespace: namespace, + }, configMap)).Should(Succeed()) + + // Parse and verify the runconfig.json + runconfigJSON := configMap.Data["runconfig.json"] + Expect(runconfigJSON).NotTo(BeEmpty()) + + var runconfig map[string]interface{} + Expect(json.Unmarshal([]byte(runconfigJSON), &runconfig)).Should(Succeed()) + + // Verify middleware_configs exists + middlewareConfigs, ok := runconfig["middleware_configs"].([]interface{}) + Expect(ok).To(BeTrue(), "middleware_configs should be present in runconfig") + Expect(middlewareConfigs).NotTo(BeEmpty()) + + // Find the tokenexchange middleware + var tokenExchangeConfig map[string]interface{} + for _, middleware := range middlewareConfigs { + m := middleware.(map[string]interface{}) + if m["type"] == "tokenexchange" { + params := m["parameters"].(map[string]interface{}) + tokenExchangeConfig = params["token_exchange_config"].(map[string]interface{}) + break + } + } + + Expect(tokenExchangeConfig).NotTo(BeNil(), "tokenexchange middleware should be present") + + // Verify token exchange configuration fields + Expect(tokenExchangeConfig["token_url"]).To(Equal("https://oauth.example.com/token")) + Expect(tokenExchangeConfig["client_id"]).To(Equal("test-client-id-2")) + Expect(tokenExchangeConfig["audience"]).To(Equal("mcp-backend-2")) + + // Verify scopes array + scopes := tokenExchangeConfig["scopes"].([]interface{}) + Expect(scopes).To(ConsistOf("admin", "user")) + + // Client secret should be empty or not present in the ConfigMap (for security) + if secret, ok := tokenExchangeConfig["client_secret"]; ok { + Expect(secret).To(BeEmpty(), "client_secret should be empty in ConfigMap for security") + } + }) + }) + + Context("When updating an MCPExternalAuthConfig", Ordered, func() { + var ( + namespace string + authConfigName string + authConfig *mcpv1alpha1.MCPExternalAuthConfig + mcpServerName string + mcpServer *mcpv1alpha1.MCPServer + oauthSecret *corev1.Secret + oauthSecretName string + originalHash string + ) + + BeforeAll(func() { + namespace = defaultNamespace + authConfigName = "test-external-auth-update" + mcpServerName = "external-auth-update-server" + oauthSecretName = "oauth-test-secret-update" + + // Create namespace if it doesn't exist + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + }, + } + _ = k8sClient.Create(ctx, ns) + + // Create OAuth secret + oauthSecret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: oauthSecretName, + Namespace: namespace, + }, + StringData: map[string]string{ + "client-secret": "original-secret", + }, + } + Expect(k8sClient.Create(ctx, oauthSecret)).Should(Succeed()) + + // Create MCPExternalAuthConfig + authConfig = &mcpv1alpha1.MCPExternalAuthConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: authConfigName, + Namespace: namespace, + }, + Spec: mcpv1alpha1.MCPExternalAuthConfigSpec{ + Type: "tokenExchange", + TokenExchange: &mcpv1alpha1.TokenExchangeConfig{ + TokenURL: "https://oauth.example.com/token", + ClientID: "original-client-id", + ClientSecretRef: &mcpv1alpha1.SecretKeyRef{ + Name: oauthSecretName, + Key: "client-secret", + }, + Audience: "original-audience", + Scopes: []string{"read"}, + }, + }, + } + Expect(k8sClient.Create(ctx, authConfig)).Should(Succeed()) + + // Wait for the auth config to have a hash + Eventually(func() bool { + updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: authConfigName, + Namespace: namespace, + }, updatedAuthConfig) + if err != nil { + return false + } + originalHash = updatedAuthConfig.Status.ConfigHash + return originalHash != "" + }, timeout, interval).Should(BeTrue()) + + // Create MCPServer with external auth reference + mcpServer = &mcpv1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: mcpServerName, + Namespace: namespace, + }, + Spec: mcpv1alpha1.MCPServerSpec{ + Image: "ghcr.io/stackloklabs/mcp-fetch:latest", + Transport: "stdio", + Port: 8080, + ExternalAuthConfigRef: &mcpv1alpha1.ExternalAuthConfigRef{ + Name: authConfigName, + }, + }, + } + Expect(k8sClient.Create(ctx, mcpServer)).Should(Succeed()) + + // Wait for the MCPServer to have the original hash + Eventually(func() bool { + updatedMCPServer := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: mcpServerName, + Namespace: namespace, + }, updatedMCPServer) + if err != nil { + return false + } + return updatedMCPServer.Status.ExternalAuthConfigHash == originalHash + }, timeout, interval).Should(BeTrue()) + }) + + AfterAll(func() { + // Clean up resources + Expect(k8sClient.Delete(ctx, mcpServer)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, authConfig)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, oauthSecret)).Should(Succeed()) + }) + + It("Should update config hash when auth config is modified", func() { + // Update the auth config + Eventually(func() error { + updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: authConfigName, + Namespace: namespace, + }, updatedAuthConfig); err != nil { + return err + } + + // Modify the audience + updatedAuthConfig.Spec.TokenExchange.Audience = "updated-audience" + return k8sClient.Update(ctx, updatedAuthConfig) + }, timeout, interval).Should(Succeed()) + + // Wait for the config hash to change + var newHash string + Eventually(func() bool { + updatedAuthConfig := &mcpv1alpha1.MCPExternalAuthConfig{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: authConfigName, + Namespace: namespace, + }, updatedAuthConfig) + if err != nil { + return false + } + newHash = updatedAuthConfig.Status.ConfigHash + return newHash != "" && newHash != originalHash + }, timeout, interval).Should(BeTrue()) + + // Verify the new hash is different + Expect(newHash).NotTo(Equal(originalHash)) + }) + + It("Should trigger MCPServer reconciliation with updated hash", func() { + // Wait for the MCPServer to get the updated hash + Eventually(func() bool { + updatedMCPServer := &mcpv1alpha1.MCPServer{} + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: mcpServerName, + Namespace: namespace, + }, updatedMCPServer) + if err != nil { + return false + } + // Check if the hash has been updated + return updatedMCPServer.Status.ExternalAuthConfigHash != originalHash + }, timeout, interval).Should(BeTrue()) + }) + }) +}) diff --git a/cmd/thv-operator/test-integration/mcp-external-auth/suite_test.go b/cmd/thv-operator/test-integration/mcp-external-auth/suite_test.go new file mode 100644 index 000000000..71d69d245 --- /dev/null +++ b/cmd/thv-operator/test-integration/mcp-external-auth/suite_test.go @@ -0,0 +1,121 @@ +// Package controllers contains integration tests for the MCPExternalAuthConfig controller +package controllers + +import ( + "context" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/zap/zapcore" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/cmd/thv-operator/controllers" + ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" +) + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + ctx context.Context + cancel context.CancelFunc +) + +func TestControllers(t *testing.T) { + t.Parallel() + RegisterFailHandler(Fail) + RunSpecs(t, "MCPExternalAuthConfig Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true), zap.Level(zapcore.DebugLevel))) + + ctx, cancel = context.WithCancel(context.TODO()) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crds")}, + ErrorIfCRDPathMissing: true, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = mcpv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // Add other schemes that the controllers use + err = appsv1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + err = corev1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + err = rbacv1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // Start the controller manager + k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + Metrics: metricsserver.Options{ + BindAddress: "0", // Disable metrics server for tests to avoid port conflicts + }, + HealthProbeBindAddress: "0", // Disable health probe for tests + }) + Expect(err).ToNot(HaveOccurred()) + + // Register the MCPExternalAuthConfig controller + err = (&controllers.MCPExternalAuthConfigReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + // Register the MCPServer controller (needed for testing integration) + err = (&controllers.MCPServerReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + PlatformDetector: ctrlutil.NewSharedPlatformDetector(), + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + + // Start the manager in a goroutine + go func() { + defer GinkgoRecover() + err = k8sManager.Start(ctx) + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + }() + +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + // Give it some time to shut down gracefully + time.Sleep(100 * time.Millisecond) + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-pod-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-pod-running.yaml deleted file mode 100644 index 2016eaad9..000000000 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-pod-running.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - namespace: toolhive-system - labels: - app.kubernetes.io/instance: external-auth-test -status: - phase: Running diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-running.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-running.yaml deleted file mode 100644 index 6d5a39d74..000000000 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/assert-mcpserver-running.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPServer -metadata: - name: external-auth-test - namespace: toolhive-system -status: - phase: Running diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/chainsaw-test.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/chainsaw-test.yaml deleted file mode 100644 index e4df6e5fd..000000000 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/chainsaw-test.yaml +++ /dev/null @@ -1,283 +0,0 @@ -apiVersion: chainsaw.kyverno.io/v1alpha1 -kind: Test -metadata: - name: external-auth-configmap-test -spec: - description: Test that external authentication (token exchange) configuration is correctly generated in ConfigMap - steps: - - name: enable-configmap-mode - try: - - script: - content: | - echo "Setting TOOLHIVE_USE_CONFIGMAP=true on operator deployment..." - - # Use strategic merge patch to add the environment variable to existing env array - kubectl patch deployment toolhive-operator -n toolhive-system --type='strategic' -p='{"spec":{"template":{"spec":{"containers":[{"name":"manager","env":[{"name":"TOOLHIVE_USE_CONFIGMAP","value":"true"}]}]}}}}' - - # Wait for rollout to complete - kubectl rollout status deployment/toolhive-operator -n toolhive-system --timeout=60s - - # Verify the environment variable was set - echo "Verifying TOOLHIVE_USE_CONFIGMAP environment variable is set..." - ENV_VAR=$(kubectl get deployment toolhive-operator -n toolhive-system -o jsonpath='{.spec.template.spec.containers[?(@.name=="manager")].env[?(@.name=="TOOLHIVE_USE_CONFIGMAP")].value}') - if [ "$ENV_VAR" = "true" ]; then - echo "✓ TOOLHIVE_USE_CONFIGMAP=true verified on operator deployment" - else - echo "✗ Failed to set TOOLHIVE_USE_CONFIGMAP environment variable" - exit 1 - fi - - # Wait for controller caches to sync after restart - echo "Waiting for controller caches to sync..." - sleep 5 - echo "✓ Controller caches should be synced" - timeout: 120s - - - name: create-oauth-secret - try: - - apply: - file: oauth-secret.yaml - - assert: - resource: - apiVersion: v1 - kind: Secret - metadata: - name: oauth-test-secret - namespace: toolhive-system - - - name: create-external-auth-config - try: - - apply: - file: mcpexternalauthconfig.yaml - - assert: - resource: - apiVersion: toolhive.stacklok.dev/v1alpha1 - kind: MCPExternalAuthConfig - metadata: - name: test-external-auth - namespace: toolhive-system - - - name: verify-external-auth-config-status - try: - - script: - content: | - echo "Verifying MCPExternalAuthConfig status..." - - # Wait for status to be updated with hash (increased attempts for controller cache sync) - for i in $(seq 1 15); do - HASH=$(kubectl get mcpexternalauthconfig test-external-auth -n toolhive-system -o jsonpath='{.status.configHash}' 2>/dev/null || echo "") - if [ -n "$HASH" ]; then - echo "✓ MCPExternalAuthConfig hash is set: $HASH" - break - fi - echo " Waiting for MCPExternalAuthConfig hash... (attempt $i/15)" - sleep 3 - done - - if [ -z "$HASH" ]; then - echo "✗ MCPExternalAuthConfig hash was not set" - kubectl get mcpexternalauthconfig test-external-auth -n toolhive-system -o yaml - exit 1 - fi - - echo "✓ MCPExternalAuthConfig status verified" - timeout: 120s - - - name: create-mcpserver-with-external-auth - try: - - apply: - file: mcpserver-with-external-auth.yaml - - assert: - file: assert-mcpserver-running.yaml - timeout: 120s - - - name: verify-pod-running - try: - - assert: - file: assert-mcpserver-pod-running.yaml - timeout: 120s - - - name: verify-mcpserver-external-auth-hash - try: - - script: - content: | - echo "Verifying MCPServer has external auth config hash..." - - # Get hash from MCPExternalAuthConfig - EXTERNAL_AUTH_HASH=$(kubectl get mcpexternalauthconfig test-external-auth -n toolhive-system -o jsonpath='{.status.configHash}') - echo "MCPExternalAuthConfig hash: $EXTERNAL_AUTH_HASH" - - # Get hash from MCPServer status - MCPSERVER_HASH=$(kubectl get mcpserver external-auth-test -n toolhive-system -o jsonpath='{.status.externalAuthConfigHash}') - echo "MCPServer externalAuthConfigHash: $MCPSERVER_HASH" - - if [ "$EXTERNAL_AUTH_HASH" != "$MCPSERVER_HASH" ]; then - echo "✗ Hash mismatch between MCPExternalAuthConfig and MCPServer" - exit 1 - fi - - echo "✓ MCPServer external auth hash verified" - timeout: 120s - - - name: verify-configmap-external-auth-config - try: - - script: - content: | - echo "Verifying ConfigMap external auth configuration..." - - # Wait for ConfigMap to be created - for i in $(seq 1 10); do - if kubectl get configmap -n toolhive-system -l toolhive.stacklok.io/mcp-server=external-auth-test >/dev/null 2>&1; then - echo "✓ ConfigMap exists" - break - fi - echo " Waiting for ConfigMap... (attempt $i/10)" - sleep 2 - done - - # Get the ConfigMap and extract the runconfig.json - CONFIGMAP_JSON=$(kubectl get configmap -n toolhive-system -l toolhive.stacklok.io/mcp-server=external-auth-test -o jsonpath='{.items[0].data.runconfig\.json}' 2>/dev/null || echo "") - - if [ -z "$CONFIGMAP_JSON" ]; then - echo "✗ ConfigMap does not contain runconfig.json data" - kubectl get configmap -n toolhive-system -l toolhive.stacklok.io/mcp-server=external-auth-test -o yaml - exit 1 - fi - - echo "$CONFIGMAP_JSON" > /tmp/runconfig.json - - # Debug: Show the full ConfigMap content - echo "=== DEBUG: Full runconfig.json content ===" - echo "$CONFIGMAP_JSON" | jq . || echo "$CONFIGMAP_JSON" - echo "=== END DEBUG ===" - - # Verify middleware_configs section is present in runconfig.json - if ! echo "$CONFIGMAP_JSON" | jq -e '.middleware_configs' > /dev/null 2>&1; then - echo "✗ middleware_configs section not found in runconfig.json" - exit 1 - fi - echo "✓ middleware_configs section found" - - # Verify tokenexchange middleware exists - TOKENEXCHANGE_CONFIG=$(echo "$CONFIGMAP_JSON" | jq '.middleware_configs[] | select(.type=="tokenexchange") | .parameters.token_exchange_config') - if [ -z "$TOKENEXCHANGE_CONFIG" ]; then - echo "✗ tokenexchange middleware configuration not found" - exit 1 - fi - echo "✓ tokenexchange middleware configuration found" - - # Verify token URL - TOKEN_URL=$(echo "$TOKENEXCHANGE_CONFIG" | jq -r '.token_url // empty') - if [ "$TOKEN_URL" != "https://oauth.example.com/token" ]; then - echo "✗ Token URL mismatch. Expected: 'https://oauth.example.com/token', Got: '$TOKEN_URL'" - exit 1 - fi - echo "✓ Token URL verified" - - # Verify client ID - CLIENT_ID=$(echo "$TOKENEXCHANGE_CONFIG" | jq -r '.client_id // empty') - if [ "$CLIENT_ID" != "test-client-id" ]; then - echo "✗ Client ID mismatch. Expected: 'test-client-id', Got: '$CLIENT_ID'" - exit 1 - fi - echo "✓ Client ID verified" - - # Verify audience - AUDIENCE=$(echo "$TOKENEXCHANGE_CONFIG" | jq -r '.audience // empty') - if [ "$AUDIENCE" != "mcp-backend" ]; then - echo "✗ Audience mismatch. Expected: 'mcp-backend', Got: '$AUDIENCE'" - exit 1 - fi - echo "✓ Audience verified" - - # Verify scopes array contains "read" and "write" - SCOPES=$(echo "$TOKENEXCHANGE_CONFIG" | jq -r '.scopes | join(" ")') - if [ "$SCOPES" != "read write" ]; then - echo "✗ Scopes mismatch. Expected: 'read write', Got: '$SCOPES'" - exit 1 - fi - echo "✓ Scopes verified" - - # Verify external token header name - EXT_HEADER=$(echo "$TOKENEXCHANGE_CONFIG" | jq -r '.external_token_header_name // empty') - if [ "$EXT_HEADER" != "X-Upstream-Token" ]; then - echo "✗ External token header name mismatch. Expected: 'X-Upstream-Token', Got: '$EXT_HEADER'" - exit 1 - fi - echo "✓ External token header name verified" - - # Verify header strategy is "custom" - HEADER_STRATEGY=$(echo "$TOKENEXCHANGE_CONFIG" | jq -r '.header_strategy // empty') - if [ "$HEADER_STRATEGY" != "custom" ]; then - echo "✗ Header strategy mismatch. Expected: 'custom', Got: '$HEADER_STRATEGY'" - exit 1 - fi - echo "✓ Header strategy verified" - - # Verify client secret is NOT in ConfigMap (should be env var) - CLIENT_SECRET=$(echo "$TOKENEXCHANGE_CONFIG" | jq -r '.client_secret // empty') - if [ -n "$CLIENT_SECRET" ]; then - echo "✗ Client secret should not be in ConfigMap (should be environment variable)" - exit 1 - fi - echo "✓ Client secret not in ConfigMap (as expected)" - - echo "✓ All external auth configuration validations passed!" - timeout: 120s - - - name: verify-deployment-env-var - try: - - script: - content: | - echo "Verifying TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable..." - - # Get deployment by name (deployment name matches MCPServer name) - DEPLOYMENT_NAME="external-auth-test" - - # Verify deployment exists - if ! kubectl get deployment "$DEPLOYMENT_NAME" -n toolhive-system >/dev/null 2>&1; then - echo "✗ Deployment not found: $DEPLOYMENT_NAME" - exit 1 - fi - echo "Found deployment: $DEPLOYMENT_NAME" - - # Check for TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET env var - ENV_VAR=$(kubectl get deployment "$DEPLOYMENT_NAME" -n toolhive-system -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET")]}') - - if [ -z "$ENV_VAR" ]; then - echo "✗ TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable not found" - kubectl get deployment "$DEPLOYMENT_NAME" -n toolhive-system -o yaml - exit 1 - fi - - # Verify it's sourced from secret - SECRET_REF=$(echo "$ENV_VAR" | jq -r '.valueFrom.secretKeyRef.name // empty') - if [ "$SECRET_REF" != "oauth-test-secret" ]; then - echo "✗ Environment variable not sourced from correct secret. Expected: 'oauth-test-secret', Got: '$SECRET_REF'" - exit 1 - fi - - SECRET_KEY=$(echo "$ENV_VAR" | jq -r '.valueFrom.secretKeyRef.key // empty') - if [ "$SECRET_KEY" != "client-secret" ]; then - echo "✗ Environment variable not sourced from correct key. Expected: 'client-secret', Got: '$SECRET_KEY'" - exit 1 - fi - - echo "✓ TOOLHIVE_TOKEN_EXCHANGE_CLIENT_SECRET environment variable verified" - timeout: 120s - - - name: cleanup-configmap-mode - try: - - script: - content: | - echo "Cleaning up ConfigMap mode..." - - # Wait for ConfigMap to be deleted - kubectl wait --for=delete configmap -l toolhive.stacklok.io/mcp-server=external-auth-test -n toolhive-system --timeout=60s || true - - # Disable ConfigMap mode to avoid affecting subsequent tests - echo "Disabling ConfigMap mode..." - kubectl patch deployment toolhive-operator -n toolhive-system --type='strategic' -p='{"spec":{"template":{"spec":{"containers":[{"name":"manager","env":[{"name":"TOOLHIVE_USE_CONFIGMAP","value":"false"}]}]}}}}' - kubectl rollout status deployment/toolhive-operator -n toolhive-system --timeout=60s - echo "✓ ConfigMap mode cleanup completed" - timeout: 120s diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpexternalauthconfig.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpexternalauthconfig.yaml deleted file mode 100644 index 2e6be6e42..000000000 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpexternalauthconfig.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPExternalAuthConfig -metadata: - name: test-external-auth - namespace: toolhive-system -spec: - type: tokenExchange - tokenExchange: - tokenUrl: https://oauth.example.com/token - clientId: test-client-id - clientSecretRef: - name: oauth-test-secret - key: client-secret - audience: mcp-backend - scopes: - - read - - write - externalTokenHeaderName: "X-Upstream-Token" diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpserver-with-external-auth.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpserver-with-external-auth.yaml deleted file mode 100644 index b98919981..000000000 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/mcpserver-with-external-auth.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPServer -metadata: - name: external-auth-test - namespace: toolhive-system -spec: - image: ghcr.io/stackloklabs/yardstick/yardstick-server:0.0.2 - transport: stdio - proxyPort: 8080 - - # Reference to external authentication configuration - externalAuthConfigRef: - name: test-external-auth - - permissionProfile: - type: builtin - name: network - resources: - limits: - cpu: "100m" - memory: "128Mi" - requests: - cpu: "50m" - memory: "64Mi" diff --git a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/oauth-secret.yaml b/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/oauth-secret.yaml deleted file mode 100644 index 1442e25d9..000000000 --- a/test/e2e/chainsaw/operator/single-tenancy/test-scenarios/external-auth-configmap/oauth-secret.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: oauth-test-secret - namespace: toolhive-system -type: Opaque -stringData: - client-secret: "test-secret-value"