Files
Entkube/tests/EntKube.Clusters.Tests/Features/KeycloakTests.cs
2026-05-13 14:01:32 +02:00

547 lines
23 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public class KeycloakTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> keycloakInstaller;
public KeycloakTests()
{
repository = new InMemoryClusterRepository();
keycloakInstaller = new Mock<IComponentInstaller>();
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<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "Keycloak 26.0 installed with operator",
Actions: new List<string>
{
"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<IComponentInstaller>
{
keycloakInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("keycloak", Version: "26.0.0",
Parameters: new Dictionary<string, string>
{
["keycloakDomain"] = "auth.internal.corp.com",
["adminPassword"] = "admin123",
["storageClass"] = "longhorn",
["databaseType"] = "postgres",
["installBankIdPlugin"] = "true"
});
// Act
Result<InstallResult> 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<ComponentInstallOptions>(o =>
o.Parameters!["keycloakDomain"] == "auth.internal.corp.com" &&
o.Parameters["installBankIdPlugin"] == "true"),
It.IsAny<CancellationToken>()), 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<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "keycloak",
Message: "Keycloak requires a PostgreSQL database (CloudNativePG or external)",
Actions: new List<string> { "Prerequisite check failed: no database available" }));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
keycloakInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("keycloak",
Parameters: new Dictionary<string, string>
{
["keycloakDomain"] = "auth.internal.corp.com"
});
// Act
Result<InstallResult> 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<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "Realm 'tenant-alpha' created with custom theme",
Actions: new List<string>
{
"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<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["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<InstallResult> 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<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "BankID provider added to realm 'tenant-alpha'",
Actions: new List<string>
{
"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<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["realmName"] = "tenant-alpha",
["addProvider"] = "bankid",
["bankIdEnvironment"] = "production",
["bankIdCertificateP12"] = "MIIEvgIBADANBg...", // base64 P12
["bankIdCertificatePassword"] = "cert-password",
["bankIdDisplayName"] = "Logga in med BankID"
});
// Act
Result<InstallResult> 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<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "OIDC provider 'azure-ad' added to realm 'tenant-beta'",
Actions: new List<string>
{
"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<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["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<InstallResult> 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<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "SAML provider 'corp-adfs' added to realm 'tenant-gamma'",
Actions: new List<string>
{
"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<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["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<InstallResult> 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<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "keycloak",
Message: "Theme updated for realm 'tenant-alpha'",
Actions: new List<string>
{
"Updated theme for realm 'tenant-alpha'",
"Logo updated to new URL",
"Primary color changed to #FF5722",
"Background image updated"
}));
List<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["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<InstallResult> 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<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.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<string>
{
$"Configured {provider} provider in realm '{realm}' only",
$"Other realms not affected"
});
});
List<IComponentInstaller> installers = new() { keycloakInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
// Act — Add BankID to tenant-alpha.
ConfigureComponentRequest request1 = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["realmName"] = "tenant-alpha",
["addProvider"] = "bankid",
["bankIdEnvironment"] = "test"
});
Result<InstallResult> result1 = await configHandler.HandleAsync(cluster.Id, request1);
// Act — Add OIDC to tenant-beta.
ConfigureComponentRequest request2 = new(
ComponentName: "keycloak",
Values: new Dictionary<string, string>
{
["realmName"] = "tenant-beta",
["addProvider"] = "oidc",
["providerAlias"] = "google",
["oidcDiscoveryUrl"] = "https://accounts.google.com/.well-known/openid-configuration",
["oidcClientId"] = "google-client-id",
["oidcClientSecret"] = "google-secret"
});
Result<InstallResult> 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<IAdoptionCheck> 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<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult(
"keycloak",
ComponentStatus.Installed,
Details: new List<string>
{
"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<string>(),
Configuration: new DiscoveredConfiguration(
Version: "26.0.0",
Namespace: "keycloak",
HelmReleaseName: "keycloak-operator",
Values: new Dictionary<string, string>
{
["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");
}
}