using EntKube.Clusters.Domain; using EntKube.Clusters.Features.AdoptCluster; using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent; using EntKube.Clusters.Features.AdoptCluster.DeployComponent; using EntKube.Clusters.Infrastructure; using EntKube.SharedKernel.Domain; using FluentAssertions; using Moq; namespace EntKube.Clusters.Tests.Features; /// /// Tests for the Keycloak identity provider component. Keycloak provides: /// /// 1. **Multi-realm identity management** — each tenant or environment gets its own realm /// 2. **BankID identity provider** — Swedish BankID as a login option per realm /// 3. **Custom theming** — per-realm logos, colors, and backgrounds /// 4. **Federation** — SAML, OIDC, and social providers per realm /// /// Keycloak is deployed via the Bitnami-free official Keycloak Operator or Helm chart. /// Realms, providers, and themes are configured post-install via the Keycloak Admin API. /// public class KeycloakTests { private readonly InMemoryClusterRepository repository; private readonly Mock keycloakInstaller; public KeycloakTests() { repository = new InMemoryClusterRepository(); keycloakInstaller = new Mock(); keycloakInstaller.Setup(i => i.ComponentName).Returns("keycloak"); } // ─── Keycloak Installation ──────────────────────────────────────────────── [Fact] public async Task Keycloak_Install_DeploysWithOperatorAndDefaultRealm() { // Arrange — Deploy Keycloak with its operator and a default master realm. // The operator manages Keycloak instances via CRDs (Keycloak, KeycloakRealm). KubernetesCluster cluster = CreateConnectedCluster(); await repository.AddAsync(cluster); keycloakInstaller .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) .ReturnsAsync(new InstallResult( Success: true, ComponentName: "keycloak", Message: "Keycloak 26.0 installed with operator", Actions: new List { "Ensured namespace 'keycloak'", "Helm install keycloak-operator v26.0.0", "Created Keycloak CR 'platform-keycloak'", "Keycloak available at auth.internal.corp.com", "BankID provider plugin deployed" })); DeployComponentHandler handler = new(repository, new List { keycloakInstaller.Object }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); DeployComponentRequest request = new("keycloak", Version: "26.0.0", Parameters: new Dictionary { ["keycloakDomain"] = "auth.internal.corp.com", ["adminPassword"] = "admin123", ["storageClass"] = "longhorn", ["databaseType"] = "postgres", ["installBankIdPlugin"] = "true" }); // Act Result result = await handler.HandleAsync(cluster.Id, request); // Assert — Keycloak operator and instance deployed. result.IsSuccess.Should().BeTrue(); result.Value!.Actions.Should().Contain(a => a.Contains("keycloak-operator")); result.Value.Actions.Should().Contain(a => a.Contains("Keycloak CR")); result.Value.Actions.Should().Contain(a => a.Contains("BankID")); keycloakInstaller.Verify(i => i.InstallAsync( cluster, It.Is(o => o.Parameters!["keycloakDomain"] == "auth.internal.corp.com" && o.Parameters["installBankIdPlugin"] == "true"), It.IsAny()), Times.Once); } [Fact] public async Task Keycloak_Install_WithoutDatabase_ReportsFailure() { // Arrange — Keycloak requires a database (internal or CloudNativePG). // If no database is available, the install should fail gracefully. KubernetesCluster cluster = CreateConnectedCluster(); await repository.AddAsync(cluster); keycloakInstaller .Setup(i => i.InstallAsync(cluster, It.IsAny(), It.IsAny())) .ReturnsAsync(new InstallResult( Success: false, ComponentName: "keycloak", Message: "Keycloak requires a PostgreSQL database (CloudNativePG or external)", Actions: new List { "Prerequisite check failed: no database available" })); DeployComponentHandler handler = new(repository, new List { keycloakInstaller.Object }, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); DeployComponentRequest request = new("keycloak", Parameters: new Dictionary { ["keycloakDomain"] = "auth.internal.corp.com" }); // Act Result result = await handler.HandleAsync(cluster.Id, request); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Contain("database"); } // ─── Realm Management ───────────────────────────────────────────────────── [Fact] public async Task Keycloak_Configure_CreateRealm_WithCustomTheme() { // Arrange — Create a new realm with a custom name, logo, background // image, and brand colors. The realm is fully isolated and can have // its own identity providers, users, and theme. KubernetesCluster cluster = CreateConnectedCluster(); await repository.AddAsync(cluster); keycloakInstaller .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) .ReturnsAsync(new InstallResult( Success: true, ComponentName: "keycloak", Message: "Realm 'tenant-alpha' created with custom theme", Actions: new List { "Created realm 'tenant-alpha'", "Set realm display name to 'Alpha Corp'", "Deployed custom theme 'alpha-theme'", "Theme: logo uploaded, primary color #1A73E8, background set", "Realm login page configured with custom branding" })); List installers = new() { keycloakInstaller.Object }; ConfigureComponentHandler configHandler = new(repository, installers); ConfigureComponentRequest configRequest = new( ComponentName: "keycloak", Values: new Dictionary { ["createRealm"] = "tenant-alpha", ["realmDisplayName"] = "Alpha Corp", ["themeLogo"] = "https://cdn.alpha.com/logo.png", ["themeBackground"] = "https://cdn.alpha.com/bg.jpg", ["themePrimaryColor"] = "#1A73E8", ["themeSecondaryColor"] = "#FFFFFF", ["themeLoginTitle"] = "Welcome to Alpha Corp" }); // Act Result result = await configHandler.HandleAsync(cluster.Id, configRequest); // Assert — Realm created with full theming. result.IsSuccess.Should().BeTrue(); result.Value!.Actions.Should().Contain(a => a.Contains("realm 'tenant-alpha'")); result.Value.Actions.Should().Contain(a => a.Contains("Alpha Corp")); result.Value.Actions.Should().Contain(a => a.Contains("theme")); result.Value.Actions.Should().Contain(a => a.Contains("#1A73E8")); } [Fact] public async Task Keycloak_Configure_AddBankIdProvider_ToRealm() { // Arrange — Add a Swedish BankID identity provider to a specific realm. // BankID requires a service certificate (P12) and API endpoint configuration. KubernetesCluster cluster = CreateConnectedCluster(); await repository.AddAsync(cluster); keycloakInstaller .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) .ReturnsAsync(new InstallResult( Success: true, ComponentName: "keycloak", Message: "BankID provider added to realm 'tenant-alpha'", Actions: new List { "Configured BankID identity provider in realm 'tenant-alpha'", "Stored BankID service certificate in Keycloak keystore", "BankID endpoint: https://appapi2.bankid.com/rp/v6.0", "BankID provider enabled as login option" })); List installers = new() { keycloakInstaller.Object }; ConfigureComponentHandler configHandler = new(repository, installers); ConfigureComponentRequest configRequest = new( ComponentName: "keycloak", Values: new Dictionary { ["realmName"] = "tenant-alpha", ["addProvider"] = "bankid", ["bankIdEnvironment"] = "production", ["bankIdCertificateP12"] = "MIIEvgIBADANBg...", // base64 P12 ["bankIdCertificatePassword"] = "cert-password", ["bankIdDisplayName"] = "Logga in med BankID" }); // Act Result result = await configHandler.HandleAsync(cluster.Id, configRequest); // Assert — BankID configured as identity provider. result.IsSuccess.Should().BeTrue(); result.Value!.Actions.Should().Contain(a => a.Contains("BankID") && a.Contains("tenant-alpha")); result.Value.Actions.Should().Contain(a => a.Contains("certificate")); result.Value.Actions.Should().Contain(a => a.Contains("appapi2.bankid.com")); } [Fact] public async Task Keycloak_Configure_AddOIDCProvider_ToRealm() { // Arrange — Add a generic OIDC identity provider (e.g., Azure AD, Google) // to a realm, independently from other realms. KubernetesCluster cluster = CreateConnectedCluster(); await repository.AddAsync(cluster); keycloakInstaller .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) .ReturnsAsync(new InstallResult( Success: true, ComponentName: "keycloak", Message: "OIDC provider 'azure-ad' added to realm 'tenant-beta'", Actions: new List { "Configured OIDC identity provider 'azure-ad' in realm 'tenant-beta'", "Discovery URL: https://login.microsoftonline.com/{tenant}/.well-known/openid-configuration", "Client ID configured", "Provider enabled as login option" })); List installers = new() { keycloakInstaller.Object }; ConfigureComponentHandler configHandler = new(repository, installers); ConfigureComponentRequest configRequest = new( ComponentName: "keycloak", Values: new Dictionary { ["realmName"] = "tenant-beta", ["addProvider"] = "oidc", ["providerAlias"] = "azure-ad", ["providerDisplayName"] = "Sign in with Microsoft", ["oidcDiscoveryUrl"] = "https://login.microsoftonline.com/abc123/.well-known/openid-configuration", ["oidcClientId"] = "client-id-here", ["oidcClientSecret"] = "client-secret-here" }); // Act Result result = await configHandler.HandleAsync(cluster.Id, configRequest); // Assert — OIDC provider configured independently in tenant-beta. result.IsSuccess.Should().BeTrue(); result.Value!.Actions.Should().Contain(a => a.Contains("azure-ad") && a.Contains("tenant-beta")); result.Value.Actions.Should().Contain(a => a.Contains("Discovery URL")); } [Fact] public async Task Keycloak_Configure_AddSAMLProvider_ToRealm() { // Arrange — Add a SAML identity provider to a realm. Useful for // enterprise customers with ADFS or other SAML-based IdPs. KubernetesCluster cluster = CreateConnectedCluster(); await repository.AddAsync(cluster); keycloakInstaller .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) .ReturnsAsync(new InstallResult( Success: true, ComponentName: "keycloak", Message: "SAML provider 'corp-adfs' added to realm 'tenant-gamma'", Actions: new List { "Configured SAML identity provider 'corp-adfs' in realm 'tenant-gamma'", "SAML entity ID: https://adfs.corp.com/adfs/services/trust", "SSO URL configured", "Provider enabled as login option" })); List installers = new() { keycloakInstaller.Object }; ConfigureComponentHandler configHandler = new(repository, installers); ConfigureComponentRequest configRequest = new( ComponentName: "keycloak", Values: new Dictionary { ["realmName"] = "tenant-gamma", ["addProvider"] = "saml", ["providerAlias"] = "corp-adfs", ["providerDisplayName"] = "Enterprise SSO", ["samlEntityId"] = "https://adfs.corp.com/adfs/services/trust", ["samlSsoUrl"] = "https://adfs.corp.com/adfs/ls/", ["samlCertificate"] = "MIIDXTCCAkWgAwIBAgI..." }); // Act Result result = await configHandler.HandleAsync(cluster.Id, configRequest); // Assert — SAML provider configured. result.IsSuccess.Should().BeTrue(); result.Value!.Actions.Should().Contain(a => a.Contains("SAML") && a.Contains("tenant-gamma")); result.Value.Actions.Should().Contain(a => a.Contains("entity ID")); } [Fact] public async Task Keycloak_Configure_UpdateRealmTheme() { // Arrange — Update the theme of an existing realm. This changes logo, // colors, and background without recreating the realm. KubernetesCluster cluster = CreateConnectedCluster(); await repository.AddAsync(cluster); keycloakInstaller .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) .ReturnsAsync(new InstallResult( Success: true, ComponentName: "keycloak", Message: "Theme updated for realm 'tenant-alpha'", Actions: new List { "Updated theme for realm 'tenant-alpha'", "Logo updated to new URL", "Primary color changed to #FF5722", "Background image updated" })); List installers = new() { keycloakInstaller.Object }; ConfigureComponentHandler configHandler = new(repository, installers); ConfigureComponentRequest configRequest = new( ComponentName: "keycloak", Values: new Dictionary { ["realmName"] = "tenant-alpha", ["updateTheme"] = "true", ["themeLogo"] = "https://cdn.alpha.com/new-logo.png", ["themeBackground"] = "https://cdn.alpha.com/new-bg.jpg", ["themePrimaryColor"] = "#FF5722" }); // Act Result result = await configHandler.HandleAsync(cluster.Id, configRequest); // Assert — Theme updated without affecting realm config. result.IsSuccess.Should().BeTrue(); result.Value!.Actions.Should().Contain(a => a.Contains("Updated theme")); result.Value.Actions.Should().Contain(a => a.Contains("#FF5722")); } [Fact] public async Task Keycloak_Configure_MultipleProviders_DifferentRealms() { // Arrange — Verify that providers are realm-scoped. Adding BankID to // tenant-alpha does NOT affect tenant-beta which has its own OIDC provider. // This test verifies the realm isolation. KubernetesCluster cluster = CreateConnectedCluster(); await repository.AddAsync(cluster); keycloakInstaller .Setup(i => i.ConfigureAsync(cluster, It.IsAny(), It.IsAny())) .ReturnsAsync((KubernetesCluster c, ComponentConfiguration cfg, CancellationToken _) => { string realm = cfg.Values["realmName"]; string provider = cfg.Values["addProvider"]; return new InstallResult( Success: true, ComponentName: "keycloak", Message: $"Provider '{provider}' added to realm '{realm}'", Actions: new List { $"Configured {provider} provider in realm '{realm}' only", $"Other realms not affected" }); }); List installers = new() { keycloakInstaller.Object }; ConfigureComponentHandler configHandler = new(repository, installers); // Act — Add BankID to tenant-alpha. ConfigureComponentRequest request1 = new( ComponentName: "keycloak", Values: new Dictionary { ["realmName"] = "tenant-alpha", ["addProvider"] = "bankid", ["bankIdEnvironment"] = "test" }); Result result1 = await configHandler.HandleAsync(cluster.Id, request1); // Act — Add OIDC to tenant-beta. ConfigureComponentRequest request2 = new( ComponentName: "keycloak", Values: new Dictionary { ["realmName"] = "tenant-beta", ["addProvider"] = "oidc", ["providerAlias"] = "google", ["oidcDiscoveryUrl"] = "https://accounts.google.com/.well-known/openid-configuration", ["oidcClientId"] = "google-client-id", ["oidcClientSecret"] = "google-secret" }); Result result2 = await configHandler.HandleAsync(cluster.Id, request2); // Assert — Each realm got its own provider independently. result1.IsSuccess.Should().BeTrue(); result1.Value!.Actions.Should().Contain(a => a.Contains("tenant-alpha") && a.Contains("only")); result2.IsSuccess.Should().BeTrue(); result2.Value!.Actions.Should().Contain(a => a.Contains("tenant-beta") && a.Contains("only")); } // ─── Keycloak Adoption Check ────────────────────────────────────────────── [Fact] public async Task Keycloak_Check_DetectsExistingDeployment() { // Arrange — Keycloak is already running. The check discovers the // instance, its realms, and configured providers. KubernetesCluster cluster = CreateConnectedCluster(); await repository.AddAsync(cluster); Mock keycloakCheck = new(); keycloakCheck.Setup(c => c.ComponentName).Returns("keycloak"); keycloakCheck.Setup(c => c.DisplayName).Returns("Keycloak (Identity & Access Management)"); keycloakCheck .Setup(c => c.CheckAsync(cluster, It.IsAny())) .ReturnsAsync(new ComponentCheckResult( "keycloak", ComponentStatus.Installed, Details: new List { "Keycloak 26.0 detected (operator in namespace 'keycloak')", "Endpoint: auth.internal.corp.com", "Realms: master, tenant-alpha, tenant-beta", "tenant-alpha: BankID provider enabled, custom theme", "tenant-beta: OIDC (Azure AD) provider enabled", "BankID plugin version 1.2.0 installed" }, MissingItems: new List(), Configuration: new DiscoveredConfiguration( Version: "26.0.0", Namespace: "keycloak", HelmReleaseName: "keycloak-operator", Values: new Dictionary { ["endpoint"] = "auth.internal.corp.com", ["realms"] = "master,tenant-alpha,tenant-beta", ["bankIdPluginVersion"] = "1.2.0", ["databaseType"] = "postgres" }))); // Act ComponentCheckResult checkResult = await keycloakCheck.Object.CheckAsync(cluster); // Assert — Full Keycloak state discovered. checkResult.Status.Should().Be(ComponentStatus.Installed); checkResult.Configuration.Should().NotBeNull(); checkResult.Configuration!.Version.Should().Be("26.0.0"); checkResult.Configuration.Values["realms"].Should().Contain("tenant-alpha"); checkResult.Details.Should().Contain(d => d.Contains("BankID")); checkResult.Details.Should().Contain(d => d.Contains("Azure AD")); } private static KubernetesCluster CreateConnectedCluster() { KubernetesCluster cluster = KubernetesCluster.Register( "prod-cluster", "https://k8s.example.com:6443", "apiVersion: v1\nkind: Config\nclusters: []\nusers: []\ncontexts: []", Guid.NewGuid(), "prod-ctx", Guid.NewGuid()); cluster.MarkConnected(); return cluster; } // ─── CNPG Database Integration ──────────────────────────────────────────── [Fact] public void KeycloakSchema_ContainsCnpgClusterNameParameter() { // Keycloak should reference the shared CNPG cluster for its database // instead of assuming a dedicated keycloak-db cluster exists. ConfigurationSchema schema = ComponentSchemas.GetSchema("keycloak"); schema.Parameters.Should().Contain(p => p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster); } [Fact] public void KeycloakSchema_ContainsCnpgNamespaceParameter() { // The CNPG cluster namespace — needed to resolve the database endpoint. ConfigurationSchema schema = ComponentSchemas.GetSchema("keycloak"); schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace"); } }