too much for one commit

This commit is contained in:
Nils Blomgren
2026-05-13 14:01:32 +02:00
parent a96dd33039
commit 328d494530
394 changed files with 98104 additions and 78 deletions

View File

@@ -1,4 +1,5 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Domain;
@@ -8,20 +9,29 @@ public class KubernetesClusterTests
[Fact]
public void Register_WithValidInputs_CreatesClusterInPendingState()
{
// Arrange & Act — A tenant admin registers a new cluster with its name and API URL.
// Arrange & Act — A tenant admin registers a new cluster with its name, API URL,
// kubeconfig, tenant, and environment it belongs to.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
KubernetesCluster cluster = KubernetesCluster.Register(
"production-eu",
"https://k8s.example.com:6443",
"secret-ref-123");
"apiVersion: v1\nkind: Config",
tenantId,
"prod-context",
environmentId);
// Assert — The cluster should be created with a unique ID and start in Pending state
// because we haven't verified connectivity yet.
// Assert — The cluster should be created with a unique ID, linked to the tenant
// and environment, and start in Pending state because we haven't verified connectivity yet.
cluster.Id.Should().NotBe(Guid.Empty);
cluster.Name.Should().Be("production-eu");
cluster.ApiServerUrl.Should().Be("https://k8s.example.com:6443");
cluster.KubeConfigSecret.Should().Be("secret-ref-123");
cluster.KubeConfig.Should().Contain("apiVersion");
cluster.TenantId.Should().Be(tenantId);
cluster.EnvironmentId.Should().Be(environmentId);
cluster.ContextName.Should().Be("prod-context");
cluster.Status.Should().Be(ClusterStatus.Pending);
cluster.RegisteredAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
@@ -32,7 +42,7 @@ public class KubernetesClusterTests
// Arrange & Act — Attempting to register a cluster without a name should fail
// because every cluster needs a human-readable identifier.
Action act = () => KubernetesCluster.Register("", "https://k8s.example.com", null);
Action act = () => KubernetesCluster.Register("", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Assert
@@ -45,7 +55,7 @@ public class KubernetesClusterTests
{
// Arrange & Act — Without an API URL, we can't communicate with the cluster.
Action act = () => KubernetesCluster.Register("my-cluster", "", null);
Action act = () => KubernetesCluster.Register("my-cluster", "", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Assert
@@ -58,7 +68,7 @@ public class KubernetesClusterTests
{
// Arrange — A freshly registered cluster in Pending state.
KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", null);
KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act — The health check confirms the cluster is reachable.
@@ -75,7 +85,7 @@ public class KubernetesClusterTests
{
// Arrange
KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", null);
KubernetesCluster cluster = KubernetesCluster.Register("test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
// Act — The health check fails.
@@ -87,4 +97,355 @@ public class KubernetesClusterTests
cluster.Status.Should().Be(ClusterStatus.Unreachable);
cluster.LastHealthCheckAt.Should().NotBeNull();
}
[Fact]
public void Register_WithEnvironmentId_StoresEnvironmentId()
{
// Arrange & Act — A cluster is registered and immediately assigned
// to an environment (e.g. dev01 → "dev" environment).
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
KubernetesCluster cluster = KubernetesCluster.Register(
"dev01", "https://api.dev", "kubeconfig", tenantId, "dev-ctx", environmentId);
// Assert — The environment should be stored on the cluster.
cluster.EnvironmentId.Should().Be(environmentId);
}
[Fact]
public void Register_WithoutEnvironmentId_ThrowsArgumentException()
{
// Arrange & Act — Attempting to register a cluster without an environment
// should fail because every cluster must belong to an environment.
Action act = () => KubernetesCluster.Register(
"orphan", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.Empty);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("environmentId");
}
[Fact]
public void AssignToEnvironment_ReassignsToNewEnvironment()
{
// Arrange — A cluster registered in one environment can be reassigned.
Guid originalEnvId = Guid.NewGuid();
Guid newEnvId = Guid.NewGuid();
KubernetesCluster cluster = KubernetesCluster.Register(
"test01", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", originalEnvId);
// Act — Admin reassigns it to a different environment.
cluster.AssignToEnvironment(newEnvId);
// Assert
cluster.EnvironmentId.Should().Be(newEnvId);
}
[Fact]
public void AssignToEnvironment_WithEmptyGuid_ThrowsArgumentException()
{
// Arrange
KubernetesCluster cluster = KubernetesCluster.Register(
"test01", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act
Action act = () => cluster.AssignToEnvironment(Guid.Empty);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("environmentId");
}
[Fact]
public void Register_CreatesClusterWithEmptyComponents()
{
// Arrange & Act — A freshly registered cluster has no known components yet.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Assert
cluster.Components.Should().BeEmpty();
}
[Fact]
public void UpdateComponents_WithCheckResults_PersistsComponents()
{
// Arrange — After an adoption scan, we record the discovered components
// on the cluster so they survive without needing to re-scan.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> results = new()
{
new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running" }, new List<string>(),
new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno", new Dictionary<string, string> { ["replicas"] = "3" })),
new ComponentCheckResult("cert-manager", ComponentStatus.NotInstalled,
new List<string>(), new List<string> { "No pods found" })
};
// Act
cluster.UpdateComponents(results);
// Assert — Both components stored with their status and configuration.
cluster.Components.Should().HaveCount(2);
ClusterComponent kyverno = cluster.Components.First(c => c.ComponentName == "kyverno");
kyverno.Status.Should().Be(ComponentStatus.Installed);
kyverno.Version.Should().Be("3.3.4");
kyverno.Namespace.Should().Be("kyverno");
kyverno.Configuration["replicas"].Should().Be("3");
kyverno.LastCheckedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
ClusterComponent certManager = cluster.Components.First(c => c.ComponentName == "cert-manager");
certManager.Status.Should().Be(ComponentStatus.NotInstalled);
certManager.Configuration.Should().BeEmpty();
}
[Fact]
public void UpdateComponents_ReplacesExistingComponents()
{
// Arrange — A second scan should replace the first scan's results entirely.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> firstScan = new()
{
new ComponentCheckResult("kyverno", ComponentStatus.NotInstalled, new List<string>(), new List<string> { "Missing" })
};
List<ComponentCheckResult> secondScan = new()
{
new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Running" }, new List<string>(),
new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno", new Dictionary<string, string>()))
};
// Act
cluster.UpdateComponents(firstScan);
cluster.UpdateComponents(secondScan);
// Assert — Only the latest scan results should remain.
cluster.Components.Should().HaveCount(1);
cluster.Components[0].Status.Should().Be(ComponentStatus.Installed);
}
[Fact]
public void UpdateComponentConfiguration_UpdatesExistingComponentValues()
{
// Arrange — After a configuration change is deployed, we update the
// stored configuration so it reflects the current state.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> results = new()
{
new ComponentCheckResult("monitoring", ComponentStatus.Installed,
new List<string>(), new List<string>(),
new DiscoveredConfiguration("45.0.0", "monitoring", "kube-prometheus-stack",
new Dictionary<string, string> { ["retention"] = "30d", ["replicas"] = "2" }))
};
cluster.UpdateComponents(results);
// Act — Change retention to 90d.
Dictionary<string, string> newValues = new() { ["retention"] = "90d" };
cluster.UpdateComponentConfiguration("monitoring", newValues);
// Assert — Retention updated, replicas unchanged.
ClusterComponent monitoring = cluster.Components.First(c => c.ComponentName == "monitoring");
monitoring.Configuration["retention"].Should().Be("90d");
monitoring.Configuration["replicas"].Should().Be("2");
}
[Fact]
public void UpdateComponentConfiguration_ForUnknownComponent_DoesNotThrow()
{
// Arrange — If a component hasn't been scanned yet, updating its config
// is a no-op. This shouldn't break anything.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
// Act — No components recorded yet.
Action act = () => cluster.UpdateComponentConfiguration("unknown", new Dictionary<string, string> { ["k"] = "v" });
// Assert
act.Should().NotThrow();
cluster.Components.Should().BeEmpty();
}
[Fact]
public void UpdateComponentVersion_ForInstalledComponent_UpdatesVersion()
{
// Arrange — A cluster has Harbor installed at version 1.16.0. After a
// successful upgrade, we need to track the new version.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> results = new()
{
new ComponentCheckResult("harbor", ComponentStatus.Installed,
new List<string> { "Running" }, new List<string>(),
new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary<string, string>()))
};
cluster.UpdateComponents(results);
// Act — Upgrade to 1.17.0.
cluster.UpdateComponentVersion("harbor", "1.17.0");
// Assert — The tracked version now reflects the upgrade.
ClusterComponent harbor = cluster.Components.First(c => c.ComponentName == "harbor");
harbor.Version.Should().Be("1.17.0");
harbor.LastCheckedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void UpdateComponentVersion_ForUnknownComponent_DoesNotThrow()
{
// Arrange — If the component hasn't been scanned, updating its version
// is a safe no-op — no exception, no side effects.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
// Act
Action act = () => cluster.UpdateComponentVersion("nonexistent", "1.0.0");
// Assert
act.Should().NotThrow();
cluster.Components.Should().BeEmpty();
}
[Fact]
public void UpdateComponentVersion_IsCaseInsensitive()
{
// Arrange — Component names should match case-insensitively, just like
// MarkComponentInstalled and UpdateComponentConfiguration.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://api.test", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> results = new()
{
new ComponentCheckResult("Harbor", ComponentStatus.Installed,
new List<string>(), new List<string>(),
new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary<string, string>()))
};
cluster.UpdateComponents(results);
// Act — Use different casing.
cluster.UpdateComponentVersion("harbor", "1.17.0");
// Assert
cluster.Components.First().Version.Should().Be("1.17.0");
}
[Fact]
public void Register_DefaultsToNoneProvider()
{
// When a cluster is first registered, no cloud provider is set.
// The operator can assign one later when they know where it's hosted.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.Provider.Should().Be(CloudProvider.None);
cluster.ProviderCredentials.Should().BeNull();
}
[Fact]
public void SetProvider_WithCleura_StoresProviderAndCredentials()
{
// A cluster admin links their cluster to Cleura, providing their
// Cleura Cloud username and password so EntKube can call the REST API.
KubernetesCluster cluster = KubernetesCluster.Register(
"cleura-cluster", "https://k8s.cleura.cloud", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
ProviderCredentials creds = new("my-user", "my-password", "Sto2");
// Act
cluster.SetProvider(CloudProvider.Cleura, creds);
// Assert
cluster.Provider.Should().Be(CloudProvider.Cleura);
cluster.ProviderCredentials.Should().NotBeNull();
cluster.ProviderCredentials!.Username.Should().Be("my-user");
cluster.ProviderCredentials!.Region.Should().Be("Sto2");
}
[Fact]
public void SetProvider_ToNone_ClearsCredentials()
{
// Unlinking a provider removes the stored credentials.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetProvider(CloudProvider.Cleura, new ProviderCredentials("u", "p", "Sto2"));
// Act
cluster.SetProvider(CloudProvider.None, null);
// Assert
cluster.Provider.Should().Be(CloudProvider.None);
cluster.ProviderCredentials.Should().BeNull();
}
[Fact]
public void SetProvider_CleuraWithoutCredentials_Throws()
{
// Setting Cleura as the provider requires credentials — you can't
// call the Cleura API without a username and password.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
Action act = () => cluster.SetProvider(CloudProvider.Cleura, null);
act.Should().Throw<ArgumentException>();
}
}

View File

@@ -0,0 +1,185 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class AdoptClusterHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IAdoptionCheck> kyvernoCheck;
private readonly Mock<IAdoptionCheck> securityPoliciesCheck;
private readonly AdoptClusterHandler handler;
public AdoptClusterHandlerTests()
{
repository = new InMemoryClusterRepository();
kyvernoCheck = new Mock<IAdoptionCheck>();
securityPoliciesCheck = new Mock<IAdoptionCheck>();
kyvernoCheck.Setup(c => c.ComponentName).Returns("kyverno");
securityPoliciesCheck.Setup(c => c.ComponentName).Returns("security-policies");
List<IAdoptionCheck> checks = new() { kyvernoCheck.Object, securityPoliciesCheck.Object };
handler = new AdoptClusterHandler(repository, checks, NullLogger<AdoptClusterHandler>.Instance);
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange — Attempt to adopt a cluster that doesn't exist.
Guid unknownId = Guid.NewGuid();
// Act
Result<AdoptionReport> result = await handler.HandleAsync(unknownId);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ClusterNotConnected_ReturnsFailure()
{
// Arrange — The cluster exists but is still Pending (not yet reachable).
KubernetesCluster cluster = KubernetesCluster.Register(
"dev-cluster", "https://k8s.dev:6443", "kubeconfig-data", Guid.NewGuid(), "dev-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_AllComponentsInstalled_ReturnsReadyReport()
{
// Arrange — A connected cluster where all components report Installed.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running: kyverno-admission-controller-0" },
new List<string>()));
securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.Installed,
new List<string> { "Policy present: deny-loadbalancer-services" },
new List<string>()));
// Act
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert — All components installed means Ready.
result.IsSuccess.Should().BeTrue();
result.Value!.Status.Should().Be(AdoptionReadiness.Ready);
result.Value.Components.Should().HaveCount(2);
result.Value.Components.Should().AllSatisfy(c => c.Status.Should().Be(ComponentStatus.Installed));
}
[Fact]
public async Task HandleAsync_OneComponentNotInstalled_ReturnsNotReady()
{
// Arrange — Kyverno is missing entirely while policies exist (impossible
// in practice, but tests the aggregation logic).
KubernetesCluster cluster = KubernetesCluster.Register(
"bare-cluster", "https://k8s.bare:6443", "kubeconfig-data", Guid.NewGuid(), "bare-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.NotInstalled,
new List<string>(),
new List<string> { "No Kyverno pods found" }));
securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.NotInstalled,
new List<string>(),
new List<string> { "restrict-image-registries", "deny-loadbalancer-services" }));
// Act
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert — Any component NotInstalled means the cluster is NotReady.
result.IsSuccess.Should().BeTrue();
result.Value!.Status.Should().Be(AdoptionReadiness.NotReady);
}
[Fact]
public async Task HandleAsync_OneComponentDegraded_ReturnsPartiallyReady()
{
// Arrange — Kyverno is installed but security policies are only partially deployed.
KubernetesCluster cluster = KubernetesCluster.Register(
"partial-cluster", "https://k8s.partial:6443", "kubeconfig-data", Guid.NewGuid(), "partial-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running: kyverno-admission-controller-0" },
new List<string>()));
securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.Degraded,
new List<string> { "Policy present: deny-loadbalancer-services" },
new List<string> { "restrict-image-registries", "require-seccomp-runtime-default" }));
// Act
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert — Degraded components mean PartiallyReady.
result.IsSuccess.Should().BeTrue();
result.Value!.Status.Should().Be(AdoptionReadiness.PartiallyReady);
}
[Fact]
public async Task HandleAsync_RunsAllChecksInParallel()
{
// Arrange — Verify that both checks are called for a connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig-data", Guid.NewGuid(), "test-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed, new List<string>(), new List<string>()));
securityPoliciesCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("security-policies", ComponentStatus.Installed, new List<string>(), new List<string>()));
// Act
await handler.HandleAsync(cluster.Id);
// Assert — Both checks were invoked.
kyvernoCheck.Verify(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()), Times.Once);
securityPoliciesCheck.Verify(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -0,0 +1,197 @@
using EntKube.Clusters.Features.Certificates;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the Certificate Authority feature. Since the handler connects to
/// a real K8s cluster, these tests focus on the domain models, request validation,
/// and operation result patterns. Integration tests with a real cluster would go
/// in a separate test suite.
/// </summary>
public class CertificateAuthorityTests
{
// ─── CaOperationResult ────────────────────────────────────────────────
[Fact]
public void CaOperationResult_Ok_HasSuccessTrue()
{
// When we create a successful result, it should carry the message
// and have Success = true.
CaOperationResult result = CaOperationResult.Ok("CA created.", new List<string> { "Step 1" });
result.Success.Should().BeTrue();
result.Message.Should().Be("CA created.");
result.Actions.Should().ContainSingle().Which.Should().Be("Step 1");
}
[Fact]
public void CaOperationResult_Failure_HasSuccessFalse()
{
// A failure result should carry the error message with Success = false.
CaOperationResult result = CaOperationResult.Failure("Something went wrong.");
result.Success.Should().BeFalse();
result.Message.Should().Be("Something went wrong.");
result.Actions.Should().BeEmpty();
}
[Fact]
public void CaOperationResult_Ok_WithNoActions_DefaultsToEmptyList()
{
// When no actions are provided, the list should default to empty.
CaOperationResult result = CaOperationResult.Ok("Done.");
result.Actions.Should().NotBeNull();
result.Actions.Should().BeEmpty();
}
// ─── CertificateAuthorityInfo ─────────────────────────────────────────
[Fact]
public void CertificateAuthorityInfo_InternalCA_HasCorrectType()
{
// An internal CA should be classified as CaType.Internal and have
// no domain restrictions.
CertificateAuthorityInfo ca = new(
Name: "platform-internal-ca",
Type: CaType.Internal,
SecretName: "platform-internal-ca-secret",
Domains: new List<string>(),
IsExternal: false,
InTrustBundle: true,
Status: "Ready",
NotBefore: DateTimeOffset.UtcNow.AddYears(-1),
NotAfter: DateTimeOffset.UtcNow.AddYears(9),
Organization: "EntKube Platform");
ca.Type.Should().Be(CaType.Internal);
ca.Domains.Should().BeEmpty();
ca.IsExternal.Should().BeFalse();
ca.InTrustBundle.Should().BeTrue();
}
[Fact]
public void CertificateAuthorityInfo_DomainCA_HasDomains()
{
// A domain CA should carry the list of domains it's scoped to.
CertificateAuthorityInfo ca = new(
Name: "corp-ca",
Type: CaType.Domain,
SecretName: "corp-ca-secret",
Domains: new List<string> { "*.internal.corp.com", "*.svc.local" },
IsExternal: false,
InTrustBundle: true,
Status: "Ready",
NotBefore: DateTimeOffset.UtcNow,
NotAfter: DateTimeOffset.UtcNow.AddYears(5),
Organization: "Corp Inc");
ca.Type.Should().Be(CaType.Domain);
ca.Domains.Should().HaveCount(2);
ca.Domains.Should().Contain("*.internal.corp.com");
}
[Fact]
public void CertificateAuthorityInfo_ExternalCA_FlagIsSet()
{
// An imported external CA should have IsExternal = true.
CertificateAuthorityInfo ca = new(
Name: "digicert-ca",
Type: CaType.Domain,
SecretName: "digicert-ca-secret",
Domains: new List<string> { "*.example.com" },
IsExternal: true,
InTrustBundle: true,
Status: "Ready",
NotBefore: null,
NotAfter: null,
Organization: "DigiCert");
ca.IsExternal.Should().BeTrue();
}
[Fact]
public void CertificateAuthorityInfo_LetsEncrypt_HasAcmeType()
{
// A Let's Encrypt issuer is always external and publicly trusted.
CertificateAuthorityInfo ca = new(
Name: "letsencrypt-prod",
Type: CaType.LetsEncrypt,
SecretName: string.Empty,
Domains: new List<string>(),
IsExternal: true,
InTrustBundle: true,
Status: "Ready",
NotBefore: null,
NotAfter: null,
Organization: "Let's Encrypt");
ca.Type.Should().Be(CaType.LetsEncrypt);
ca.IsExternal.Should().BeTrue();
}
// ─── Request models ──────────────────────────────────────────────────
[Fact]
public void CreateInternalCARequest_DefaultValues_AreReasonable()
{
// The default values for an internal CA should be sensible defaults.
CreateInternalCARequest request = new("my-ca");
request.Name.Should().Be("my-ca");
request.Organization.Should().Be("EntKube Platform");
request.DurationDays.Should().Be(3650);
request.BundleName.Should().BeNull();
}
[Fact]
public void CreateDomainCARequest_WithDomains_CarriesThem()
{
// A domain CA request should carry the domains it covers.
CreateDomainCARequest request = new(
Name: "corp-ca",
Domains: new List<string> { "*.corp.com", "*.internal.corp.com" });
request.Domains.Should().HaveCount(2);
request.TlsCert.Should().BeNull();
request.TlsKey.Should().BeNull();
}
[Fact]
public void CreateDomainCARequest_WithImportedCert_HasTlsFields()
{
// An imported domain CA should carry the base64-encoded cert and key.
CreateDomainCARequest request = new(
Name: "imported-ca",
Domains: new List<string> { "*.example.com" },
TlsCert: "base64cert",
TlsKey: "base64key");
request.TlsCert.Should().Be("base64cert");
request.TlsKey.Should().Be("base64key");
}
// ─── CaType enum ─────────────────────────────────────────────────────
[Fact]
public void CaType_HasExpectedValues()
{
// The enum should have exactly three values.
Enum.GetValues<CaType>().Should().HaveCount(3);
Enum.GetValues<CaType>().Should().Contain(CaType.Internal);
Enum.GetValues<CaType>().Should().Contain(CaType.Domain);
Enum.GetValues<CaType>().Should().Contain(CaType.LetsEncrypt);
}
}

View File

@@ -0,0 +1,152 @@
using EntKube.Clusters.Infrastructure.Cleura;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class CleuraClientTests
{
[Fact]
public void ParseAuthResponse_ValidJson_ReturnsToken()
{
// The Cleura API returns a JSON body with result and token fields
// after a successful authentication POST.
string json = """{"result": "login_ok", "token": "vahkie7EiDaij7chegaitee2zohsh1oh"}""";
CleuraAuthResponse? response = System.Text.Json.JsonSerializer.Deserialize<CleuraAuthResponse>(json);
response.Should().NotBeNull();
response!.Result.Should().Be("login_ok");
response.Token.Should().Be("vahkie7EiDaij7chegaitee2zohsh1oh");
}
[Fact]
public void ParseDomainsResponse_ValidJson_ReturnsRegions()
{
// The domains endpoint returns a list of domains, each with a region identifier.
string json = """
{
"domains": [
{"name": "my-domain", "region": "Sto2", "id": "abc123"},
{"name": "my-domain", "region": "Fra1", "id": "def456"}
]
}
""";
CleuraDomainsResponse? response = System.Text.Json.JsonSerializer.Deserialize<CleuraDomainsResponse>(json,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response.Should().NotBeNull();
response!.Domains.Should().HaveCount(2);
response.Domains[0].Region.Should().Be("Sto2");
response.Domains[1].Region.Should().Be("Fra1");
}
[Fact]
public void BuildAuthRequest_FormatsCorrectly()
{
// Verify the authentication request body matches Cleura's expected format.
CleuraAuthRequest request = new("my-user", "my-pass");
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().Contain("\"auth\"");
json.Should().Contain("\"login\"");
json.Should().Contain("\"password\"");
json.Should().Contain("my-user");
}
[Fact]
public void BuildAuthRequest_WithTwofaMethod_IncludesTwofaMethod()
{
// When a 2FA method is specified, the auth body should include
// the twofa_method field so Cleura selects that method.
CleuraAuthRequest request = new("my-user", "my-pass", "sms");
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().Contain("\"twofa_method\"");
json.Should().Contain("sms");
}
[Fact]
public void BuildAuthRequest_WithoutTwofaMethod_OmitsTwofaMethod()
{
// Without a 2FA method, the twofa_method field should not be present.
CleuraAuthRequest request = new("my-user", "my-pass");
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().NotContain("twofa_method");
}
[Fact]
public void ParseAuthResponse_TwoFactorOptions_IndicatesNeed()
{
// When 2FA is enabled but no code is provided, Cleura responds with
// twofactor_options listing available methods (sms, webauthn).
string json = """{"result": "twofactor_options", "token": "", "options": ["sms", "webauthn"]}""";
CleuraAuthResponse? response = System.Text.Json.JsonSerializer.Deserialize<CleuraAuthResponse>(json);
response.Should().NotBeNull();
response!.Result.Should().Be("twofactor_options");
response.Options.Should().Contain("sms");
response.Options.Should().Contain("webauthn");
}
[Fact]
public void ParseAuthResponse_TwoFactorRequired_ReturnsVerification()
{
// When a 2FA method is selected, Cleura responds with twofactor_required
// and a verification token for subsequent steps.
string json = """{"result": "twofactor_required", "token": "", "verification": "xe9hik8q6r"}""";
CleuraAuthResponse? response = System.Text.Json.JsonSerializer.Deserialize<CleuraAuthResponse>(json);
response.Should().NotBeNull();
response!.Result.Should().Be("twofactor_required");
response.Verification.Should().Be("xe9hik8q6r");
}
[Fact]
public void BuildRequest2faCode_FormatsCorrectly()
{
// The request2facode endpoint expects a specific JSON structure
// with the login and verification token from the prior step.
CleuraRequest2faCodeRequest request = new("JohnDoe", "xe9hik8q6r");
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().Contain("\"request2fa\"");
json.Should().Contain("\"login\"");
json.Should().Contain("\"verification\"");
json.Should().Contain("JohnDoe");
json.Should().Contain("xe9hik8q6r");
}
[Fact]
public void BuildVerify2fa_FormatsCorrectly()
{
// The verify2fa endpoint expects the login, verification, and integer code.
CleuraVerify2faRequest request = new("JohnDoe", "xe9hik8q6r", 123456);
string json = System.Text.Json.JsonSerializer.Serialize(request);
json.Should().Contain("\"verify2fa\"");
json.Should().Contain("\"login\"");
json.Should().Contain("\"verification\"");
json.Should().Contain("\"code\"");
json.Should().Contain("JohnDoe");
json.Should().Contain("xe9hik8q6r");
json.Should().Contain("123456");
}
}

View File

@@ -0,0 +1,116 @@
using EntKube.Clusters.Infrastructure.Cleura;
using FluentAssertions;
using System.Text.Json;
namespace EntKube.Clusters.Tests.Features;
public class CleuraObjectStorageTests
{
[Fact]
public void ParseKeystoneTokenResponse_ExtractsUserId()
{
// After authenticating with Keystone, the response includes the user ID
// which is needed to create EC2 credentials.
string json = """
{
"token": {
"user": {
"id": "abc123",
"name": "testuser",
"domain": { "id": "domainid", "name": "CCP_Domain_1_268" }
},
"catalog": [],
"expires_at": "2026-05-08T12:00:00Z"
}
}
""";
KeystoneTokenResponse? response = JsonSerializer.Deserialize<KeystoneTokenResponse>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response.Should().NotBeNull();
response!.Token.User.Id.Should().Be("abc123");
response.Token.User.Name.Should().Be("testuser");
}
[Fact]
public void ParseEc2CredentialResponse_ExtractsAccessAndSecret()
{
// Creating EC2 credentials returns the access/secret key pair
// that serves as S3-compatible credentials.
string json = """
{
"credential": {
"access": "AKIAIOSFODNN7EXAMPLE",
"secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"user_id": "abc123",
"project_id": "project456",
"tenant_id": "project456"
}
}
""";
Ec2CredentialResponse? response = JsonSerializer.Deserialize<Ec2CredentialResponse>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response.Should().NotBeNull();
response!.Credential.Access.Should().Be("AKIAIOSFODNN7EXAMPLE");
response.Credential.Secret.Should().Be("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
response.Credential.ProjectId.Should().Be("project456");
}
[Fact]
public void S3EndpointUrl_ForRegion_FormatsCorrectly()
{
// Cleura S3 endpoints follow the pattern https://s3-{region}.citycloud.com
string endpoint = CleuraS3Client.GetS3Endpoint("Sto2");
endpoint.Should().Be("https://s3-sto2.citycloud.com");
}
[Fact]
public void S3EndpointUrl_ForFra1_FormatsCorrectly()
{
string endpoint = CleuraS3Client.GetS3Endpoint("Fra1");
endpoint.Should().Be("https://s3-fra1.citycloud.com");
}
[Fact]
public void ParseEc2CredentialListResponse_ReturnsMultiple()
{
// Listing credentials returns all existing EC2 credential pairs for the user/project.
string json = """
{
"credentials": [
{
"access": "KEY1",
"secret": "SECRET1",
"user_id": "u1",
"project_id": "p1",
"tenant_id": "p1"
},
{
"access": "KEY2",
"secret": "SECRET2",
"user_id": "u1",
"project_id": "p2",
"tenant_id": "p2"
}
]
}
""";
Ec2CredentialListResponse? response = JsonSerializer.Deserialize<Ec2CredentialListResponse>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
response.Should().NotBeNull();
response!.Credentials.Should().HaveCount(2);
response.Credentials[0].Access.Should().Be("KEY1");
response.Credentials[1].ProjectId.Should().Be("p2");
}
}

View File

@@ -0,0 +1,239 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.ClusterHealth;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class ClusterHealthHandlerTests
{
private readonly FakePrometheusQueryClient fakeQueryClient;
private readonly FakeClusterRepository repository;
private readonly ClusterHealthHandler sut;
public ClusterHealthHandlerTests()
{
fakeQueryClient = new FakePrometheusQueryClient();
repository = new FakeClusterRepository();
sut = new ClusterHealthHandler(repository, fakeQueryClient);
}
[Fact]
public async Task HandleAsync_WithHealthyCluster_ReturnsAllMetrics()
{
// Arrange — a cluster with a Prometheus endpoint, all metrics looking good.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "prod", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus")
});
await repository.AddAsync(cluster);
fakeQueryClient.SetResults(new Dictionary<string, double>
{
["count(kube_node_info)"] = 3,
["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 3,
["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 42.5,
["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 68.2,
["sum(kube_pod_status_phase{phase=\"Running\"})"] = 45,
["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 2,
["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 0,
["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 3,
["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 0.1,
["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 0
});
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert — we get a complete health report.
result.IsSuccess.Should().BeTrue();
ClusterHealthReport report = result.Value!;
report.TotalNodes.Should().Be(3);
report.ReadyNodes.Should().Be(3);
report.CpuUtilizationPercent.Should().BeApproximately(42.5, 0.1);
report.MemoryUtilizationPercent.Should().BeApproximately(68.2, 0.1);
report.RunningPods.Should().Be(45);
report.PendingPods.Should().Be(2);
report.FailedPods.Should().Be(0);
report.ContainerRestartsLastHour.Should().Be(3);
report.ApiServerErrorRatePercent.Should().BeApproximately(0.1, 0.01);
report.NodesWithDiskPressure.Should().Be(0);
report.OverallStatus.Should().Be(ClusterHealthStatus.Healthy);
}
[Fact]
public async Task HandleAsync_WithNoPrometheusEndpoints_ReturnsFailure()
{
// Arrange — cluster without Prometheus discovered yet.
KubernetesCluster cluster = KubernetesCluster.Register(
"no-prom", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("No Prometheus endpoints");
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_WithDegradedMetrics_ReturnsDegradedStatus()
{
// Arrange — high CPU, some pending pods, and container restarts indicate degradation.
KubernetesCluster cluster = KubernetesCluster.Register(
"degraded-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus")
});
await repository.AddAsync(cluster);
fakeQueryClient.SetResults(new Dictionary<string, double>
{
["count(kube_node_info)"] = 3,
["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 2,
["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 85.0,
["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 78.0,
["sum(kube_pod_status_phase{phase=\"Running\"})"] = 40,
["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 8,
["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 2,
["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 15,
["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 3.0,
["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 0
});
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.OverallStatus.Should().Be(ClusterHealthStatus.Degraded);
}
[Fact]
public async Task HandleAsync_WithCriticalMetrics_ReturnsCriticalStatus()
{
// Arrange — nodes not ready and high API error rate = critical.
KubernetesCluster cluster = KubernetesCluster.Register(
"critical-cluster", "https://k8s.example.com", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://prometheus.monitoring.svc.cluster.local:9090", "monitoring", "prometheus")
});
await repository.AddAsync(cluster);
fakeQueryClient.SetResults(new Dictionary<string, double>
{
["count(kube_node_info)"] = 3,
["sum(kube_node_status_condition{condition=\"Ready\",status=\"true\"})"] = 1,
["(1 - avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))) * 100"] = 95.0,
["(1 - sum(node_memory_MemAvailable_bytes) / sum(node_memory_MemTotal_bytes)) * 100"] = 92.0,
["sum(kube_pod_status_phase{phase=\"Running\"})"] = 10,
["sum(kube_pod_status_phase{phase=\"Pending\"})"] = 20,
["sum(kube_pod_status_phase{phase=\"Failed\"})"] = 15,
["sum(increase(kube_pod_container_status_restarts_total[1h]))"] = 50,
["sum(rate(apiserver_request_total{code=~\"5..\"}[5m])) / sum(rate(apiserver_request_total[5m])) * 100"] = 12.0,
["sum(kube_node_status_condition{condition=\"DiskPressure\",status=\"true\"})"] = 2
});
// Act
SharedKernel.Domain.Result<ClusterHealthReport> result = await sut.HandleAsync(cluster.Id);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.OverallStatus.Should().Be(ClusterHealthStatus.Critical);
}
}
/// <summary>
/// Fake Prometheus query client for testing — returns preconfigured values
/// for specific PromQL queries without needing a real Prometheus instance.
/// </summary>
public class FakePrometheusQueryClient : IPrometheusQueryClient
{
private Dictionary<string, double> results = new();
public void SetResults(Dictionary<string, double> queryResults)
{
results = queryResults;
}
public Task<double?> QueryScalarAsync(KubernetesCluster cluster, string prometheusUrl, string promql, CancellationToken ct = default)
{
if (results.TryGetValue(promql, out double value))
{
return Task.FromResult<double?>(value);
}
return Task.FromResult<double?>(null);
}
}
/// <summary>
/// Simple fake repository for test isolation.
/// </summary>
public class FakeClusterRepository : IClusterRepository
{
private readonly List<KubernetesCluster> clusters = new();
public Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return Task.FromResult(clusters.FirstOrDefault(c => c.Id == id));
}
public Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default)
{
return Task.FromResult<IReadOnlyList<KubernetesCluster>>(clusters.AsReadOnly());
}
public Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
clusters.Add(cluster);
return Task.CompletedTask;
}
public Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task DeleteAsync(Guid id, CancellationToken ct = default)
{
clusters.RemoveAll(c => c.Id == id);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,183 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.ClusterSettings;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Cluster settings let operators view and edit configuration that lives on the
/// live cluster — like the list of allowed container registries in the Kyverno
/// restrict-image-registries policy. These tests verify the handler logic without
/// touching a real cluster. The actual Kubernetes interaction is behind an
/// IClusterSettingsReader abstraction so we can mock it in tests.
/// </summary>
public class ClusterSettingsTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IClusterSettingsReader> settingsReader;
private readonly Mock<IClusterSettingsWriter> settingsWriter;
private readonly GetClusterSettingsHandler getHandler;
private readonly UpdateClusterSettingsHandler updateHandler;
public ClusterSettingsTests()
{
repository = new InMemoryClusterRepository();
settingsReader = new Mock<IClusterSettingsReader>();
settingsWriter = new Mock<IClusterSettingsWriter>();
getHandler = new GetClusterSettingsHandler(repository, settingsReader.Object);
updateHandler = new UpdateClusterSettingsHandler(repository, settingsWriter.Object);
}
// ─── GET ───────────────────────────────────────────────────────────────
[Fact]
public async Task GetSettings_WithConnectedCluster_ReturnsSettings()
{
// Arrange — a connected cluster with some registries configured.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
List<string> registries = new() { "docker.io", "ghcr.io", "registry.k8s.io" };
settingsReader
.Setup(r => r.ReadAllowedRegistriesAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(registries);
// Act
Result<ClusterSettingsDto> result = await getHandler.HandleAsync(cluster.Id);
// Assert — the returned settings should include the registries from the live cluster.
result.IsSuccess.Should().BeTrue();
result.Value!.AllowedRegistries.Should().BeEquivalentTo(registries);
}
[Fact]
public async Task GetSettings_WithNonExistentCluster_ReturnsFailure()
{
// Act
Result<ClusterSettingsDto> result = await getHandler.HandleAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task GetSettings_WithDisconnectedCluster_ReturnsFailure()
{
// Arrange — a cluster that hasn't been connected yet.
KubernetesCluster cluster = KubernetesCluster.Register(
"pending", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act
Result<ClusterSettingsDto> result = await getHandler.HandleAsync(cluster.Id);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
// ─── UPDATE ────────────────────────────────────────────────────────────
[Fact]
public async Task UpdateSettings_WithValidRegistries_CallsWriter()
{
// Arrange — a connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
List<string> newRegistries = new() { "docker.io", "ghcr.io", "myregistry.com" };
settingsWriter
.Setup(w => w.UpdateAllowedRegistriesAsync(cluster, newRegistries, It.IsAny<CancellationToken>()))
.ReturnsAsync(Result.Success());
UpdateClusterSettingsRequest request = new(AllowedRegistries: newRegistries);
// Act
Result result = await updateHandler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
settingsWriter.Verify(
w => w.UpdateAllowedRegistriesAsync(cluster, newRegistries, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task UpdateSettings_WithEmptyRegistries_ReturnsFailure()
{
// Arrange
KubernetesCluster cluster = KubernetesCluster.Register(
"prod", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
UpdateClusterSettingsRequest request = new(AllowedRegistries: new List<string>());
// Act
Result result = await updateHandler.HandleAsync(cluster.Id, request);
// Assert — can't have zero allowed registries, that would block all pods.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("at least one");
}
[Fact]
public async Task UpdateSettings_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
UpdateClusterSettingsRequest request = new(AllowedRegistries: new List<string> { "docker.io" });
// Act
Result result = await updateHandler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task UpdateSettings_WithDisconnectedCluster_ReturnsFailure()
{
// Arrange
KubernetesCluster cluster = KubernetesCluster.Register(
"pending", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
UpdateClusterSettingsRequest request = new(AllowedRegistries: new List<string> { "docker.io" });
// Act
Result result = await updateHandler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
}

View File

@@ -0,0 +1,137 @@
using EntKube.Clusters.Features.AdoptCluster.Components;
using FluentAssertions;
using k8s.Models;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the shared CNPG database provisioner that builds Job and Secret
/// manifests for creating databases on a CNPG cluster. These are pure manifest
/// builders — no Kubernetes API calls — so they're fully unit-testable.
/// </summary>
public class CnpgDatabaseProvisionerTests
{
[Fact]
public void GetPrimaryHost_ReturnsCorrectFqdn()
{
// The CNPG primary service follows the naming convention
// {clusterName}-rw.{namespace}.svc.cluster.local.
string host = CnpgDatabaseProvisioner.GetPrimaryHost("shared-pg", "cnpg-system");
host.Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local");
}
[Fact]
public void BuildCreateDatabaseJob_TargetsPrimaryService()
{
// The Job should connect to the CNPG cluster's read-write primary
// service to execute the CREATE ROLE and CREATE DATABASE commands.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "gitea", "gitea", "secret123");
// The psql command should target {clusterName}-rw.
string args = job.Spec.Template.Spec.Containers[0].Args[0];
args.Should().Contain("psql -h shared-pg-rw");
}
[Fact]
public void BuildCreateDatabaseJob_UsesSuperuserSecret()
{
// The Job needs the superuser password from the CNPG cluster's
// automatically created {clusterName}-superuser secret.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "gitea", "gitea", "secret123");
V1EnvVar passwordEnv = job.Spec.Template.Spec.Containers[0].Env[0];
passwordEnv.Name.Should().Be("POSTGRES_PASSWORD");
passwordEnv.ValueFrom!.SecretKeyRef!.Name.Should().Be("shared-pg-superuser");
passwordEnv.ValueFrom.SecretKeyRef.Key.Should().Be("password");
}
[Fact]
public void BuildCreateDatabaseJob_SetsCorrectNamespace()
{
// The Job must run in the same namespace as the CNPG cluster
// so it can resolve the primary service by short name.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "harbor", "harbor", "pass");
job.Metadata.NamespaceProperty.Should().Be("cnpg-system");
}
[Fact]
public void BuildCreateDatabaseJob_HasManagementLabels()
{
// The Job should be labeled for EntKube management and discoverability.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "keycloak", "keycloak", "pass");
job.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube");
job.Metadata.Labels["entkube.io/operation"].Should().Be("create-database");
}
[Fact]
public void BuildCreateDatabaseJob_UsesIdempotentSql()
{
// The SQL script should use IF NOT EXISTS patterns so re-running
// the Job on an already-provisioned database doesn't fail.
V1Job job = CnpgDatabaseProvisioner.BuildCreateDatabaseJob(
"shared-pg", "cnpg-system", "gitea", "gitea", "secret123");
string args = job.Spec.Template.Spec.Containers[0].Args[0];
args.Should().Contain("WHERE NOT EXISTS (SELECT FROM pg_roles");
args.Should().Contain("WHERE NOT EXISTS (SELECT FROM pg_database");
}
[Fact]
public void BuildCredentialsSecret_ContainsAllConnectionDetails()
{
// The credentials secret should contain host, database, username,
// and password so the consuming service can connect.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"gitea-db-credentials", "gitea",
"shared-pg-rw.cnpg-system.svc.cluster.local",
"gitea", "gitea", "secret123");
secret.StringData["host"].Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local");
secret.StringData["database"].Should().Be("gitea");
secret.StringData["username"].Should().Be("gitea");
secret.StringData["password"].Should().Be("secret123");
}
[Fact]
public void BuildCredentialsSecret_HasManagementLabels()
{
// The secret should be labeled for EntKube management.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"harbor-db-credentials", "harbor",
"shared-pg-rw.cnpg-system.svc.cluster.local",
"harbor", "harbor", "pass");
secret.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube");
secret.Metadata.Labels["entkube.io/purpose"].Should().Be("database-credentials");
}
[Fact]
public void BuildCredentialsSecret_SetsCorrectNamespace()
{
// The secret must be created in the target service's namespace,
// not the CNPG cluster's namespace.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"keycloak-db-credentials", "keycloak",
"shared-pg-rw.cnpg-system.svc.cluster.local",
"keycloak", "keycloak", "pass");
secret.Metadata.NamespaceProperty.Should().Be("keycloak");
}
}

View File

@@ -0,0 +1,237 @@
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 Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Verifies that adoption scans, deployments, and configuration changes
/// persist component state on the cluster aggregate. This ensures we don't
/// need to re-scan the cluster every time the UI loads — detected components
/// are stored and updated incrementally.
/// </summary>
public class ComponentPersistenceTests
{
private readonly InMemoryClusterRepository repository;
public ComponentPersistenceTests()
{
repository = new InMemoryClusterRepository();
}
[Fact]
public async Task AdoptCluster_PersistsDetectedComponentsOnCluster()
{
// Arrange — A connected cluster with two components: one installed, one missing.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
Mock<IAdoptionCheck> kyvernoCheck = new();
kyvernoCheck.Setup(c => c.ComponentName).Returns("kyverno");
kyvernoCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running" }, new List<string>(),
new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno",
new Dictionary<string, string> { ["replicas"] = "3" })));
Mock<IAdoptionCheck> certManagerCheck = new();
certManagerCheck.Setup(c => c.ComponentName).Returns("cert-manager");
certManagerCheck.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult("cert-manager", ComponentStatus.NotInstalled,
new List<string>(), new List<string> { "No pods found" }));
List<IAdoptionCheck> checks = new() { kyvernoCheck.Object, certManagerCheck.Object };
AdoptClusterHandler handler = new(repository, checks, NullLogger<AdoptClusterHandler>.Instance);
// Act — Run the adoption scan.
Result<AdoptionReport> result = await handler.HandleAsync(cluster.Id);
// Assert — Components should now be persisted on the cluster.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
persisted!.Components.Should().HaveCount(2);
ClusterComponent kyverno = persisted.Components.First(c => c.ComponentName == "kyverno");
kyverno.Status.Should().Be(ComponentStatus.Installed);
kyverno.Version.Should().Be("3.3.4");
kyverno.Configuration["replicas"].Should().Be("3");
ClusterComponent certManager = persisted.Components.First(c => c.ComponentName == "cert-manager");
certManager.Status.Should().Be(ComponentStatus.NotInstalled);
}
[Fact]
public async Task ConfigureComponent_UpdatesPersistedConfiguration()
{
// Arrange — A cluster with monitoring already detected and persisted.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.UpdateComponents(new List<ComponentCheckResult>
{
new("monitoring", ComponentStatus.Installed, new List<string>(), new List<string>(),
new DiscoveredConfiguration("45.0.0", "monitoring", "kube-prometheus-stack",
new Dictionary<string, string> { ["retention"] = "30d" }))
});
await repository.AddAsync(cluster);
Mock<IComponentInstaller> monitoringInstaller = new();
monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring");
monitoringInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "monitoring", "Reconfigured", new List<string> { "retention=90d" }));
List<IComponentInstaller> installers = new() { monitoringInstaller.Object };
ConfigureComponentHandler handler = new(repository, installers);
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act — Apply the configuration change.
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The persisted component configuration should reflect the new value.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
ClusterComponent monitoring = persisted!.Components.First(c => c.ComponentName == "monitoring");
monitoring.Configuration["retention"].Should().Be("90d");
}
[Fact]
public async Task DeployComponent_UpdatesPersistedComponentStatus()
{
// Arrange — A cluster with cert-manager detected as NotInstalled.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.UpdateComponents(new List<ComponentCheckResult>
{
new("cert-manager", ComponentStatus.NotInstalled, new List<string>(), new List<string> { "Missing" })
});
await repository.AddAsync(cluster);
Mock<IComponentInstaller> certManagerInstaller = new();
certManagerInstaller.Setup(i => i.ComponentName).Returns("cert-manager");
certManagerInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cert-manager", "Installed v1.14.0",
new List<string> { "Helm install cert-manager v1.14.0" }));
List<IComponentInstaller> installers = new() { certManagerInstaller.Object };
DeployComponentHandler handler = new(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("cert-manager", Version: "1.14.0", Namespace: "cert-manager");
// Act — Deploy the component.
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The persisted component should now show Installed.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
ClusterComponent certManager = persisted!.Components.First(c => c.ComponentName == "cert-manager");
certManager.Status.Should().Be(ComponentStatus.Installed);
}
[Fact]
public async Task ConfigureComponent_WhenInstallerFails_DoesNotUpdatePersisted()
{
// Arrange — Configuration that fails should NOT update the persisted state.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.UpdateComponents(new List<ComponentCheckResult>
{
new("monitoring", ComponentStatus.Installed, new List<string>(), new List<string>(),
new DiscoveredConfiguration("45.0.0", "monitoring", "kube-prometheus-stack",
new Dictionary<string, string> { ["retention"] = "30d" }))
});
await repository.AddAsync(cluster);
Mock<IComponentInstaller> monitoringInstaller = new();
monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring");
monitoringInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "monitoring", "Helm failed", new List<string>()));
List<IComponentInstaller> installers = new() { monitoringInstaller.Object };
ConfigureComponentHandler handler = new(repository, installers);
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Original value preserved.
result.IsFailure.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
ClusterComponent monitoring = persisted!.Components.First(c => c.ComponentName == "monitoring");
monitoring.Configuration["retention"].Should().Be("30d");
}
[Fact]
public async Task DeployComponent_WhenInstallerFails_DoesNotUpdatePersisted()
{
// Arrange — Failed deployment should not change persisted status.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.UpdateComponents(new List<ComponentCheckResult>
{
new("cert-manager", ComponentStatus.NotInstalled, new List<string>(), new List<string>())
});
await repository.AddAsync(cluster);
Mock<IComponentInstaller> installer = new();
installer.Setup(i => i.ComponentName).Returns("cert-manager");
installer
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "cert-manager", "Timeout", new List<string>()));
List<IComponentInstaller> installers = new() { installer.Object };
DeployComponentHandler handler = new(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("cert-manager");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
KubernetesCluster? persisted = await repository.GetByIdAsync(cluster.Id);
ClusterComponent certManager = persisted!.Components.First(c => c.ComponentName == "cert-manager");
certManager.Status.Should().Be(ComponentStatus.NotInstalled);
}
}

View File

@@ -0,0 +1,634 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the component configuration schema system. Schemas define what
/// parameters each component accepts, their types, validation constraints,
/// and how they're grouped in the UI. This enables the frontend to render
/// proper typed forms instead of raw key/value editors.
/// </summary>
public class ConfigurationSchemaTests
{
[Fact]
public void ConfigurationParameter_WithRequiredString_ValidatesPresence()
{
// Arrange — A required parameter with no value provided.
ConfigurationParameter param = new(
Key: "email",
DisplayName: "Email Address",
Description: "The email registered with the certificate authority",
Type: ParameterType.String,
Group: "General",
IsRequired: true);
// Act
ParameterValidationResult result = param.Validate(null);
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("required");
}
[Fact]
public void ConfigurationParameter_WithRequiredString_PassesWhenProvided()
{
// Arrange
ConfigurationParameter param = new(
Key: "email",
DisplayName: "Email Address",
Description: "The email registered with the certificate authority",
Type: ParameterType.String,
Group: "General",
IsRequired: true);
// Act
ParameterValidationResult result = param.Validate("admin@example.com");
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_WithIntegerType_RejectsNonNumeric()
{
// Arrange — An integer parameter with a non-numeric value.
ConfigurationParameter param = new(
Key: "replicas",
DisplayName: "Replicas",
Description: "Number of pod replicas",
Type: ParameterType.Integer,
Group: "Scaling",
IsRequired: true,
Min: 1,
Max: 10);
// Act
ParameterValidationResult result = param.Validate("not-a-number");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("integer");
}
[Fact]
public void ConfigurationParameter_WithIntegerType_RejectsOutOfRange()
{
// Arrange — An integer outside the allowed range.
ConfigurationParameter param = new(
Key: "replicas",
DisplayName: "Replicas",
Description: "Number of pod replicas",
Type: ParameterType.Integer,
Group: "Scaling",
IsRequired: true,
Min: 1,
Max: 10);
// Act
ParameterValidationResult result = param.Validate("15");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("10");
}
[Fact]
public void ConfigurationParameter_WithIntegerType_AcceptsValidValue()
{
// Arrange
ConfigurationParameter param = new(
Key: "replicas",
DisplayName: "Replicas",
Description: "Number of pod replicas",
Type: ParameterType.Integer,
Group: "Scaling",
IsRequired: true,
Min: 1,
Max: 10);
// Act
ParameterValidationResult result = param.Validate("3");
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_WithBooleanType_RejectsInvalidValues()
{
// Arrange — A boolean parameter with a non-boolean value.
ConfigurationParameter param = new(
Key: "grafanaEnabled",
DisplayName: "Enable Grafana",
Description: "Whether to deploy Grafana alongside Prometheus",
Type: ParameterType.Boolean,
Group: "Features");
// Act
ParameterValidationResult result = param.Validate("maybe");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("true");
}
[Fact]
public void ConfigurationParameter_WithBooleanType_AcceptsTrueAndFalse()
{
// Arrange
ConfigurationParameter param = new(
Key: "grafanaEnabled",
DisplayName: "Enable Grafana",
Description: "Whether to deploy Grafana alongside Prometheus",
Type: ParameterType.Boolean,
Group: "Features");
// Act & Assert
param.Validate("true").IsValid.Should().BeTrue();
param.Validate("false").IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_WithSelectType_RejectsUnknownOption()
{
// Arrange — A select parameter with allowed values.
ConfigurationParameter param = new(
Key: "provider",
DisplayName: "Ingress Provider",
Description: "Which ingress controller to use",
Type: ParameterType.Select,
Group: "General",
IsRequired: true,
AllowedValues: new List<string> { "traefik", "istio" });
// Act
ParameterValidationResult result = param.Validate("nginx");
// Assert
result.IsValid.Should().BeFalse();
result.Error.Should().Contain("traefik");
}
[Fact]
public void ConfigurationParameter_WithSelectType_AcceptsAllowedValue()
{
// Arrange
ConfigurationParameter param = new(
Key: "provider",
DisplayName: "Ingress Provider",
Description: "Which ingress controller to use",
Type: ParameterType.Select,
Group: "General",
IsRequired: true,
AllowedValues: new List<string> { "traefik", "istio" });
// Act
ParameterValidationResult result = param.Validate("istio");
// Assert
result.IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationParameter_Optional_AcceptsNullOrEmpty()
{
// Arrange — An optional parameter with no value is valid.
ConfigurationParameter param = new(
Key: "storageSize",
DisplayName: "Storage Size",
Description: "PVC storage size",
Type: ParameterType.String,
Group: "Storage",
IsRequired: false,
DefaultValue: "50Gi");
// Act & Assert
param.Validate(null).IsValid.Should().BeTrue();
param.Validate("").IsValid.Should().BeTrue();
}
[Fact]
public void ConfigurationSchema_ValidateAll_ReturnsAllErrors()
{
// Arrange — A schema with multiple parameters, some invalid.
ConfigurationSchema schema = new(
ComponentName: "monitoring",
DisplayName: "Monitoring (kube-prometheus-stack)",
Description: "Prometheus, Grafana, and Alertmanager stack",
Parameters: new List<ConfigurationParameter>
{
new(Key: "retention", DisplayName: "Retention", Description: "Data retention period",
Type: ParameterType.String, Group: "Storage", IsRequired: true),
new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas",
Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10),
new(Key: "grafanaEnabled", DisplayName: "Enable Grafana", Description: "Deploy Grafana",
Type: ParameterType.Boolean, Group: "Features")
});
Dictionary<string, string> values = new()
{
["replicas"] = "999",
["grafanaEnabled"] = "invalid"
};
// Act
SchemaValidationResult result = schema.Validate(values);
// Assert — Three errors: retention missing, replicas out of range, grafana invalid.
result.IsValid.Should().BeFalse();
result.Errors.Should().HaveCount(3);
result.Errors.Should().ContainKey("retention");
result.Errors.Should().ContainKey("replicas");
result.Errors.Should().ContainKey("grafanaEnabled");
}
[Fact]
public void ConfigurationSchema_ValidateAll_PassesWithValidValues()
{
// Arrange
ConfigurationSchema schema = new(
ComponentName: "monitoring",
DisplayName: "Monitoring (kube-prometheus-stack)",
Description: "Prometheus, Grafana, and Alertmanager stack",
Parameters: new List<ConfigurationParameter>
{
new(Key: "retention", DisplayName: "Retention", Description: "Data retention period",
Type: ParameterType.String, Group: "Storage", IsRequired: true),
new(Key: "replicas", DisplayName: "Prometheus Replicas", Description: "Number of Prometheus replicas",
Type: ParameterType.Integer, Group: "Scaling", IsRequired: true, Min: 1, Max: 10)
});
Dictionary<string, string> values = new()
{
["retention"] = "30d",
["replicas"] = "2"
};
// Act
SchemaValidationResult result = schema.Validate(values);
// Assert
result.IsValid.Should().BeTrue();
result.Errors.Should().BeEmpty();
}
[Fact]
public void ConfigurationSchema_GroupsParameters_ByGroupName()
{
// Arrange — Schema parameters can be grouped for UI rendering.
ConfigurationSchema schema = new(
ComponentName: "monitoring",
DisplayName: "Monitoring",
Description: "Prometheus stack",
Parameters: new List<ConfigurationParameter>
{
new(Key: "retention", DisplayName: "Retention", Description: "Retention",
Type: ParameterType.String, Group: "Storage"),
new(Key: "storageSize", DisplayName: "Storage Size", Description: "PVC size",
Type: ParameterType.String, Group: "Storage"),
new(Key: "replicas", DisplayName: "Replicas", Description: "Replicas",
Type: ParameterType.Integer, Group: "Scaling", Min: 1, Max: 10),
new(Key: "grafanaEnabled", DisplayName: "Grafana", Description: "Enable Grafana",
Type: ParameterType.Boolean, Group: "Features")
});
// Act
Dictionary<string, List<ConfigurationParameter>> groups = schema.GetParametersByGroup();
// Assert
groups.Should().HaveCount(3);
groups["Storage"].Should().HaveCount(2);
groups["Scaling"].Should().HaveCount(1);
groups["Features"].Should().HaveCount(1);
}
[Fact]
public void CertManagerSchema_DoesNotContainLetsEncryptParameters()
{
// Arrange & Act — The cert-manager schema should only have scaling
// parameters. Let's Encrypt configuration belongs to the letsencrypt component.
ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager");
// Assert — No Let's Encrypt-related keys should exist.
List<string> paramKeys = schema.Parameters.Select(p => p.Key).ToList();
paramKeys.Should().NotContain("letsEncryptEmail");
paramKeys.Should().NotContain("httpSolverEnabled");
paramKeys.Should().NotContain("dnsSolverEnabled");
paramKeys.Should().NotContain("dnsSolverProvider");
paramKeys.Should().Contain("replicas");
}
[Fact]
public void LetsEncryptSchema_ContainsSolverAndEmailParameters()
{
// Arrange & Act — The letsencrypt schema should own all the ACME/solver
// configuration that was previously in cert-manager.
ConfigurationSchema schema = ComponentSchemas.GetSchema("letsencrypt");
// Assert
List<string> paramKeys = schema.Parameters.Select(p => p.Key).ToList();
paramKeys.Should().Contain("email");
paramKeys.Should().Contain("httpSolverEnabled");
paramKeys.Should().Contain("dnsSolverEnabled");
paramKeys.Should().Contain("dnsSolverProvider");
schema.Parameters.First(p => p.Key == "email").IsRequired.Should().BeTrue();
}
[Fact]
public void CertManagerSchema_OnlyHasScalingGroup()
{
// The cert-manager component is purely the runtime engine — its schema
// should only expose scaling settings, not TLS/issuer configuration.
ConfigurationSchema schema = ComponentSchemas.GetSchema("cert-manager");
Dictionary<string, List<ConfigurationParameter>> groups = schema.GetParametersByGroup();
groups.Should().HaveCount(1);
groups.Keys.Should().Contain("Scaling");
}
// ─── Contextual Schema Enrichment ─────────────────────────────────────
[Fact]
public void EnrichForCluster_WithIstioIngress_DefaultsToGatewayHTTPRoute()
{
// Arrange — A cluster where the ingress component reports Istio as the
// provider, with an internal gateway detected. When configuring Let's
// Encrypt on this cluster, the HTTP-01 solver should default to
// gatewayHTTPRoute mode with the internal gateway pre-selected.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasInternalGateway"] = "True",
["hasExternalGateway"] = "True"
})
};
// Act — Enrich the letsencrypt schema using the cluster's component state.
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — The HTTP-01 solver should default to Gateway API mode.
ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode");
modeParam.DefaultValue.Should().Be("gatewayHTTPRoute");
ConfigurationParameter gatewayNameParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName");
gatewayNameParam.DefaultValue.Should().Be("internal");
ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace");
gatewayNsParam.DefaultValue.Should().Be("internal-ingress");
}
[Fact]
public void EnrichForCluster_WithTraefikIngress_DefaultsToIngressMode()
{
// Arrange — A cluster using Traefik for ingress. The HTTP-01 solver
// should default to ingress mode with "traefik" as the class.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "traefik",
["ingressClassRegistered"] = "True"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — Should remain in ingress mode with traefik class.
ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode");
modeParam.DefaultValue.Should().Be("ingress");
ConfigurationParameter classParam = enriched.Parameters.First(p => p.Key == "httpSolverIngressClass");
classParam.DefaultValue.Should().Be("traefik");
}
[Fact]
public void EnrichForCluster_WithNoIngressComponent_UsesStaticDefaults()
{
// Arrange — No ingress component detected on the cluster. The schema
// should return unchanged static defaults.
List<ClusterComponent> components = new();
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — Static defaults should be preserved.
ConfigurationParameter modeParam = enriched.Parameters.First(p => p.Key == "httpSolverMode");
modeParam.DefaultValue.Should().Be("ingress");
}
[Fact]
public void EnrichForCluster_WithIstioAndDiscoveredGateways_SetsGatewayAsSelect()
{
// Arrange — A cluster with Istio and discovered gateways stored in
// the ingress component's config. The httpSolverGatewayName should
// become a Select parameter with the available gateways as options.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasInternalGateway"] = "True",
["hasExternalGateway"] = "True"
}),
BuildComponent("letsencrypt", ComponentStatus.Installed, new Dictionary<string, string>
{
["availableGateways"] = "[{\"name\":\"internal\",\"namespace\":\"internal-ingress\"},{\"name\":\"external\",\"namespace\":\"external-ingress\"}]"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — Gateway name should be a Select with discovered gateways as options.
ConfigurationParameter gatewayParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayName");
gatewayParam.Type.Should().Be(ParameterType.Select);
gatewayParam.AllowedValues.Should().Contain("internal");
gatewayParam.AllowedValues.Should().Contain("external");
}
[Fact]
public void EnrichForCluster_NonLetsEncryptComponent_ReturnsUnchanged()
{
// Arrange — Enriching a component other than letsencrypt should
// return the static schema unchanged.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("cert-manager", components);
// Assert — cert-manager schema should be unchanged.
ConfigurationSchema original = ComponentSchemas.GetSchema("cert-manager");
enriched.Parameters.Should().HaveCount(original.Parameters.Count);
}
[Fact]
public void EnrichForCluster_WithIstioAndExistingLetsEncryptConfig_PreservesDiscoveredValues()
{
// Arrange — A cluster where Let's Encrypt was already configured with
// specific values. The enrichment should set defaults from the ingress
// provider but the existing discovered config (email, solver type) is
// separate and handled by the UI pre-fill, not schema defaults.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasInternalGateway"] = "True",
["hasExternalGateway"] = "False"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("letsencrypt", components);
// Assert — With only internal gateway, external should not appear in options.
// The gateway namespace should default to internal-ingress.
ConfigurationParameter gatewayNsParam = enriched.Parameters.First(p => p.Key == "httpSolverGatewayNamespace");
gatewayNsParam.DefaultValue.Should().Be("internal-ingress");
}
[Fact]
public void EnrichForCluster_IngressWithExternalGateway_DefaultsEnableExternalToTrue()
{
// Arrange — The cluster scan detected an external gateway. When the
// user opens the ingress configuration, the enable_external toggle
// should default to true so it reflects reality.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasExternalGateway"] = "True"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components);
// Assert — enable_external should default to "true".
ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external");
extParam.DefaultValue.Should().Be("true");
}
[Fact]
public void EnrichForCluster_IngressWithoutExternalGateway_DefaultsEnableExternalToFalse()
{
// Arrange — No external gateway detected. The default should stay false.
List<ClusterComponent> components = new()
{
BuildComponent("ingress", ComponentStatus.Installed, new Dictionary<string, string>
{
["provider"] = "istio",
["hasExternalGateway"] = "False"
})
};
// Act
ConfigurationSchema enriched = ComponentSchemas.EnrichForCluster("ingress", components);
// Assert — enable_external should remain "false".
ConfigurationParameter extParam = enriched.Parameters.First(p => p.Key == "enable_external");
extParam.DefaultValue.Should().Be("false");
}
// ─── Helpers ──────────────────────────────────────────────────────────
private static ClusterComponent BuildComponent(
string name, ComponentStatus status, Dictionary<string, string> config)
{
ComponentCheckResult checkResult = new(
name, status, new List<string>(), new List<string>(),
new DiscoveredConfiguration(null, null, null, config));
return ClusterComponent.FromCheckResult(checkResult);
}
[Fact]
public void ParameterType_IncludesPostgresCluster()
{
// The UI needs a PostgresCluster parameter type to render a CNPG cluster
// picker, similar to how StorageBucket renders a bucket picker.
ParameterType type = ParameterType.PostgresCluster;
type.Should().BeDefined();
}
}

View File

@@ -0,0 +1,197 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class ConfigureComponentHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> monitoringInstaller;
private readonly Mock<IComponentInstaller> kyvernoInstaller;
private readonly ConfigureComponentHandler handler;
public ConfigureComponentHandlerTests()
{
repository = new InMemoryClusterRepository();
monitoringInstaller = new Mock<IComponentInstaller>();
monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring");
kyvernoInstaller = new Mock<IComponentInstaller>();
kyvernoInstaller.Setup(i => i.ComponentName).Returns("kyverno");
List<IComponentInstaller> installers = new() { monitoringInstaller.Object, kyvernoInstaller.Object };
handler = new ConfigureComponentHandler(repository, installers);
}
[Fact]
public async Task HandleAsync_WithValidComponent_CallsConfigureAsync()
{
// Arrange — A connected cluster and a request to change Prometheus retention.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
monitoringInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "monitoring", "Reconfigured", new List<string> { "retention=90d" }));
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.Success.Should().BeTrue();
result.Value.Message.Should().Be("Reconfigured");
monitoringInstaller.Verify(
i => i.ConfigureAsync(cluster, It.Is<ComponentConfiguration>(c => c.Values["retention"] == "90d"), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_WithDisconnectedCluster_ReturnsFailure()
{
// Arrange — Cluster that isn't connected can't be configured.
KubernetesCluster cluster = KubernetesCluster.Register(
"offline-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
ConfigureComponentRequest request = new("monitoring", Values: new Dictionary<string, string>
{
["retention"] = "90d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_WithUnknownComponent_ReturnsFailure()
{
// Arrange — Request an installer that doesn't exist.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ConfigureComponentRequest request = new("nonexistent-thing", Values: new Dictionary<string, string>
{
["foo"] = "bar"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("No installer found");
result.Error.Should().Contain("monitoring");
result.Error.Should().Contain("kyverno");
}
[Fact]
public async Task HandleAsync_WhenConfigureFails_ReturnsFailure()
{
// Arrange — The installer reports a failure.
KubernetesCluster cluster = KubernetesCluster.Register(
"broken-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "kyverno", "Helm upgrade failed: timeout", new List<string> { "Error: timeout" }));
ConfigureComponentRequest request = new("kyverno", Values: new Dictionary<string, string>
{
["admissionReplicas"] = "4"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Helm upgrade failed");
}
[Fact]
public async Task HandleAsync_PassesNamespaceToConfiguration()
{
// Arrange — Verify the namespace from the request reaches the installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
monitoringInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "monitoring", "Done", new List<string>()));
ConfigureComponentRequest request = new("monitoring", Namespace: "custom-monitoring", Values: new Dictionary<string, string>
{
["retention"] = "7d"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
monitoringInstaller.Verify(
i => i.ConfigureAsync(cluster, It.Is<ComponentConfiguration>(c => c.Namespace == "custom-monitoring"), It.IsAny<CancellationToken>()),
Times.Once);
}
}

View File

@@ -0,0 +1,448 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests the configure handler with the new component installers.
/// Verifies that reconfiguration requests route correctly and pass
/// the right configuration values to each installer.
/// </summary>
public class ConfigureNewComponentsTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> cnpgInstaller;
private readonly Mock<IComponentInstaller> redisInstaller;
private readonly Mock<IComponentInstaller> certManagerInstaller;
private readonly Mock<IComponentInstaller> externalSecretsInstaller;
private readonly Mock<IComponentInstaller> rabbitmqInstaller;
private readonly Mock<IComponentInstaller> customDnsInstaller;
private readonly ConfigureComponentHandler handler;
public ConfigureNewComponentsTests()
{
repository = new InMemoryClusterRepository();
cnpgInstaller = new Mock<IComponentInstaller>();
cnpgInstaller.Setup(i => i.ComponentName).Returns("cnpg");
redisInstaller = new Mock<IComponentInstaller>();
redisInstaller.Setup(i => i.ComponentName).Returns("redis");
certManagerInstaller = new Mock<IComponentInstaller>();
certManagerInstaller.Setup(i => i.ComponentName).Returns("cert-manager");
externalSecretsInstaller = new Mock<IComponentInstaller>();
externalSecretsInstaller.Setup(i => i.ComponentName).Returns("external-secrets");
rabbitmqInstaller = new Mock<IComponentInstaller>();
rabbitmqInstaller.Setup(i => i.ComponentName).Returns("rabbitmq");
customDnsInstaller = new Mock<IComponentInstaller>();
customDnsInstaller.Setup(i => i.ComponentName).Returns("custom-dns");
List<IComponentInstaller> installers = new()
{
cnpgInstaller.Object,
redisInstaller.Object,
certManagerInstaller.Object,
externalSecretsInstaller.Object,
rabbitmqInstaller.Object,
customDnsInstaller.Object
};
handler = new ConfigureComponentHandler(repository, installers);
}
[Fact]
public async Task HandleAsync_ConfigureCnpg_PassesMonitoringFlag()
{
// Arrange — Reconfigure CNPG to enable Prometheus monitoring.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
cnpgInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cnpg", "CNPG reconfigured", new List<string> { "monitoring=true" }));
ConfigureComponentRequest request = new("cnpg", Values: new Dictionary<string, string>
{
["monitoringEnabled"] = "true",
["operatorReplicas"] = "2"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
cnpgInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["monitoringEnabled"] == "true" &&
c.Values["operatorReplicas"] == "2"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureRedis_PassesSentinelAndPersistence()
{
// Arrange — Reconfigure Redis: scale replicas and change persistence size.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
redisInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "redis", "Redis reconfigured", new List<string> { "replicas=5" }));
ConfigureComponentRequest request = new("redis", Values: new Dictionary<string, string>
{
["replicaCount"] = "5",
["persistenceSize"] = "16Gi",
["maxMemory"] = "512mb"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
redisInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["replicaCount"] == "5" &&
c.Values["persistenceSize"] == "16Gi" &&
c.Values["maxMemory"] == "512mb"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureCertManager_PassesDnsSolverConfig()
{
// Arrange — Switch cert-manager to use Route53 DNS01 solver.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
certManagerInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cert-manager", "cert-manager reconfigured",
new List<string> { "Updated issuers with Route53 DNS01" }));
ConfigureComponentRequest request = new("cert-manager", Values: new Dictionary<string, string>
{
["letsEncryptEmail"] = "ops@company.com",
["dnsSolverEnabled"] = "true",
["dnsSolverProvider"] = "route53",
["dnsSolverHostedZone"] = "Z1234567890"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
certManagerInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["dnsSolverProvider"] == "route53" &&
c.Values["dnsSolverHostedZone"] == "Z1234567890"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureExternalSecrets_PassesStoreProvider()
{
// Arrange — Add an AWS Secrets Manager ClusterSecretStore.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
externalSecretsInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "external-secrets", "ESO reconfigured",
new List<string> { "ClusterSecretStore for AWS created" }));
ConfigureComponentRequest request = new("external-secrets", Values: new Dictionary<string, string>
{
["storeProvider"] = "aws",
["storeRegion"] = "eu-north-1",
["storeSecretName"] = "aws-credentials"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
externalSecretsInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["storeProvider"] == "aws" &&
c.Values["storeRegion"] == "eu-north-1"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureRabbitMQ_PassesTopologyConfig()
{
// Arrange — Enable topology operator on RabbitMQ.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
rabbitmqInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "rabbitmq", "RabbitMQ reconfigured",
new List<string> { "Topology operator enabled" }));
ConfigureComponentRequest request = new("rabbitmq", Values: new Dictionary<string, string>
{
["topologyOperator"] = "true",
["operatorReplicas"] = "2"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
rabbitmqInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["topologyOperator"] == "true" &&
c.Values["operatorReplicas"] == "2"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureCertManager_CaseInsensitiveName()
{
// Arrange — Component name matching should be case-insensitive.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
certManagerInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cert-manager", "Reconfigured", new List<string>()));
ConfigureComponentRequest request = new("Cert-Manager", Values: new Dictionary<string, string>
{
["replicas"] = "2"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
}
[Fact]
public async Task HandleAsync_ConfigureWithNamespace_PassesNamespaceThrough()
{
// Arrange — Specify a custom namespace for Redis reconfiguration.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
redisInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "redis", "Reconfigured", new List<string>()));
ConfigureComponentRequest request = new("redis", Namespace: "custom-redis-ns", Values: new Dictionary<string, string>
{
["replicaCount"] = "5"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
redisInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c => c.Namespace == "custom-redis-ns"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_ConfigureCustomDns_PassesStubZonesAndCorefile()
{
// Arrange — Reconfigure CoreDNS with new stub zones and custom Corefile entries.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
customDnsInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "custom-dns", "CoreDNS reconfigured",
new List<string> { "Stub zones updated", "Custom Corefile entries applied" }));
ConfigureComponentRequest request = new("custom-dns", Values: new Dictionary<string, string>
{
["stubZones"] = "corp.internal=10.0.0.53;svc.internal=10.0.1.53",
["customCorefileEntries"] = "log\nrewrite name api.old.local api.new.local",
["customRecords"] = "myservice.local=10.10.10.10"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
customDnsInstaller.Verify(
i => i.ConfigureAsync(cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["stubZones"] == "corp.internal=10.0.0.53;svc.internal=10.0.1.53" &&
c.Values["customCorefileEntries"] == "log\nrewrite name api.old.local api.new.local" &&
c.Values["customRecords"] == "myservice.local=10.10.10.10"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_WithSchema_RejectsInvalidIntegerValue()
{
// Arrange — CNPG has a schema requiring operatorReplicas as Integer with min/max.
// Providing a non-numeric value should fail validation before reaching the installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ConfigurationSchema cnpgSchema = ComponentSchemas.GetSchema("cnpg");
cnpgInstaller.Setup(i => i.GetSchema()).Returns(cnpgSchema);
ConfigureComponentRequest request = new("cnpg", Values: new Dictionary<string, string>
{
["operatorReplicas"] = "not-a-number"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — should fail validation without ever calling ConfigureAsync.
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("must be an integer");
cnpgInstaller.Verify(
i => i.ConfigureAsync(It.IsAny<KubernetesCluster>(), It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HandleAsync_WithSchema_RejectsValueOutOfRange()
{
// Arrange — Redis schema has maxReplicas=10 for replicas field.
// Providing a value above max should fail.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ConfigurationSchema redisSchema = ComponentSchemas.GetSchema("redis");
redisInstaller.Setup(i => i.GetSchema()).Returns(redisSchema);
ConfigureComponentRequest request = new("redis", Values: new Dictionary<string, string>
{
["replicaCount"] = "99"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("must be at most 10");
redisInstaller.Verify(
i => i.ConfigureAsync(It.IsAny<KubernetesCluster>(), It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task HandleAsync_WithSchema_PassesValidConfiguration()
{
// Arrange — Provide valid values matching the CNPG schema.
// Validation should pass and ConfigureAsync should be called.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ConfigurationSchema cnpgSchema = ComponentSchemas.GetSchema("cnpg");
cnpgInstaller.Setup(i => i.GetSchema()).Returns(cnpgSchema);
cnpgInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cnpg", "Configured", new List<string>()));
ConfigureComponentRequest request = new("cnpg", Values: new Dictionary<string, string>
{
["operatorReplicas"] = "2",
["monitoringEnabled"] = "true"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — validation passes, installer is called.
result.IsSuccess.Should().BeTrue();
cnpgInstaller.Verify(
i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()),
Times.Once);
}
}

View File

@@ -0,0 +1,62 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.DeleteCluster;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class DeleteClusterHandlerTests
{
private readonly DeleteClusterHandler handler;
private readonly InMemoryClusterRepository repository;
public DeleteClusterHandlerTests()
{
repository = new InMemoryClusterRepository();
handler = new DeleteClusterHandler(repository);
}
[Fact]
public async Task HandleAsync_WithExistingCluster_DeletesAndReturnsSuccess()
{
// Arrange — Register a cluster so we have something to delete.
KubernetesCluster cluster = KubernetesCluster.Register(
"staging",
"https://staging-k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"staging-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act — Delete it by ID.
Result result = await handler.HandleAsync(cluster.Id);
// Assert — The deletion succeeds and the cluster is no longer in the repository.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? deleted = await repository.GetByIdAsync(cluster.Id);
deleted.Should().BeNull();
}
[Fact]
public async Task HandleAsync_WithNonExistentId_ReturnsFailure()
{
// Arrange — An ID that doesn't exist.
Guid unknownId = Guid.NewGuid();
// Act
Result result = await handler.HandleAsync(unknownId);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,181 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.DeployComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class DeployComponentHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> kyvernoInstaller;
private readonly Mock<IComponentInstaller> policiesInstaller;
private readonly DeployComponentHandler handler;
public DeployComponentHandlerTests()
{
repository = new InMemoryClusterRepository();
kyvernoInstaller = new Mock<IComponentInstaller>();
policiesInstaller = new Mock<IComponentInstaller>();
kyvernoInstaller.Setup(i => i.ComponentName).Returns("kyverno");
policiesInstaller.Setup(i => i.ComponentName).Returns("security-policies");
List<IComponentInstaller> installers = new() { kyvernoInstaller.Object, policiesInstaller.Object };
handler = new DeployComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange
DeployComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ClusterNotConnected_ReturnsFailure()
{
// Arrange — Cluster is Pending, can't deploy to it.
KubernetesCluster cluster = KubernetesCluster.Register(
"pending-cluster", "https://k8s.dev:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
DeployComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_UnknownComponent_ReturnsFailureWithAvailable()
{
// Arrange — Requesting a component that has no installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
DeployComponentRequest request = new("nonexistent-thing");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Should list available installers.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("kyverno");
result.Error.Should().Contain("security-policies");
}
[Fact]
public async Task HandleAsync_ValidComponent_DelegatesToInstaller()
{
// Arrange — Deploy Kyverno on a connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "kyverno",
Message: "Kyverno 3.3.4 installed successfully",
Actions: new List<string> { "Helm install/upgrade kyverno v3.3.4" }));
DeployComponentRequest request = new("kyverno", Version: "3.3.4");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Should succeed and return installer output.
result.IsSuccess.Should().BeTrue();
result.Value!.Success.Should().BeTrue();
result.Value.ComponentName.Should().Be("kyverno");
result.Value.Actions.Should().Contain("Helm install/upgrade kyverno v3.3.4");
}
[Fact]
public async Task HandleAsync_InstallerFails_ReturnsFailure()
{
// Arrange — The installer encounters an error (e.g., helm timeout).
KubernetesCluster cluster = KubernetesCluster.Register(
"flaky-cluster", "https://k8s.flaky:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "kyverno",
Message: "Helm failed (exit 1): timed out waiting for condition",
Actions: new List<string> { "Error: timed out" }));
DeployComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("timed out");
}
[Fact]
public async Task HandleAsync_PassesOptionsToInstaller()
{
// Arrange — Deploy security policies with custom parameters.
KubernetesCluster cluster = KubernetesCluster.Register(
"config-cluster", "https://k8s.cfg:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ComponentInstallOptions? capturedOptions = null;
policiesInstaller.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.Callback<KubernetesCluster, ComponentInstallOptions, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new InstallResult(true, "security-policies", "Done", new List<string>()));
DeployComponentRequest request = new(
"security-policies",
Parameters: new Dictionary<string, string> { ["validationAction"] = "Enforce" });
// Act
await handler.HandleAsync(cluster.Id, request);
// Assert — The installer received the custom parameters.
capturedOptions.Should().NotBeNull();
capturedOptions!.Parameters.Should().ContainKey("validationAction");
capturedOptions.Parameters!["validationAction"].Should().Be("Enforce");
}
}

View File

@@ -0,0 +1,324 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
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 the deploy handler with all new component installers registered.
/// Verifies that the handler correctly routes to the right installer based on
/// component name and passes parameters through.
/// </summary>
public class DeployNewComponentsTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> cnpgInstaller;
private readonly Mock<IComponentInstaller> redisInstaller;
private readonly Mock<IComponentInstaller> certManagerInstaller;
private readonly Mock<IComponentInstaller> externalSecretsInstaller;
private readonly Mock<IComponentInstaller> rabbitmqInstaller;
private readonly Mock<IComponentInstaller> customDnsInstaller;
private readonly DeployComponentHandler handler;
public DeployNewComponentsTests()
{
repository = new InMemoryClusterRepository();
cnpgInstaller = new Mock<IComponentInstaller>();
cnpgInstaller.Setup(i => i.ComponentName).Returns("cnpg");
redisInstaller = new Mock<IComponentInstaller>();
redisInstaller.Setup(i => i.ComponentName).Returns("redis");
certManagerInstaller = new Mock<IComponentInstaller>();
certManagerInstaller.Setup(i => i.ComponentName).Returns("cert-manager");
externalSecretsInstaller = new Mock<IComponentInstaller>();
externalSecretsInstaller.Setup(i => i.ComponentName).Returns("external-secrets");
rabbitmqInstaller = new Mock<IComponentInstaller>();
rabbitmqInstaller.Setup(i => i.ComponentName).Returns("rabbitmq");
customDnsInstaller = new Mock<IComponentInstaller>();
customDnsInstaller.Setup(i => i.ComponentName).Returns("custom-dns");
List<IComponentInstaller> installers = new()
{
cnpgInstaller.Object,
redisInstaller.Object,
certManagerInstaller.Object,
externalSecretsInstaller.Object,
rabbitmqInstaller.Object,
customDnsInstaller.Object
};
handler = new DeployComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
}
[Fact]
public async Task HandleAsync_DeployCnpg_CallsInstaller()
{
// Arrange — A connected cluster wants CloudNativePG installed.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
cnpgInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cnpg", "CloudNativePG 0.22.0 installed", new List<string> { "Helm install" }));
DeployComponentRequest request = new("cnpg", Version: "0.22.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.ComponentName.Should().Be("cnpg");
cnpgInstaller.Verify(
i => i.InstallAsync(cluster, It.Is<ComponentInstallOptions>(o => o.Version == "0.22.0"), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DeployRedis_PassesSentinelParameter()
{
// Arrange — Deploy Redis with Sentinel HA enabled.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
redisInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "redis", "Redis installed", new List<string> { "Helm install" }));
DeployComponentRequest request = new("redis", Parameters: new Dictionary<string, string>
{
["sentinelEnabled"] = "true",
["replicaCount"] = "3"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
redisInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["sentinelEnabled"] == "true" &&
o.Parameters["replicaCount"] == "3"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DeployCertManager_PassesLetsEncryptConfig()
{
// Arrange — Deploy cert-manager with Let's Encrypt + Cloudflare DNS01.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
certManagerInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "cert-manager", "cert-manager installed with LE issuers",
new List<string> { "Helm install", "ClusterIssuer created" }));
DeployComponentRequest request = new("cert-manager", Parameters: new Dictionary<string, string>
{
["letsEncryptEmail"] = "admin@example.com",
["httpSolverEnabled"] = "true",
["dnsSolverEnabled"] = "true",
["dnsSolverProvider"] = "cloudflare",
["dnsSolverSecretName"] = "cloudflare-api-token"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
certManagerInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["letsEncryptEmail"] == "admin@example.com" &&
o.Parameters["dnsSolverProvider"] == "cloudflare"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DeployExternalSecrets_PassesVaultConfig()
{
// Arrange — Deploy ESO with Vault as the secret store provider.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
externalSecretsInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "external-secrets", "ESO installed",
new List<string> { "Helm install", "ClusterSecretStore created" }));
DeployComponentRequest request = new("external-secrets", Parameters: new Dictionary<string, string>
{
["storeProvider"] = "vault",
["storeEndpoint"] = "http://vault.vault:8200"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
externalSecretsInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["storeProvider"] == "vault" &&
o.Parameters["storeEndpoint"] == "http://vault.vault:8200"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DeployRabbitMQ_PassesTopologyOperatorParam()
{
// Arrange — Deploy RabbitMQ with messaging topology operator.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
rabbitmqInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "rabbitmq", "RabbitMQ operator installed",
new List<string> { "Helm install", "Topology operator enabled" }));
DeployComponentRequest request = new("rabbitmq", Parameters: new Dictionary<string, string>
{
["topologyOperator"] = "true",
["monitoringEnabled"] = "true"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
rabbitmqInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["topologyOperator"] == "true"),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_DisconnectedCluster_ReturnsFailure()
{
// Arrange — A cluster that isn't connected can't have components deployed.
KubernetesCluster cluster = KubernetesCluster.Register(
"offline-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
DeployComponentRequest request = new("cnpg");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_InstallerFails_ReturnsFailure()
{
// Arrange — The installer reports a failure.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
redisInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "redis", "Helm timeout", new List<string> { "Error: timeout" }));
DeployComponentRequest request = new("redis");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Helm timeout");
}
[Fact]
public async Task HandleAsync_DeployCustomDns_PassesStubZonesAndRecords()
{
// Arrange — Deploy custom DNS with stub zones and static records.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.example.com:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
customDnsInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "custom-dns", "CoreDNS customized",
new List<string> { "Stub zones configured", "Custom records applied" }));
DeployComponentRequest request = new("custom-dns", Parameters: new Dictionary<string, string>
{
["stubZones"] = "corp.internal=10.0.0.53,10.0.0.54;db.internal=10.1.0.53",
["customRecords"] = "api.local=192.168.1.100;cache.local=192.168.1.101"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.ComponentName.Should().Be("custom-dns");
customDnsInstaller.Verify(
i => i.InstallAsync(cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["stubZones"] == "corp.internal=10.0.0.53,10.0.0.54;db.internal=10.1.0.53" &&
o.Parameters["customRecords"] == "api.local=192.168.1.100;cache.local=192.168.1.101"),
It.IsAny<CancellationToken>()),
Times.Once);
}
}

View File

@@ -0,0 +1,162 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.DiscoverPrometheus;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class DiscoverPrometheusHandlerTests
{
private readonly InMemoryClusterRepository repository;
public DiscoverPrometheusHandlerTests()
{
repository = new InMemoryClusterRepository();
}
[Fact]
public async Task HandleAsync_WithDiscoveredEndpoints_StoresThemOnCluster()
{
// Arrange — A registered cluster and a scanner that finds two Prometheus instances.
KubernetesCluster cluster = KubernetesCluster.Register(
"production",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
List<PrometheusEndpoint> fakeEndpoints = new()
{
new PrometheusEndpoint("http://prometheus.monitoring:9090", "monitoring", "prometheus-server"),
new PrometheusEndpoint("http://prometheus.observability:9090", "observability", "prom-stack")
};
FakePrometheusScanner scanner = new(fakeEndpoints);
DiscoverPrometheusHandler handler = new(repository, scanner);
// Act — Run discovery on the cluster.
Result<List<PrometheusEndpoint>> result = await handler.HandleAsync(cluster.Id);
// Assert — Both endpoints should be stored and returned.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
result.Value![0].Namespace.Should().Be("monitoring");
result.Value[1].Namespace.Should().Be("observability");
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.PrometheusEndpoints.Should().HaveCount(2);
}
[Fact]
public async Task HandleAsync_WithNoPrometheus_ReturnsEmptyList()
{
// Arrange — A cluster with no Prometheus running.
KubernetesCluster cluster = KubernetesCluster.Register(
"staging",
"https://staging.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"staging-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
FakePrometheusScanner scanner = new(new List<PrometheusEndpoint>());
DiscoverPrometheusHandler handler = new(repository, scanner);
// Act
Result<List<PrometheusEndpoint>> result = await handler.HandleAsync(cluster.Id);
// Assert — Success but empty list. No Prometheus found is not an error.
result.IsSuccess.Should().BeTrue();
result.Value.Should().BeEmpty();
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
FakePrometheusScanner scanner = new(new List<PrometheusEndpoint>());
DiscoverPrometheusHandler handler = new(repository, scanner);
// Act
Result<List<PrometheusEndpoint>> result = await handler.HandleAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ReplacesExistingEndpoints_OnRediscovery()
{
// Arrange — A cluster that already had endpoints from a previous scan.
KubernetesCluster cluster = KubernetesCluster.Register(
"production",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
cluster.SetPrometheusEndpoints(new List<PrometheusEndpoint>
{
new("http://old-prometheus:9090", "legacy", "old-prom")
});
await repository.AddAsync(cluster);
// A new scan finds a different Prometheus.
List<PrometheusEndpoint> newEndpoints = new()
{
new PrometheusEndpoint("http://prometheus.monitoring:9090", "monitoring", "prometheus-server")
};
FakePrometheusScanner scanner = new(newEndpoints);
DiscoverPrometheusHandler handler = new(repository, scanner);
// Act
Result<List<PrometheusEndpoint>> result = await handler.HandleAsync(cluster.Id);
// Assert — The old endpoint is replaced by the new one.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(1);
result.Value![0].ServiceName.Should().Be("prometheus-server");
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.PrometheusEndpoints.Should().HaveCount(1);
}
}
/// <summary>
/// Test double that returns pre-configured Prometheus endpoints
/// without actually connecting to a Kubernetes cluster.
/// </summary>
public class FakePrometheusScanner : IPrometheusScanner
{
private readonly List<PrometheusEndpoint> endpoints;
public FakePrometheusScanner(List<PrometheusEndpoint> endpoints)
{
this.endpoints = endpoints;
}
public Task<List<PrometheusEndpoint>> ScanAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
return Task.FromResult(endpoints);
}
}

View File

@@ -0,0 +1,142 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Infrastructure;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Integration tests for the EF Core cluster repository. These tests use a real
/// SQLite database to verify that EF change tracking works correctly — something
/// the in-memory repository can't catch. The key scenario: adding a child entity
/// (StorageBucket) to an already-tracked aggregate and saving without a
/// DbUpdateConcurrencyException.
/// </summary>
public class EfClusterRepositoryTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ClustersDbContext db;
private readonly EfClusterRepository repository;
public EfClusterRepositoryTests()
{
// Use an in-memory SQLite database that persists for the test lifetime.
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ClustersDbContext> options = new DbContextOptionsBuilder<ClustersDbContext>()
.UseSqlite(connection)
.Options;
db = new ClustersDbContext(options);
db.Database.EnsureCreated();
repository = new EfClusterRepository(db);
}
[Fact]
public async Task UpdateAsync_AddingStorageBucket_DoesNotThrowConcurrencyException()
{
// Arrange — Register a cluster and persist it to the database.
// This simulates the real flow: a cluster already exists, and
// the user creates a storage bucket that needs to be saved.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s:6443", "kubeconfig-data",
Guid.NewGuid(), "test-ctx", Guid.NewGuid());
cluster.MarkConnected();
cluster.SetProvider(CloudProvider.Cleura, new ProviderCredentials("user", "pass", "eu-north-1"));
await repository.AddAsync(cluster);
// Act — Load the cluster back, add a new storage bucket, then save.
// Before the fix, db.Clusters.Update() would mark the new bucket as
// Modified instead of Added, causing DbUpdateConcurrencyException.
KubernetesCluster? loaded = await repository.GetByIdAsync(cluster.Id);
loaded.Should().NotBeNull();
StorageBucket bucket = StorageBucket.Create(
"my-encrypted-bucket", "AKID", "SECRET", "https://s3.example.com", "eu-north-1", true);
loaded!.AddStorageBucket(bucket);
Func<Task> act = async () => await repository.UpdateAsync(loaded);
// Assert — Should save without throwing.
await act.Should().NotThrowAsync<DbUpdateConcurrencyException>();
// Verify the bucket was actually persisted.
KubernetesCluster? reloaded = await repository.GetByIdAsync(cluster.Id);
reloaded!.StorageBuckets.Should().HaveCount(1);
reloaded.StorageBuckets[0].Name.Should().Be("my-encrypted-bucket");
reloaded.StorageBuckets[0].Encrypted.Should().BeTrue();
}
[Fact]
public async Task UpdateAsync_ReplacingComponents_PersistsNewComponents()
{
// Arrange — Register a cluster, run a first adoption scan, and persist.
// This mirrors the real flow: adoption scan detects components, then
// a second scan replaces them with fresh results.
KubernetesCluster cluster = KubernetesCluster.Register(
"scan-cluster", "https://k8s:6443", "kubeconfig-data",
Guid.NewGuid(), "scan-ctx", Guid.NewGuid());
cluster.MarkConnected();
// Simulate first adoption scan — detect kyverno as installed.
List<ComponentCheckResult> firstScan = new()
{
new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running" }, new List<string>(),
new DiscoveredConfiguration("3.3.4", "kyverno", "kyverno", new Dictionary<string, string>()))
};
cluster.UpdateComponents(firstScan);
await repository.AddAsync(cluster);
// Act — Load the cluster, run a second scan with different results,
// and save. This is the adoption flow: UpdateComponents replaces the
// backing field, then UpdateAsync persists the change.
KubernetesCluster? loaded = await repository.GetByIdAsync(cluster.Id);
loaded.Should().NotBeNull();
loaded!.Components.Should().HaveCount(1);
List<ComponentCheckResult> secondScan = new()
{
new ComponentCheckResult("kyverno", ComponentStatus.Installed,
new List<string> { "Pod running" }, new List<string>(),
new DiscoveredConfiguration("3.8.0", "kyverno", "kyverno", new Dictionary<string, string>())),
new ComponentCheckResult("cert-manager", ComponentStatus.NotInstalled,
new List<string>(), new List<string> { "No pods found" })
};
loaded.UpdateComponents(secondScan);
Func<Task> act = async () => await repository.UpdateAsync(loaded);
// Assert — Should save without error and persist both components.
await act.Should().NotThrowAsync();
KubernetesCluster? reloaded = await repository.GetByIdAsync(cluster.Id);
reloaded!.Components.Should().HaveCount(2);
reloaded.Components.Should().Contain(c => c.ComponentName == "kyverno" && c.Version == "3.8.0");
reloaded.Components.Should().Contain(c => c.ComponentName == "cert-manager");
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
}

View File

@@ -0,0 +1,61 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.GetClusterById;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class GetClusterByIdHandlerTests
{
private readonly GetClusterByIdHandler handler;
private readonly InMemoryClusterRepository repository;
public GetClusterByIdHandlerTests()
{
repository = new InMemoryClusterRepository();
handler = new GetClusterByIdHandler(repository);
}
[Fact]
public async Task HandleAsync_WithExistingCluster_ReturnsSuccess()
{
// Arrange — Register a cluster so the repository has something to find.
KubernetesCluster cluster = KubernetesCluster.Register(
"production-eu",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act — Look it up by its ID.
Result<KubernetesCluster> result = await handler.HandleAsync(cluster.Id);
// Assert — The handler should return the full cluster aggregate.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBeNull();
result.Value!.Name.Should().Be("production-eu");
}
[Fact]
public async Task HandleAsync_WithNonExistentId_ReturnsFailure()
{
// Arrange — An ID that doesn't exist in the repository.
Guid unknownId = Guid.NewGuid();
// Act
Result<KubernetesCluster> result = await handler.HandleAsync(unknownId);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,348 @@
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.Components.Gitea;
using EntKube.Clusters.Infrastructure.Cleura;
using FluentAssertions;
using k8s.Models;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class GiteaComponentTests
{
[Fact]
public void GiteaInstaller_HasCorrectComponentName()
{
// The installer's ComponentName must match the check's ComponentName
// so the coordinator can pair them correctly.
GiteaInstaller installer = new(
new Microsoft.Extensions.Logging.Abstractions.NullLogger<GiteaInstaller>(),
new CleuraS3Client(Mock.Of<IHttpClientFactory>()),
Mock.Of<IHttpClientFactory>());
installer.ComponentName.Should().Be("gitea");
}
[Fact]
public void GiteaCheck_HasCorrectComponentName()
{
// The check's ComponentName identifies which component is being evaluated
// during cluster adoption scans.
GiteaCheck check = new(new Microsoft.Extensions.Logging.Abstractions.NullLogger<GiteaCheck>());
check.ComponentName.Should().Be("gitea");
}
[Fact]
public void GiteaCheck_HasDisplayName()
{
// The display name is shown in the UI so users can identify the component.
GiteaCheck check = new(new Microsoft.Extensions.Logging.Abstractions.NullLogger<GiteaCheck>());
check.DisplayName.Should().NotBeNullOrWhiteSpace();
check.DisplayName.Should().Contain("Gitea");
}
[Fact]
public void GiteaSchema_IsRegistered()
{
// The configuration schema must be registered so the UI can render
// typed configuration forms for the Gitea component.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.ComponentName.Should().Be("gitea");
schema.Parameters.Should().NotBeEmpty();
}
[Fact]
public void GiteaSchema_ContainsReplicasParameter()
{
// The replicas parameter controls how many Gitea pods are deployed.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "replicas");
}
[Fact]
public void GiteaSchema_ContainsActionsParameter()
{
// Gitea Actions (CI/CD) is a key feature that can be toggled.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "actionsEnabled");
}
[Fact]
public void GiteaSchema_ContainsStorageParameter()
{
// Repository persistence size is configurable for capacity planning.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "persistenceSize");
}
[Fact]
public void GiteaSchema_ContainsSshParameter()
{
// SSH access can be enabled/disabled depending on security requirements.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "sshEnabled");
}
[Fact]
public void GiteaInstaller_GetSchema_ReturnsGiteaSchema()
{
// The installer's default GetSchema() implementation should delegate
// to ComponentSchemas and return the gitea schema.
IComponentInstaller installer = new GiteaInstaller(
new Microsoft.Extensions.Logging.Abstractions.NullLogger<GiteaInstaller>(),
new CleuraS3Client(Mock.Of<IHttpClientFactory>()),
Mock.Of<IHttpClientFactory>());
ConfigurationSchema schema = installer.GetSchema();
schema.ComponentName.Should().Be("gitea");
schema.Parameters.Should().NotBeEmpty();
}
[Fact]
public void BuildActRunnerStatefulSet_DefaultReplicas_ReturnsValidStatefulSet()
{
// Arrange & Act
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 2, "10Gi");
// Assert
sts.Metadata.Name.Should().Be("act-runner");
sts.Metadata.NamespaceProperty.Should().Be("gitea");
sts.Spec.Replicas.Should().Be(2);
sts.Spec.ServiceName.Should().Be("act-runner");
}
[Fact]
public void BuildActRunnerStatefulSet_UsesCorrectImage()
{
// Arrange & Act
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi");
// Assert
V1Container container = sts.Spec.Template.Spec.Containers[0];
container.Image.Should().Be("gitea/act_runner:latest-dind-rootless");
container.Name.Should().Be("runner");
}
[Fact]
public void BuildActRunnerStatefulSet_SetsEnvironmentVariables()
{
// Arrange & Act
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi");
// Assert — runner needs DOCKER_HOST and GITEA_INSTANCE_URL at minimum
V1Container container = sts.Spec.Template.Spec.Containers[0];
container.Env.Should().Contain(e => e.Name == "DOCKER_HOST");
container.Env.Should().Contain(e => e.Name == "GITEA_INSTANCE_URL");
container.Env.Should().Contain(e => e.Name == "GITEA_RUNNER_REGISTRATION_TOKEN");
}
[Fact]
public void BuildActRunnerStatefulSet_ConfiguresPvcWithRequestedSize()
{
// Arrange & Act
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 3, "20Gi");
// Assert
V1PersistentVolumeClaim pvc = sts.Spec.VolumeClaimTemplates[0];
pvc.Metadata.Name.Should().Be("runner-data");
pvc.Spec.Resources.Requests["storage"].ToString().Should().Be("20Gi");
}
[Fact]
public void BuildActRunnerStatefulSet_WithHarborUrl_SetsRegistryMirrorEnv()
{
// Arrange & Act — provide a Harbor registry URL for the runners.
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi", "harbor.example.com");
// Assert — the Docker daemon should be configured to mirror through Harbor.
V1Container container = sts.Spec.Template.Spec.Containers[0];
container.Env.Should().Contain(e =>
e.Name == "DOCKERD_ROOTLESS_FLAGS" &&
e.Value == "--registry-mirror=https://harbor.example.com");
}
[Fact]
public void BuildActRunnerStatefulSet_WithoutHarborUrl_NoMirrorEnv()
{
// Arrange & Act — no Harbor URL, runners pull images directly.
V1StatefulSet sts = GiteaInstaller.BuildActRunnerStatefulSet("gitea", 1, "5Gi");
// Assert — no Docker daemon mirror flag should be present.
V1Container container = sts.Spec.Template.Spec.Containers[0];
container.Env.Should().NotContain(e => e.Name == "DOCKERD_ROOTLESS_FLAGS");
}
[Fact]
public void GiteaSchema_ContainsCnpgClusterNameParameter()
{
// Gitea should reference the shared CNPG cluster for its database.
// The cnpgClusterName parameter tells the installer which CNPG cluster
// to create the gitea database on, instead of requiring manual dbHost.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p =>
p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster);
}
[Fact]
public void GiteaSchema_ContainsCnpgNamespaceParameter()
{
// The namespace where the CNPG cluster lives — needed to resolve
// the service endpoint ({clusterName}-rw.{namespace}.svc).
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace");
}
[Fact]
public void GiteaSchema_IngressProviderIsSelect()
{
// Ingress provider should be a dropdown selector, not a free-text
// field, so users pick from the supported providers.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
ConfigurationParameter ingressProvider = schema.Parameters.First(p => p.Key == "ingressProvider");
ingressProvider.Type.Should().Be(ParameterType.Select);
ingressProvider.AllowedValues.Should().Contain("traefik");
ingressProvider.AllowedValues.Should().Contain("istio");
ingressProvider.AllowedValues.Should().Contain("gatewayapi");
}
[Fact]
public void GiteaSchema_RedisClusterIsRedisClusterType()
{
// Redis should be a cluster selector dropdown, not a manual host
// textbox. The UI populates it from provisioned Redis clusters.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p =>
p.Key == "redisClusterName" && p.Type == ParameterType.RedisCluster);
}
[Fact]
public void GiteaSchema_DatabaseFieldsDoNotIncludeManualHostUserPassword()
{
// With the CNPG cluster selector, manual dbHost/dbUser/dbPassword
// fields are no longer needed — credentials are auto-generated
// and stored in the secrets vault.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().NotContain(p => p.Key == "dbHost");
schema.Parameters.Should().NotContain(p => p.Key == "dbUser");
schema.Parameters.Should().NotContain(p => p.Key == "dbPassword");
}
[Fact]
public void GiteaSchema_ContainsOidcParameters()
{
// OIDC integration allows Gitea to authenticate users via an external
// identity provider like Keycloak.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "oidcEnabled" && p.Type == ParameterType.Boolean);
schema.Parameters.Should().Contain(p => p.Key == "oidcDiscoveryUrl");
schema.Parameters.Should().Contain(p => p.Key == "oidcClientId");
schema.Parameters.Should().Contain(p => p.Key == "oidcClientSecret");
}
[Fact]
public void GiteaSchema_OidcFieldsDependOnOidcEnabled()
{
// OIDC-specific fields should only be visible when oidcEnabled is true.
// This keeps the UI clean for installations without OIDC.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
ConfigurationParameter discoveryUrl = schema.Parameters.First(p => p.Key == "oidcDiscoveryUrl");
discoveryUrl.DependsOnKey.Should().Be("oidcEnabled");
discoveryUrl.DependsOnValues.Should().Contain("true");
}
[Fact]
public void GiteaSchema_ObjectStorageCascading()
{
// Object storage fields should cascade based on the objectStorageType
// selection — selecting "bucket" shows the bucket selector, "s3" shows
// manual S3 fields, "azure" shows Azure fields, "none" hides everything.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
ConfigurationParameter storageType = schema.Parameters.First(p => p.Key == "objectStorageType");
storageType.Type.Should().Be(ParameterType.Select);
storageType.AllowedValues.Should().Contain("none");
storageType.AllowedValues.Should().Contain("bucket");
storageType.AllowedValues.Should().Contain("s3");
storageType.AllowedValues.Should().Contain("azure");
ConfigurationParameter bucket = schema.Parameters.First(p => p.Key == "storageBucketId");
bucket.DependsOnKey.Should().Be("objectStorageType");
bucket.DependsOnValues.Should().Contain("bucket");
ConfigurationParameter s3Endpoint = schema.Parameters.First(p => p.Key == "s3Endpoint");
s3Endpoint.DependsOnKey.Should().Be("objectStorageType");
s3Endpoint.DependsOnValues.Should().Contain("s3");
ConfigurationParameter azureName = schema.Parameters.First(p => p.Key == "azureAccountName");
azureName.DependsOnKey.Should().Be("objectStorageType");
azureName.DependsOnValues.Should().Contain("azure");
}
[Fact]
public void GiteaSchema_ContainsVersionParameter()
{
// Users should be able to select which version of Gitea to install
// or upgrade to.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p => p.Key == "version");
}
[Fact]
public void GiteaSchema_ContainsProjectsParameter()
{
// Projects (kanban boards) should be configurable per installation.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
schema.Parameters.Should().Contain(p =>
p.Key == "projectsEnabled" && p.Type == ParameterType.Boolean);
}
[Fact]
public void GiteaSchema_SshPortDependsOnSshEnabled()
{
// SSH port should only be visible when SSH is enabled.
ConfigurationSchema schema = ComponentSchemas.GetSchema("gitea");
ConfigurationParameter sshPort = schema.Parameters.First(p => p.Key == "sshPort");
sshPort.DependsOnKey.Should().Be("sshEnabled");
sshPort.DependsOnValues.Should().Contain("true");
}
}

View File

@@ -0,0 +1,728 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.Components;
using EntKube.Clusters.Features.AdoptCluster.ConfigureComponent;
using EntKube.Clusters.Features.AdoptCluster.DeployComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using k8s.Models;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the Harbor container registry component. Harbor provides:
///
/// 1. **Container registry** — hosting private container images
/// 2. **Helm chart repository** — hosting Helm charts via OCI
/// 3. **Pull-through cache** — caching upstream registries (Docker Hub, ghcr.io, quay.io, etc.)
///
/// The pull-through cache is especially valuable: all services and tenant apps
/// pull images through Harbor, gaining caching, vulnerability scanning, and
/// network isolation from public registries.
///
/// Dependency chain: ingress (for Harbor UI/API) → cert-manager (TLS) → Harbor
/// </summary>
public class HarborTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> harborInstaller;
public HarborTests()
{
repository = new InMemoryClusterRepository();
harborInstaller = new Mock<IComponentInstaller>();
harborInstaller.Setup(i => i.ComponentName).Returns("harbor");
}
// ─── Harbor Installation ──────────────────────────────────────────────────
[Fact]
public async Task Harbor_Install_DeploysRegistryWithPullThroughCache()
{
// Arrange — We want a full Harbor deployment with pull-through proxy cache
// enabled for major upstream registries. This gives us a local mirror that
// speeds up pulls and survives upstream outages.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Harbor 2.12.0 installed with pull-through cache for docker.io, ghcr.io, quay.io",
Actions: new List<string>
{
"Ensured namespace 'harbor'",
"Helm install harbor v2.12.0",
"Created proxy cache project 'dockerhub-cache' → docker.io",
"Created proxy cache project 'ghcr-cache' → ghcr.io",
"Created proxy cache project 'quay-cache' → quay.io",
"Harbor registry available at registry.internal.corp.com"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor", Version: "2.12.0",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.internal.corp.com",
["adminPassword"] = "Harbor12345",
["storageClass"] = "longhorn",
["storageSize"] = "100Gi",
["cacheRegistries"] = "docker.io,ghcr.io,quay.io",
["enableChartMuseum"] = "true"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Harbor deployed with proxy cache projects for each registry.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("Helm install harbor"));
result.Value.Actions.Should().Contain(a => a.Contains("dockerhub-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("ghcr-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("quay-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("registry.internal.corp.com"));
harborInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["harborDomain"] == "registry.internal.corp.com" &&
o.Parameters["cacheRegistries"] == "docker.io,ghcr.io,quay.io"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Harbor_Install_WithTLS_UsesClusterIssuerForCertificate()
{
// Arrange — Harbor should use a cert-manager ClusterIssuer to auto-provision
// TLS certificates. This integrates with the internal CA or domain CA
// we set up earlier.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Harbor 2.12.0 installed with TLS via ClusterIssuer 'platform-internal-ca'",
Actions: new List<string>
{
"Ensured namespace 'harbor'",
"Helm install harbor v2.12.0",
"Configured TLS via ClusterIssuer 'platform-internal-ca'",
"Created Ingress for registry.internal.corp.com with TLS",
"Harbor registry available at registry.internal.corp.com"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor", Version: "2.12.0",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.internal.corp.com",
["adminPassword"] = "Harbor12345",
["storageClass"] = "longhorn",
["storageSize"] = "50Gi",
["tlsIssuer"] = "platform-internal-ca",
["enableTLS"] = "true"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — TLS configured via cert-manager integration.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("TLS via ClusterIssuer"));
result.Value.Actions.Should().Contain(a => a.Contains("Ingress"));
}
[Fact]
public async Task Harbor_Install_WithoutPrerequisites_ReportsFailure()
{
// Arrange — Harbor requires ingress to be accessible. If the cluster
// has no ingress controller, the install should fail gracefully.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "harbor",
Message: "Harbor requires an ingress controller but none was detected",
Actions: new List<string> { "Prerequisite check failed: no ingress controller found" }));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.internal.corp.com",
["adminPassword"] = "Harbor12345"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Prerequisite failure reported.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("ingress");
}
// ─── Harbor Configuration ─────────────────────────────────────────────────
[Fact]
public async Task Harbor_Configure_AddProxyCacheRegistry()
{
// Arrange — Harbor is running. We want to add a new upstream registry
// as a proxy cache (e.g., registry.k8s.io for Kubernetes images).
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Proxy cache project added for registry.k8s.io",
Actions: new List<string>
{
"Created proxy cache project 'k8s-registry-cache' → registry.k8s.io",
"Proxy cache now covers: docker.io, ghcr.io, quay.io, registry.k8s.io"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["addProxyCache"] = "registry.k8s.io",
["proxyCacheProjectName"] = "k8s-registry-cache"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — New proxy cache project created.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("registry.k8s.io"));
result.Value.Actions.Should().Contain(a => a.Contains("k8s-registry-cache"));
}
[Fact]
public async Task Harbor_Configure_SetAsDefaultPullThroughCache()
{
// Arrange — We want to configure the cluster so all pod image pulls
// go through Harbor as a mirror/cache. This typically involves configuring
// containerd mirror settings or using Kyverno to mutate image references.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Cluster configured to use Harbor as default pull-through cache",
Actions: new List<string>
{
"Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references",
"Policy rewrites docker.io/* → registry.internal.corp.com/dockerhub-cache/*",
"Policy rewrites ghcr.io/* → registry.internal.corp.com/ghcr-cache/*",
"Policy rewrites quay.io/* → registry.internal.corp.com/quay-cache/*"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["setDefaultCache"] = "true",
["harborDomain"] = "registry.internal.corp.com",
["cacheStrategy"] = "kyverno-rewrite"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Kyverno policy created to rewrite image refs through Harbor.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("ClusterPolicy"));
result.Value.Actions.Should().Contain(a => a.Contains("docker.io"));
result.Value.Actions.Should().Contain(a => a.Contains("ghcr.io"));
}
[Fact]
public async Task Harbor_Configure_CreatePrivateProject()
{
// Arrange — Create a private project in Harbor for tenant images.
// Each tenant gets their own project with isolation.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Private project 'tenant-alpha' created",
Actions: new List<string>
{
"Created private project 'tenant-alpha'",
"Created robot account 'robot$tenant-alpha-pull' with pull access",
"Created image pull Secret 'harbor-pull-tenant-alpha' in namespace 'tenant-alpha'"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["createProject"] = "tenant-alpha",
["projectVisibility"] = "private",
["createPullSecret"] = "true",
["targetNamespace"] = "tenant-alpha"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Project, robot account, and pull secret created.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("private project 'tenant-alpha'"));
result.Value.Actions.Should().Contain(a => a.Contains("robot account"));
result.Value.Actions.Should().Contain(a => a.Contains("pull Secret"));
}
[Fact]
public async Task Harbor_Configure_EnableVulnerabilityScanning()
{
// Arrange — Enable automatic vulnerability scanning on all pushed images.
// Harbor uses Trivy for scanning. We want to configure the scan policy.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Vulnerability scanning configured",
Actions: new List<string>
{
"Enabled automatic scan on push for all projects",
"Set vulnerability severity threshold to 'High'",
"Configured scan schedule: daily at 02:00 UTC"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["enableScanOnPush"] = "true",
["severityThreshold"] = "High",
["scanSchedule"] = "0 2 * * *"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Scanning policies configured.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("scan on push"));
result.Value.Actions.Should().Contain(a => a.Contains("severity threshold"));
}
// ─── Wire All Platform Services ──────────────────────────────────────────
[Fact]
public async Task Harbor_Configure_WireAllServices_CreatesComprehensiveCacheAndPolicy()
{
// Arrange — Harbor is running. We want to wire ALL platform services
// (MinIO, CloudNativePG, cert-manager, Kyverno, Istio, Grafana, Prometheus,
// Redis, RabbitMQ, External Secrets) to pull images through Harbor.
// This creates proxy cache projects for every upstream registry used by
// our services and a Kyverno policy to rewrite image references.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "All platform services wired to pull through Harbor",
Actions: new List<string>
{
"Ensured proxy cache project 'dockerhub-cache' → docker.io (MinIO, Istio, Grafana, Redis, RabbitMQ)",
"Ensured proxy cache project 'ghcr-cache' → ghcr.io (CloudNativePG, Kyverno, External Secrets)",
"Ensured proxy cache project 'quay-cache' → quay.io (cert-manager, trust-manager, Prometheus)",
"Ensured proxy cache project 'k8s-cache' → registry.k8s.io (CoreDNS, metrics-server)",
"Ensured proxy cache project 'gcr-cache' → gcr.io (Istio release images)",
"Created Kyverno ClusterPolicy 'harbor-image-mirror' to rewrite image references",
"Policy rewrites docker.io/* → registry.internal.corp.com/dockerhub-cache/*",
"Policy rewrites ghcr.io/* → registry.internal.corp.com/ghcr-cache/*",
"Policy rewrites quay.io/* → registry.internal.corp.com/quay-cache/*",
"Policy rewrites registry.k8s.io/* → registry.internal.corp.com/k8s-cache/*",
"Policy rewrites gcr.io/* → registry.internal.corp.com/gcr-cache/*",
"All 12 platform services now pull through Harbor"
}));
List<IComponentInstaller> installers = new() { harborInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "harbor",
Values: new Dictionary<string, string>
{
["wireAllServices"] = "true",
["harborDomain"] = "registry.internal.corp.com"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — All registries cached and policy covers all platforms.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("docker.io") && a.Contains("MinIO"));
result.Value.Actions.Should().Contain(a => a.Contains("ghcr.io") && a.Contains("CloudNativePG"));
result.Value.Actions.Should().Contain(a => a.Contains("quay.io") && a.Contains("cert-manager"));
result.Value.Actions.Should().Contain(a => a.Contains("registry.k8s.io"));
result.Value.Actions.Should().Contain(a => a.Contains("gcr.io"));
result.Value.Actions.Should().Contain(a => a.Contains("ClusterPolicy"));
result.Value.Actions.Should().Contain(a => a.Contains("12 platform services"));
harborInstaller.Verify(i => i.ConfigureAsync(
cluster,
It.Is<ComponentConfiguration>(c =>
c.Values["wireAllServices"] == "true" &&
c.Values["harborDomain"] == "registry.internal.corp.com"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Harbor_Install_WithAllServices_IncludesAllRegistryCaches()
{
// Arrange — When installing Harbor with wireAllServices=true, it should
// automatically create proxy caches for all 5 upstream registries
// needed by platform services, not just the default 3.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Harbor 2.12.0 installed with pull-through cache for all platform registries",
Actions: new List<string>
{
"Ensured namespace 'harbor'",
"Helm install harbor v2.12.0",
"Created proxy cache project 'dockerhub-cache' → docker.io",
"Created proxy cache project 'ghcr-cache' → ghcr.io",
"Created proxy cache project 'quay-cache' → quay.io",
"Created proxy cache project 'k8s-cache' → registry.k8s.io",
"Created proxy cache project 'gcr-cache' → gcr.io",
"Harbor registry available at registry.internal.corp.com"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor", Version: "2.12.0",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.internal.corp.com",
["adminPassword"] = "Harbor12345",
["storageClass"] = "longhorn",
["storageSize"] = "100Gi",
["cacheRegistries"] = "docker.io,ghcr.io,quay.io,registry.k8s.io,gcr.io"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — All 5 registries have proxy cache projects.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("dockerhub-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("ghcr-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("quay-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("k8s-cache"));
result.Value.Actions.Should().Contain(a => a.Contains("gcr-cache"));
}
// ─── Harbor Adoption Check ────────────────────────────────────────────────
[Fact]
public async Task Harbor_Check_DetectsExistingHarborDeployment()
{
// Arrange — A cluster already has Harbor installed via Helm.
// The adoption check should discover it, report version, and enumerate
// existing proxy cache projects.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IAdoptionCheck> harborCheck = new();
harborCheck.Setup(c => c.ComponentName).Returns("harbor");
harborCheck.Setup(c => c.DisplayName).Returns("Harbor (Container Registry & Helm Charts)");
harborCheck
.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult(
"harbor",
ComponentStatus.Installed,
Details: new List<string>
{
"Harbor 2.12.0 detected (Helm release 'harbor' in namespace 'harbor')",
"Registry endpoint: registry.internal.corp.com",
"Proxy cache projects: dockerhub-cache, ghcr-cache, quay-cache",
"Trivy scanner enabled",
"ChartMuseum enabled"
},
MissingItems: new List<string>(),
Configuration: new DiscoveredConfiguration(
Version: "2.12.0",
Namespace: "harbor",
HelmReleaseName: "harbor",
Values: new Dictionary<string, string>
{
["registryEndpoint"] = "registry.internal.corp.com",
["proxyCacheProjects"] = "dockerhub-cache,ghcr-cache,quay-cache",
["trivyEnabled"] = "true",
["chartMuseumEnabled"] = "true",
["storageClass"] = "longhorn",
["storageSize"] = "100Gi"
})));
// Act
ComponentCheckResult checkResult = await harborCheck.Object.CheckAsync(cluster);
// Assert — Harbor detected with full configuration discovery.
checkResult.Status.Should().Be(ComponentStatus.Installed);
checkResult.Configuration.Should().NotBeNull();
checkResult.Configuration!.Version.Should().Be("2.12.0");
checkResult.Configuration.HelmReleaseName.Should().Be("harbor");
checkResult.Configuration.Values["proxyCacheProjects"].Should().Contain("dockerhub-cache");
checkResult.Details.Should().Contain(d => d.Contains("Trivy"));
}
[Fact]
public async Task Harbor_Check_DetectsPartialInstallation()
{
// Arrange — Harbor is installed but some pods are not healthy (e.g.,
// the database pod is in CrashLoopBackOff). The check reports Degraded.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IAdoptionCheck> harborCheck = new();
harborCheck.Setup(c => c.ComponentName).Returns("harbor");
harborCheck.Setup(c => c.DisplayName).Returns("Harbor (Container Registry & Helm Charts)");
harborCheck
.Setup(c => c.CheckAsync(cluster, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ComponentCheckResult(
"harbor",
ComponentStatus.Degraded,
Details: new List<string>
{
"Harbor Helm release found in namespace 'harbor'",
"Core component: Running",
"Registry component: Running",
"Database component: CrashLoopBackOff"
},
MissingItems: new List<string>
{
"harbor-database pod unhealthy (CrashLoopBackOff)"
}));
// Act
ComponentCheckResult checkResult = await harborCheck.Object.CheckAsync(cluster);
// Assert — Degraded status with details about what's wrong.
checkResult.Status.Should().Be(ComponentStatus.Degraded);
checkResult.MissingItems.Should().Contain(m => m.Contains("database"));
checkResult.Details.Should().Contain(d => d.Contains("CrashLoopBackOff"));
}
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 HarborSchema_ContainsCnpgClusterNameParameter()
{
// Harbor should be able to reference the shared CNPG cluster for its
// database instead of using the embedded PostgreSQL sub-chart.
ConfigurationSchema schema = ComponentSchemas.GetSchema("harbor");
schema.Parameters.Should().Contain(p =>
p.Key == "cnpgClusterName" && p.Type == ParameterType.PostgresCluster);
}
[Fact]
public void HarborSchema_ContainsCnpgNamespaceParameter()
{
// The CNPG cluster namespace is needed to resolve the primary service endpoint.
ConfigurationSchema schema = ComponentSchemas.GetSchema("harbor");
schema.Parameters.Should().Contain(p => p.Key == "cnpgNamespace");
}
// ─── Harbor Database Credentials ──────────────────────────────────────
[Fact]
public async Task Harbor_Install_WithCnpg_StoresCredentialsInVault()
{
// Arrange — When Harbor is installed with a CNPG cluster, the installer
// should store the generated database credentials in the vault at
// infrastructure/{clusterId}/harbor/database/* so they're auditable
// and recoverable.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
// Track vault calls made by the installer.
List<string> vaultPaths = new();
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "harbor",
Message: "Harbor installed with external CNPG database",
Actions: new List<string>
{
"Created database 'harbor' on CNPG cluster 'shared-pg'",
"Stored database credentials in Kubernetes Secret and vault",
"Helm install harbor v1.16.0"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
harborInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("harbor",
Parameters: new Dictionary<string, string>
{
["harborDomain"] = "registry.dev.example.com",
["cnpgClusterName"] = "shared-pg",
["cnpgNamespace"] = "cnpg-system"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The install result should mention both the K8s Secret and vault.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("Stored database credentials"));
}
[Fact]
public void CnpgCredentialsSecret_ContainsAllRequiredKeys()
{
// When Harbor uses an external CNPG database, a K8s Secret is created
// with the database connection details. This secret must contain all
// four keys that Harbor expects: host, database, username, password.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"harbor-cnpg-credentials", "harbor",
"shared-pg-rw.cnpg-system.svc.cluster.local",
"harbor", "harbor", "secret-password-123");
secret.StringData.Should().ContainKey("host");
secret.StringData.Should().ContainKey("database");
secret.StringData.Should().ContainKey("username");
secret.StringData.Should().ContainKey("password");
secret.StringData["host"].Should().Be("shared-pg-rw.cnpg-system.svc.cluster.local");
secret.StringData["database"].Should().Be("harbor");
secret.StringData["username"].Should().Be("harbor");
secret.StringData["password"].Should().Be("secret-password-123");
}
[Fact]
public void CnpgCredentialsSecret_HasManagementLabels()
{
// The credentials secret should be labeled for EntKube management
// so it can be discovered and cleaned up.
V1Secret secret = CnpgDatabaseProvisioner.BuildCredentialsSecret(
"harbor-cnpg-credentials", "harbor",
"host", "harbor", "harbor", "pass");
secret.Metadata.Labels["app.kubernetes.io/managed-by"].Should().Be("entkube");
secret.Metadata.Labels["entkube.io/purpose"].Should().Be("database-credentials");
}
}

View File

@@ -0,0 +1,194 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.Components.Ingress;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for IngressCheck.DetectProvider — the static method that determines
/// which ingress provider is installed on a cluster by examining its component
/// list. This drives the auto-detection behavior so users never have to
/// manually select "istio" or "traefik" when configuring ingress.
/// </summary>
public class IngressCheckTests
{
[Fact]
public void DetectProvider_IstioInstalled_ReturnsIstio()
{
// When the cluster has an Istio component marked as Installed,
// the detector should pick Istio as the provider.
KubernetesCluster cluster = CreateClusterWithComponent("istio", ComponentStatus.Installed);
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().Be("istio");
}
[Fact]
public void DetectProvider_TraefikInstalled_ReturnsTraefik()
{
// When the cluster has a Traefik component marked as Installed,
// the detector should pick Traefik as the provider.
KubernetesCluster cluster = CreateClusterWithComponent("traefik", ComponentStatus.Installed);
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().Be("traefik");
}
[Fact]
public void DetectProvider_IstioDegraded_StillReturnsIstio()
{
// Even if Istio is degraded (partially working), we should still
// detect it as the provider — the gateway infra can still be deployed.
KubernetesCluster cluster = CreateClusterWithComponent("istio", ComponentStatus.Degraded);
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().Be("istio");
}
[Fact]
public void DetectProvider_NeitherInstalled_ReturnsNull()
{
// When no ingress controller is installed, the detector returns null
// so the installer can report "install Istio or Traefik first."
KubernetesCluster cluster = CreateClusterWithComponent("kyverno", ComponentStatus.Installed);
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().BeNull();
}
[Fact]
public void DetectProvider_BothInstalled_PrefersIstio()
{
// If both Istio and Traefik are installed (unusual but possible),
// Istio takes priority because it requires dedicated gateway infrastructure.
KubernetesCluster cluster = CreateClusterWithComponents(
("istio", ComponentStatus.Installed),
("traefik", ComponentStatus.Installed));
string? provider = IngressCheck.DetectProvider(cluster);
provider.Should().Be("istio");
}
// ─── Gateway resolution from ingress component ───────────────────────
[Fact]
public void IngressComponent_WithIstioGateways_ExposesGatewayInfo()
{
// When the ingress component is installed with Istio and has discovered
// internal and external gateways, the component's configuration should
// contain the gateway details that services can use to auto-resolve.
KubernetesCluster cluster = CreateClusterWithIngressConfig(
provider: "istio",
hasInternalGateway: true,
hasExternalGateway: true);
ClusterComponent? ingress = cluster.Components.FirstOrDefault(
c => c.ComponentName == "ingress");
ingress.Should().NotBeNull();
ingress!.Configuration["provider"].Should().Be("istio");
ingress.Configuration["hasInternalGateway"].Should().Be("True");
ingress.Configuration["hasExternalGateway"].Should().Be("True");
}
[Fact]
public void IngressComponent_WithTraefik_NoGatewayRefs()
{
// Traefik manages its own ingress — no Gateway API gateway refs needed.
// The component config should show traefik as provider.
KubernetesCluster cluster = CreateClusterWithIngressConfig(
provider: "traefik",
hasInternalGateway: false,
hasExternalGateway: false);
ClusterComponent? ingress = cluster.Components.FirstOrDefault(
c => c.ComponentName == "ingress");
ingress.Should().NotBeNull();
ingress!.Configuration["provider"].Should().Be("traefik");
ingress.Configuration.ContainsKey("hasInternalGateway").Should().BeFalse();
}
// ─── Helpers ─────────────────────────────────────────────────────────
private static KubernetesCluster CreateClusterWithComponent(string componentName, ComponentStatus status)
{
return CreateClusterWithComponents((componentName, status));
}
private static KubernetesCluster CreateClusterWithComponents(params (string Name, ComponentStatus Status)[] componentDefs)
{
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.example.com", "fake-kubeconfig",
Guid.NewGuid(), "test-context", Guid.NewGuid());
List<ComponentCheckResult> results = componentDefs
.Select(c => new ComponentCheckResult(
c.Name, c.Status,
Details: new List<string>(),
MissingItems: new List<string>()))
.ToList();
cluster.UpdateComponents(results);
return cluster;
}
/// <summary>
/// Creates a cluster with an ingress component that has discovered
/// configuration values — provider, gateway presence flags. This mimics
/// what the real IngressCheck discovers during an adoption scan.
/// </summary>
private static KubernetesCluster CreateClusterWithIngressConfig(
string provider, bool hasInternalGateway, bool hasExternalGateway)
{
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.example.com", "fake-kubeconfig",
Guid.NewGuid(), "test-context", Guid.NewGuid());
Dictionary<string, string> configValues = new()
{
["provider"] = provider
};
if (hasInternalGateway)
{
configValues["hasInternalGateway"] = "True";
}
if (hasExternalGateway)
{
configValues["hasExternalGateway"] = "True";
}
List<ComponentCheckResult> results = new()
{
new ComponentCheckResult(
"ingress", ComponentStatus.Installed,
Details: new List<string>(),
MissingItems: new List<string>(),
Configuration: new DiscoveredConfiguration(
Version: null,
Namespace: "internal-ingress",
HelmReleaseName: null,
Values: configValues))
};
cluster.UpdateComponents(results);
return cluster;
}
}

View File

@@ -0,0 +1,127 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster.Components.Ingress;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the ingress installer's provider-aware load balancer annotation
/// logic and Istio gateway Helm values generation. Each cloud provider requires
/// different Service annotations to create an internal (non-public) LoadBalancer.
/// These tests verify the correct annotations are produced for each provider.
/// </summary>
public class IngressInstallerTests
{
// ─── Internal LB Annotation Mapping ──────────────────────────────────
[Fact]
public void GetInternalLbAnnotations_Cleura_ReturnsOpenStackAnnotation()
{
// Cleura runs on OpenStack, which uses the openstack-internal-load-balancer
// annotation to tell the cloud controller to provision a private LB.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Cleura);
annotations.Should().ContainKey("service.beta.kubernetes.io/openstack-internal-load-balancer");
annotations["service.beta.kubernetes.io/openstack-internal-load-balancer"].Should().Be("true");
annotations.Should().HaveCount(1);
}
[Fact]
public void GetInternalLbAnnotations_Aws_ReturnsAwsSchemeAnnotation()
{
// AWS uses the load-balancer-scheme annotation to create an internal NLB/ALB.
// The newer annotation supersedes the older aws-load-balancer-internal one.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Aws);
annotations.Should().ContainKey("service.beta.kubernetes.io/aws-load-balancer-scheme");
annotations["service.beta.kubernetes.io/aws-load-balancer-scheme"].Should().Be("internal");
annotations.Should().HaveCount(1);
}
[Fact]
public void GetInternalLbAnnotations_Azure_ReturnsAzureAnnotation()
{
// Azure uses its own annotation to mark a LoadBalancer as internal.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Azure);
annotations.Should().ContainKey("service.beta.kubernetes.io/azure-load-balancer-internal");
annotations["service.beta.kubernetes.io/azure-load-balancer-internal"].Should().Be("true");
annotations.Should().HaveCount(1);
}
[Fact]
public void GetInternalLbAnnotations_Gcp_ReturnsGcpAnnotation()
{
// GCP uses the networking.gke.io annotation for internal LBs.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.Gcp);
annotations.Should().ContainKey("networking.gke.io/load-balancer-type");
annotations["networking.gke.io/load-balancer-type"].Should().Be("Internal");
annotations.Should().HaveCount(1);
}
[Fact]
public void GetInternalLbAnnotations_None_ReturnsEmptyAnnotations()
{
// When no cloud provider is set, we can't determine the right annotation.
// Return empty and let the user handle it manually if needed.
Dictionary<string, string> annotations = IngressInstaller.GetInternalLbAnnotations(CloudProvider.None);
annotations.Should().BeEmpty();
}
// ─── Istio Gateway Helm Values ───────────────────────────────────────
[Fact]
public void GetIstioInternalGatewayValues_WithCleura_IncludesOpenStackAnnotation()
{
// When deploying to a Cleura cluster, the internal gateway's LoadBalancer
// Service must carry the OpenStack internal annotation so the cloud controller
// provisions a private LB accessible only from the internal network.
string values = IngressInstaller.GetIstioInternalGatewayValues(CloudProvider.Cleura);
values.Should().Contain("openstack-internal-load-balancer");
values.Should().NotContain("azure-load-balancer-internal");
values.Should().NotContain("aws-load-balancer");
}
[Fact]
public void GetIstioInternalGatewayValues_WithAws_IncludesAwsAnnotation()
{
string values = IngressInstaller.GetIstioInternalGatewayValues(CloudProvider.Aws);
values.Should().Contain("aws-load-balancer-scheme");
values.Should().Contain("internal");
values.Should().NotContain("azure-load-balancer-internal");
}
[Fact]
public void GetIstioInternalGatewayValues_WithNone_HasNoAnnotationsBlock()
{
// When no provider is set, the values should not include any internal
// LB annotations — the Service gets a plain public LoadBalancer.
string values = IngressInstaller.GetIstioInternalGatewayValues(CloudProvider.None);
values.Should().NotContain("annotations:");
values.Should().Contain("type: LoadBalancer");
}
[Fact]
public void GetIstioExternalGatewayValues_NeverIncludesInternalAnnotations()
{
// The external gateway should never have internal LB annotations
// regardless of the cloud provider — it's meant to be public.
string values = IngressInstaller.GetIstioExternalGatewayValues();
values.Should().NotContain("internal");
values.Should().Contain("type: LoadBalancer");
}
}

View File

@@ -0,0 +1,174 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.InstallPrometheus;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class InstallPrometheusHandlerTests
{
private readonly InMemoryClusterRepository repository;
public InstallPrometheusHandlerTests()
{
repository = new InMemoryClusterRepository();
}
[Fact]
public async Task HandleAsync_WithValidCluster_InstallsAndReturnsSuccess()
{
// Arrange — A cluster with no Prometheus. The user wants to install
// kube-prometheus-stack via Helm with a production configuration.
KubernetesCluster cluster = KubernetesCluster.Register(
"fresh-cluster",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
FakeMonitoringInstaller installer = new(success: true);
InstallPrometheusHandler handler = new(repository, new List<IComponentInstaller> { installer });
// Act — Install Prometheus on the cluster.
Result<PrometheusInstallResult> result = await handler.HandleAsync(
new InstallPrometheusRequest(cluster.Id, "monitoring"));
// Assert — The install should succeed and the cluster should have
// the Prometheus endpoint stored.
result.IsSuccess.Should().BeTrue();
result.Value!.Namespace.Should().Be("monitoring");
result.Value.ReleaseName.Should().Be("kube-prometheus-stack");
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.PrometheusEndpoints.Should().HaveCount(1);
updated.PrometheusEndpoints[0].Namespace.Should().Be("monitoring");
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
FakeMonitoringInstaller installer = new(success: true);
InstallPrometheusHandler handler = new(repository, new List<IComponentInstaller> { installer });
// Act
Result<PrometheusInstallResult> result = await handler.HandleAsync(
new InstallPrometheusRequest(Guid.NewGuid(), "monitoring"));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_WhenHelmFails_ReturnsFailure()
{
// Arrange — Helm install fails (e.g., no connectivity, insufficient permissions).
KubernetesCluster cluster = KubernetesCluster.Register(
"broken-cluster",
"https://broken.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
FakeMonitoringInstaller installer = new(success: false, errorMessage: "Tiller not available");
InstallPrometheusHandler handler = new(repository, new List<IComponentInstaller> { installer });
// Act
Result<PrometheusInstallResult> result = await handler.HandleAsync(
new InstallPrometheusRequest(cluster.Id, "monitoring"));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Tiller not available");
}
[Fact]
public async Task HandleAsync_DefaultsToMonitoringNamespace()
{
// Arrange — The request doesn't specify a namespace, so it should
// default to "monitoring".
KubernetesCluster cluster = KubernetesCluster.Register(
"default-ns-cluster",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
FakeMonitoringInstaller installer = new(success: true);
InstallPrometheusHandler handler = new(repository, new List<IComponentInstaller> { installer });
// Act
Result<PrometheusInstallResult> result = await handler.HandleAsync(
new InstallPrometheusRequest(cluster.Id, null));
// Assert — Should install to "monitoring" namespace.
result.IsSuccess.Should().BeTrue();
result.Value!.Namespace.Should().Be("monitoring");
}
}
/// <summary>
/// Test double for the monitoring component installer. Returns pre-configured results
/// without actually running helm commands.
/// </summary>
public class FakeMonitoringInstaller : IComponentInstaller
{
private readonly bool success;
private readonly string? errorMessage;
public string ComponentName => "monitoring";
public FakeMonitoringInstaller(bool success, string? errorMessage = null)
{
this.success = success;
this.errorMessage = errorMessage;
}
public Task<InstallResult> InstallAsync(KubernetesCluster cluster, ComponentInstallOptions options, CancellationToken ct = default)
{
if (success)
{
return Task.FromResult(new InstallResult(
true,
ComponentName,
"kube-prometheus-stack installed",
new List<string> { "Helm install succeeded" }));
}
return Task.FromResult(new InstallResult(
false,
ComponentName,
errorMessage ?? "Install failed",
new List<string> { $"Error: {errorMessage}" }));
}
public Task<InstallResult> ConfigureAsync(KubernetesCluster cluster, ComponentConfiguration configuration, CancellationToken ct = default)
{
return Task.FromResult(new InstallResult(
true,
ComponentName,
"Monitoring reconfigured",
new List<string> { "Configuration applied" }));
}
}

View File

@@ -0,0 +1,546 @@
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");
}
}

View File

@@ -0,0 +1,176 @@
using System.Text.Json;
using EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies;
using EntKube.Clusters.Features.ClusterSettings;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests the JSON parsing logic that extracts allowed registries from a Kyverno
/// ClusterPolicy. This is the core logic that reads the restrict-image-registries
/// policy and returns the list of approved registries.
/// </summary>
public class KyvernoClusterSettingsProviderTests
{
[Fact]
public void ExtractRegistries_WithValidPolicy_ReturnsRegistryList()
{
// Arrange — a policy JSON with three allowed registries.
string json = """
{
"spec": {
"validationFailureAction": "Audit",
"rules": [{
"name": "validate-image-registry",
"validate": {
"foreach": [{
"list": "request.object.spec.[initContainers, containers][]",
"deny": {
"conditions": {
"all": [
{ "key": "{{ element.image }}", "operator": "NotEquals", "value": "docker.io/*" },
{ "key": "{{ element.image }}", "operator": "NotEquals", "value": "ghcr.io/*" },
{ "key": "{{ element.image }}", "operator": "NotEquals", "value": "registry.k8s.io/*" }
]
}
}
}]
}
}]
}
}
""";
// Act
List<string> registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json);
// Assert
registries.Should().BeEquivalentTo(new[] { "docker.io", "ghcr.io", "registry.k8s.io" });
}
[Fact]
public void ExtractRegistries_WithEmptyJson_ReturnsEmptyList()
{
// Act
List<string> registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson("{}");
// Assert
registries.Should().BeEmpty();
}
[Fact]
public void ExtractRegistries_WithMalformedJson_ReturnsEmptyList()
{
// Act
List<string> registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson("not json");
// Assert
registries.Should().BeEmpty();
}
[Fact]
public void ExtractRegistries_WithNoPolicyRules_ReturnsEmptyList()
{
// Arrange
string json = """{ "spec": { "rules": [] } }""";
// Act
List<string> registries = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json);
// Assert
registries.Should().BeEmpty();
}
// ─── Round-trip tests ──────────────────────────────────────────────────
[Fact]
public void BuildRegistryPolicy_RoundTrip_PreservesRegistries()
{
// When the settings provider writes a policy and then reads it back,
// the extracted registries should match what was written. This simulates
// the full Kubernetes API round-trip: build → serialize → parse.
// Arrange — a custom list of registries.
List<string> registries = new() { "docker.io", "ghcr.io", "myregistry.example.com" };
// Act — build the policy, serialize it to JSON (simulating K8s storage),
// then extract registries back out.
object policy = KyvernoClusterSettingsProvider.BuildRegistryPolicy(registries, "test message", "Audit");
string json = JsonSerializer.Serialize(policy);
List<string> extracted = KyvernoClusterSettingsProvider.ExtractRegistriesFromPolicyJson(json);
// Assert — the registries should survive the round-trip intact.
extracted.Should().BeEquivalentTo(registries);
}
[Fact]
public void BuildRegistryPolicy_IncludesExcludeBlock()
{
// The policy must include the exclude block so system namespaces
// (kube-system, cert-manager, etc.) are not subject to registry
// restrictions. Without this, saving registries via the Settings tab
// would create a policy that differs from the installer's policy and
// could break system pods.
// Arrange
List<string> registries = new() { "docker.io" };
// Act
object policy = KyvernoClusterSettingsProvider.BuildRegistryPolicy(registries, "test", "Audit");
string json = JsonSerializer.Serialize(policy);
using JsonDocument doc = JsonDocument.Parse(json);
// Assert — the rule should have an "exclude" block with namespace exclusions.
JsonElement rule = doc.RootElement
.GetProperty("spec")
.GetProperty("rules")[0];
rule.TryGetProperty("exclude", out JsonElement exclude).Should().BeTrue(
"the policy rule must include an exclude block to protect system namespaces");
string excludeJson = exclude.GetRawText();
excludeJson.Should().Contain("kube-system");
}
[Fact]
public void BuildRegistryPolicy_IncludesManagedByLabels()
{
// The policy labels must match what the SecurityPoliciesInstaller creates
// so the installer can find and replace the policy during reconfiguration.
// Arrange
List<string> registries = new() { "docker.io" };
// Act
object policy = KyvernoClusterSettingsProvider.BuildRegistryPolicy(registries, "test", "Enforce");
string json = JsonSerializer.Serialize(policy);
using JsonDocument doc = JsonDocument.Parse(json);
// Assert — should have both managed-by and part-of labels.
JsonElement labels = doc.RootElement
.GetProperty("metadata")
.GetProperty("labels");
labels.GetProperty("app.kubernetes.io/managed-by").GetString().Should().Be("entkube");
labels.GetProperty("app.kubernetes.io/part-of").GetString().Should().Be("security-policies");
}
}

View File

@@ -0,0 +1,748 @@
using System.Text.Json;
using EntKube.Clusters.Features.AdoptCluster.Components.LetsEncrypt;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the LetsEncrypt component check — specifically the DNS01 solver
/// configuration extraction logic. When a cluster has ACME ClusterIssuers using
/// DNS01 challenges, the check should extract the provider type, secret references,
/// hosted zones, and domain selectors so the UI can display them properly.
/// </summary>
public class LetsEncryptCheckTests
{
// ─── DNS01 Solver Parsing ─────────────────────────────────────────────
[Fact]
public void ParseDns01Details_WithCloudflareSolver_ExtractsProviderAndSecret()
{
// Arrange — A ClusterIssuer with a Cloudflare DNS01 solver. The solver
// specifies an API token stored in a Kubernetes Secret.
string json = """
{
"cloudflare": {
"apiTokenSecretRef": {
"name": "cloudflare-api-token",
"key": "api-token"
}
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act — Extract DNS01 details from the solver configuration.
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — Should identify Cloudflare as the provider and capture the secret name.
details.Provider.Should().Be("cloudflare");
details.SecretName.Should().Be("cloudflare-api-token");
details.HostedZone.Should().BeNull();
details.Project.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithRoute53Solver_ExtractsProviderAndHostedZone()
{
// Arrange — A ClusterIssuer with an AWS Route53 DNS01 solver. Route53
// solvers include a hosted zone ID and region.
string json = """
{
"route53": {
"region": "eu-north-1",
"hostedZoneID": "Z1234567890ABC"
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert
details.Provider.Should().Be("route53");
details.HostedZone.Should().Be("Z1234567890ABC");
details.SecretName.Should().BeNull();
details.Project.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithRoute53AndSecretAccessKey_ExtractsSecretName()
{
// Arrange — A Route53 DNS01 solver using explicit credentials stored in
// a Kubernetes Secret via secretAccessKeySecretRef. This is the most common
// setup for non-IRSA (IAM Roles for Service Accounts) Route53 configurations.
string json = """
{
"route53": {
"region": "eu-north-1",
"hostedZoneID": "Z1234567890ABC",
"accessKeyID": "AKIAIOSFODNN7EXAMPLE",
"secretAccessKeySecretRef": {
"name": "aws-route53-credentials",
"key": "secret-access-key"
}
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — Should capture both the hosted zone and the credential secret.
details.Provider.Should().Be("route53");
details.HostedZone.Should().Be("Z1234567890ABC");
details.SecretName.Should().Be("aws-route53-credentials");
details.Project.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithAzureDnsServicePrincipal_ExtractsAllFields()
{
// Arrange — An Azure DNS solver using a service principal for authentication.
// This is the most common Azure DNS setup: the ClusterIssuer authenticates
// to Azure using a clientID + clientSecret (stored in a K8s Secret),
// scoped to a specific subscription, tenant, resource group, and hosted zone.
string json = """
{
"azureDNS": {
"clientID": "app-id-12345",
"clientSecretSecretRef": {
"name": "azuredns-sp-secret",
"key": "client-secret"
},
"subscriptionID": "sub-aaaa-bbbb-cccc",
"tenantID": "tenant-xxxx-yyyy",
"resourceGroupName": "dns-rg",
"hostedZoneName": "example.com",
"environment": "AzurePublicCloud"
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — All Azure DNS fields should be extracted.
details.Provider.Should().Be("azuredns");
details.HostedZone.Should().Be("example.com");
details.SecretName.Should().Be("azuredns-sp-secret");
details.ClientId.Should().Be("app-id-12345");
details.SubscriptionId.Should().Be("sub-aaaa-bbbb-cccc");
details.TenantId.Should().Be("tenant-xxxx-yyyy");
details.ResourceGroup.Should().Be("dns-rg");
details.Project.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithAzureDnsManagedIdentity_ExtractsHostedZone()
{
// Arrange — An Azure DNS solver using managed identity (no clientSecret).
// Only the hosted zone and resource group are present.
string json = """
{
"azureDNS": {
"hostedZoneName": "example.com",
"resourceGroupName": "dns-rg",
"subscriptionID": "sub-123",
"environment": "AzurePublicCloud"
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — Should capture what's available, leave missing fields null.
details.Provider.Should().Be("azuredns");
details.HostedZone.Should().Be("example.com");
details.ResourceGroup.Should().Be("dns-rg");
details.SubscriptionId.Should().Be("sub-123");
details.SecretName.Should().BeNull();
details.ClientId.Should().BeNull();
details.TenantId.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithCloudDnsSolver_ExtractsProviderAndProject()
{
// Arrange — A Google Cloud DNS solver with a project and service account.
string json = """
{
"cloudDNS": {
"project": "my-gcp-project",
"serviceAccountSecretRef": {
"name": "clouddns-service-account",
"key": "key.json"
}
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert
details.Provider.Should().Be("clouddns");
details.Project.Should().Be("my-gcp-project");
details.SecretName.Should().Be("clouddns-service-account");
details.HostedZone.Should().BeNull();
}
[Fact]
public void ParseDns01Details_WithEmptyDns01_ReturnsUnknownProvider()
{
// Arrange — A DNS01 solver with no recognized provider (unusual but possible
// with custom webhook solvers).
string json = """
{
"webhook": {
"solverName": "custom-solver"
}
}
""";
JsonElement dns01Element = JsonDocument.Parse(json).RootElement;
// Act
Dns01Details details = LetsEncryptCheck.ParseDns01Details(dns01Element);
// Assert — Should report unknown when no recognized provider is found.
details.Provider.Should().BeNull();
details.SecretName.Should().BeNull();
}
// ─── Domain Selector Parsing ──────────────────────────────────────────
[Fact]
public void ParseDnsZones_WithDnsZonesSelector_ExtractsZones()
{
// Arrange — A solver with a selector that scopes it to specific DNS zones.
// This is common when using DNS01 for wildcard certificates on specific domains.
string json = """
{
"selector": {
"dnsZones": ["example.com", "internal.example.com"]
},
"dns01": {
"cloudflare": {
"apiTokenSecretRef": {
"name": "cf-token",
"key": "api-token"
}
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
List<string> zones = LetsEncryptCheck.ParseDnsZones(solverElement);
// Assert
zones.Should().BeEquivalentTo(new[] { "example.com", "internal.example.com" });
}
[Fact]
public void ParseDnsZones_WithNoSelector_ReturnsEmptyList()
{
// Arrange — A solver with no selector (applies to all domains).
string json = """
{
"dns01": {
"cloudflare": {
"apiTokenSecretRef": {
"name": "cf-token",
"key": "api-token"
}
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
List<string> zones = LetsEncryptCheck.ParseDnsZones(solverElement);
// Assert
zones.Should().BeEmpty();
}
// ─── Per-Issuer Config Values ─────────────────────────────────────────
[Fact]
public void BuildConfigValues_WithMultipleIssuers_IncludesPerIssuerDetails()
{
// Arrange — Two ACME issuers: one staging with HTTP01, one production
// with DNS01 using Cloudflare. The config values should include both
// global summary and per-issuer detail so the UI can display each issuer.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-staging",
Email: "admin@example.com",
AcmeServer: "https://acme-staging-v02.api.letsencrypt.org/directory",
SolverType: "HTTP01",
IsReady: true,
Dns01Details: null,
DnsZones: new List<string>()),
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "admin@example.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "DNS01",
IsReady: true,
Dns01Details: new Dns01Details(
Provider: "cloudflare",
SecretName: "cloudflare-api-token",
HostedZone: null,
Project: null),
DnsZones: new List<string> { "example.com" })
};
// Act — Build the configuration values dictionary from the detected issuers.
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global summary values.
configValues["issuers"].Should().Be("letsencrypt-staging,letsencrypt-prod");
configValues["issuerCount"].Should().Be("2");
configValues["email"].Should().Be("admin@example.com");
configValues["readyCount"].Should().Be("2");
configValues["httpSolverEnabled"].Should().Be("true");
configValues["dnsSolverEnabled"].Should().Be("true");
// Assert — Per-issuer detail for the DNS01 issuer.
configValues["issuer.letsencrypt-prod.solverType"].Should().Be("DNS01");
configValues["issuer.letsencrypt-prod.acmeServer"].Should().Be("https://acme-v02.api.letsencrypt.org/directory");
configValues["issuer.letsencrypt-prod.dnsSolverProvider"].Should().Be("cloudflare");
configValues["issuer.letsencrypt-prod.dnsSolverSecretName"].Should().Be("cloudflare-api-token");
configValues["issuer.letsencrypt-prod.dnsZones"].Should().Be("example.com");
// Assert — Per-issuer detail for the HTTP01 issuer.
configValues["issuer.letsencrypt-staging.solverType"].Should().Be("HTTP01");
configValues["issuer.letsencrypt-staging.acmeServer"].Should().Be("https://acme-staging-v02.api.letsencrypt.org/directory");
}
[Fact]
public void BuildConfigValues_WithDns01Solver_PopulatesGlobalDnsSolverFields()
{
// Arrange — A single issuer with DNS01. The global config values should
// include the DNS solver details so the "Current Configuration" view
// shows them without needing to expand per-issuer details.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-production",
Email: "certs@company.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "DNS01",
IsReady: true,
Dns01Details: new Dns01Details(
Provider: "route53",
SecretName: null,
HostedZone: "Z1234567890ABC",
Project: null),
DnsZones: new List<string> { "company.com", "internal.company.com" })
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global DNS solver values populated from the first DNS01 issuer found.
configValues["httpSolverEnabled"].Should().Be("false");
configValues["dnsSolverEnabled"].Should().Be("true");
configValues["dnsSolverProvider"].Should().Be("route53");
configValues["dnsSolverHostedZone"].Should().Be("Z1234567890ABC");
configValues["dnsZones"].Should().Be("company.com,internal.company.com");
configValues.Should().NotContainKey("dnsSolverProject");
}
[Fact]
public void BuildConfigValues_WithCloudDnsSolver_IncludesProject()
{
// Arrange — A GCP Cloud DNS solver should include the project.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "ops@company.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "DNS01",
IsReady: true,
Dns01Details: new Dns01Details(
Provider: "clouddns",
SecretName: "gcp-dns-sa",
HostedZone: null,
Project: "my-gcp-project"),
DnsZones: new List<string>())
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert
configValues["dnsSolverProvider"].Should().Be("clouddns");
configValues["dnsSolverProject"].Should().Be("my-gcp-project");
configValues["dnsSolverSecretName"].Should().Be("gcp-dns-sa");
configValues["dnsSolverSecretNameGcp"].Should().Be("gcp-dns-sa");
}
[Fact]
public void BuildConfigValues_WithHttp01Only_DoesNotIncludeDnsFields()
{
// Arrange — An HTTP01-only issuer should not produce DNS solver fields.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "admin@example.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "HTTP01",
IsReady: true,
Dns01Details: null,
DnsZones: new List<string>())
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert
configValues["httpSolverEnabled"].Should().Be("true");
configValues["dnsSolverEnabled"].Should().Be("false");
configValues.Should().NotContainKey("dnsSolverProvider");
configValues.Should().NotContainKey("dnsSolverSecretName");
configValues.Should().NotContainKey("dnsSolverHostedZone");
configValues.Should().NotContainKey("dnsSolverProject");
configValues.Should().NotContainKey("dnsSolverClientId");
configValues.Should().NotContainKey("dnsSolverSubscriptionId");
configValues.Should().NotContainKey("dnsSolverTenantId");
configValues.Should().NotContainKey("dnsSolverResourceGroup");
configValues.Should().NotContainKey("dnsZones");
}
[Fact]
public void BuildConfigValues_WithAzureDnsServicePrincipal_IncludesAllAzureFields()
{
// Arrange — An Azure DNS solver with full service principal configuration.
// All Azure-specific fields should appear in both global and per-issuer config.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "certs@company.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "DNS01",
IsReady: true,
Dns01Details: new Dns01Details(
Provider: "azuredns",
SecretName: "azuredns-sp-secret",
HostedZone: "company.com",
Project: null,
ClientId: "app-id-12345",
SubscriptionId: "sub-aaaa-bbbb",
TenantId: "tenant-xxxx",
ResourceGroup: "dns-rg"),
DnsZones: new List<string>())
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global DNS solver values include all Azure fields.
configValues["dnsSolverProvider"].Should().Be("azuredns");
configValues["dnsSolverSecretName"].Should().Be("azuredns-sp-secret");
configValues["dnsSolverSecretNameAzure"].Should().Be("azuredns-sp-secret");
configValues["dnsSolverHostedZone"].Should().Be("company.com");
configValues["dnsSolverClientId"].Should().Be("app-id-12345");
configValues["dnsSolverSubscriptionId"].Should().Be("sub-aaaa-bbbb");
configValues["dnsSolverTenantId"].Should().Be("tenant-xxxx");
configValues["dnsSolverResourceGroup"].Should().Be("dns-rg");
// Assert — Per-issuer values also include Azure fields.
configValues["issuer.letsencrypt-prod.dnsSolverProvider"].Should().Be("azuredns");
configValues["issuer.letsencrypt-prod.dnsSolverSecretName"].Should().Be("azuredns-sp-secret");
configValues["issuer.letsencrypt-prod.dnsSolverClientId"].Should().Be("app-id-12345");
configValues["issuer.letsencrypt-prod.dnsSolverSubscriptionId"].Should().Be("sub-aaaa-bbbb");
configValues["issuer.letsencrypt-prod.dnsSolverTenantId"].Should().Be("tenant-xxxx");
configValues["issuer.letsencrypt-prod.dnsSolverResourceGroup"].Should().Be("dns-rg");
}
// ─── HTTP01 Solver Mode Detection ─────────────────────────────────────
[Fact]
public void BuildConfigValues_WithGatewayApiHttp01_ReportsGatewayMode()
{
// Arrange — An issuer using HTTP01 via gatewayHTTPRoute (Gateway API)
// instead of traditional Ingress. The config should report the mode
// so the UI can show Gateway API-specific fields.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "admin@example.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "HTTP01",
IsReady: true,
Dns01Details: null,
DnsZones: new List<string>(),
Http01Mode: "gatewayHTTPRoute",
Http01GatewayName: "internal",
Http01GatewayNamespace: "internal-ingress")
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global config should reflect gateway mode.
configValues["httpSolverEnabled"].Should().Be("true");
configValues["httpSolverMode"].Should().Be("gatewayHTTPRoute");
configValues["httpSolverGatewayName"].Should().Be("internal");
configValues["httpSolverGatewayNamespace"].Should().Be("internal-ingress");
configValues.Should().NotContainKey("httpSolverIngressClass");
}
[Fact]
public void BuildConfigValues_WithIngressHttp01_ReportsIngressMode()
{
// Arrange — A traditional ingress-based HTTP01 solver. The config
// should report "ingress" mode with the ingress class.
List<DetectedIssuer> issuers = new()
{
new DetectedIssuer(
Name: "letsencrypt-prod",
Email: "admin@example.com",
AcmeServer: "https://acme-v02.api.letsencrypt.org/directory",
SolverType: "HTTP01",
IsReady: true,
Dns01Details: null,
DnsZones: new List<string>(),
Http01Mode: "ingress",
Http01IngressClass: "traefik")
};
// Act
Dictionary<string, string> configValues = LetsEncryptCheck.BuildConfigValues(issuers);
// Assert — Global config should reflect ingress mode.
configValues["httpSolverEnabled"].Should().Be("true");
configValues["httpSolverMode"].Should().Be("ingress");
configValues["httpSolverIngressClass"].Should().Be("traefik");
configValues.Should().NotContainKey("httpSolverGatewayName");
}
// ─── HTTP01 Solver Parsing ────────────────────────────────────────────
[Fact]
public void ParseHttp01Details_WithGatewayHTTPRoute_ExtractsParentRef()
{
// Arrange — An HTTP01 solver configured with gatewayHTTPRoute (for
// clusters using Gateway API instead of Ingress). The solver specifies
// which Gateway to attach the challenge HTTPRoute to.
string json = """
{
"http01": {
"gatewayHTTPRoute": {
"parentRefs": [
{
"name": "internal",
"namespace": "internal-ingress",
"kind": "Gateway"
}
]
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert — Should identify gateway mode and extract parent ref.
details.Mode.Should().Be("gatewayHTTPRoute");
details.GatewayName.Should().Be("internal");
details.GatewayNamespace.Should().Be("internal-ingress");
details.IngressClass.Should().BeNull();
}
[Fact]
public void ParseHttp01Details_WithIngress_ExtractsIngressClass()
{
// Arrange — A traditional ingress-based HTTP01 solver.
string json = """
{
"http01": {
"ingress": {
"ingressClassName": "traefik"
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert
details.Mode.Should().Be("ingress");
details.IngressClass.Should().Be("traefik");
details.GatewayName.Should().BeNull();
details.GatewayNamespace.Should().BeNull();
}
[Fact]
public void ParseHttp01Details_WithIngressClassField_ExtractsClass()
{
// Arrange — Some older issuers use "class" instead of "ingressClassName".
string json = """
{
"http01": {
"ingress": {
"class": "nginx"
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert
details.Mode.Should().Be("ingress");
details.IngressClass.Should().Be("nginx");
}
[Fact]
public void ParseHttp01Details_WithNoHttp01_ReturnsNull()
{
// Arrange — A solver that only has dns01.
string json = """
{
"dns01": {
"cloudflare": {}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details? details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert
details.Should().BeNull();
}
[Fact]
public void ParseHttp01Details_WithGatewayHTTPRouteNoNamespace_OmitsNamespace()
{
// Arrange — A gatewayHTTPRoute solver where the parentRef doesn't
// specify a namespace (uses the issuer's namespace by default).
string json = """
{
"http01": {
"gatewayHTTPRoute": {
"parentRefs": [
{
"name": "default-gateway"
}
]
}
}
}
""";
JsonElement solverElement = JsonDocument.Parse(json).RootElement;
// Act
Http01Details details = LetsEncryptCheck.ParseHttp01Details(solverElement);
// Assert
details.Mode.Should().Be("gatewayHTTPRoute");
details.GatewayName.Should().Be("default-gateway");
details.GatewayNamespace.Should().BeNull();
}
}

View File

@@ -20,15 +20,18 @@ public class RegisterClusterHandlerTests
[Fact]
public async Task HandleAsync_WithValidRequest_ReturnsSuccessWithClusterId()
{
// Arrange — A valid registration request with all required fields.
// Arrange — A valid registration request with all required fields
// including the tenant and kubeconfig.
RegisterClusterRequest request = new("production-eu", "https://k8s.example.com:6443", "secret-ref");
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
RegisterClusterRequest request = new("production-eu", "https://k8s.example.com:6443", "apiVersion: v1\nkind: Config", tenantId, "prod-context", environmentId);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — The handler should succeed and the cluster should be persisted.
// Assert — The handler should succeed and the cluster should be persisted with tenant link.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
@@ -36,6 +39,9 @@ public class RegisterClusterHandlerTests
KubernetesCluster? persisted = await repository.GetByIdAsync(result.Value!);
persisted.Should().NotBeNull();
persisted!.Name.Should().Be("production-eu");
persisted.TenantId.Should().Be(tenantId);
persisted.KubeConfig.Should().Contain("apiVersion");
persisted.ContextName.Should().Be("prod-context");
}
[Fact]
@@ -43,7 +49,7 @@ public class RegisterClusterHandlerTests
{
// Arrange — Missing cluster name.
RegisterClusterRequest request = new("", "https://k8s.example.com:6443", null);
RegisterClusterRequest request = new("", "https://k8s.example.com:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act
@@ -60,7 +66,7 @@ public class RegisterClusterHandlerTests
{
// Arrange — Missing API server URL.
RegisterClusterRequest request = new("my-cluster", "", null);
RegisterClusterRequest request = new("my-cluster", "", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act
@@ -71,4 +77,56 @@ public class RegisterClusterHandlerTests
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("API server URL");
}
[Fact]
public async Task HandleAsync_WithEmptyKubeConfig_ReturnsFailure()
{
// Arrange — Missing kubeconfig content. A cluster cannot be registered
// without credentials to access it.
RegisterClusterRequest request = new("my-cluster", "https://k8s.example.com:6443", "", Guid.NewGuid(), "ctx", Guid.NewGuid());
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("KubeConfig");
}
[Fact]
public async Task HandleAsync_WithEmptyTenantId_ReturnsFailure()
{
// Arrange — No tenant specified. Every cluster must belong to a tenant.
RegisterClusterRequest request = new("my-cluster", "https://k8s.example.com:6443", "kubeconfig-data", Guid.Empty, "ctx", Guid.NewGuid());
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("tenant");
}
[Fact]
public async Task HandleAsync_WithEmptyEnvironmentId_ReturnsFailure()
{
// Arrange — No environment specified. Every cluster must belong to an environment.
RegisterClusterRequest request = new("my-cluster", "https://k8s.example.com:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.Empty);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("environment");
}
}

View File

@@ -0,0 +1,111 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.SetProvider;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class SetProviderHandlerTests
{
private readonly SetProviderHandler handler;
private readonly InMemoryClusterRepository repository;
public SetProviderHandlerTests()
{
repository = new InMemoryClusterRepository();
handler = new SetProviderHandler(repository);
}
[Fact]
public async Task HandleAsync_SetCleura_StoresProviderOnCluster()
{
// Arrange — A cluster is already registered with no provider.
// The user now wants to link it to their Cleura account.
KubernetesCluster cluster = KubernetesCluster.Register(
"cleura-cluster", "https://k8s.cleura.cloud", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
SetProviderRequest request = new(cluster.Id, "Cleura", "my-user", "my-password", "Sto2");
// Act
Result result = await handler.HandleAsync(request);
// Assert — The provider and credentials should be persisted on the cluster.
result.IsSuccess.Should().BeTrue();
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.Provider.Should().Be(CloudProvider.Cleura);
updated.ProviderCredentials!.Username.Should().Be("my-user");
updated.ProviderCredentials.Region.Should().Be("Sto2");
}
[Fact]
public async Task HandleAsync_SetNone_ClearsProvider()
{
// Arrange — A cluster currently linked to Cleura. The user wants to unlink it.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.SetProvider(CloudProvider.Cleura, new ProviderCredentials("u", "p", "Fra1"));
await repository.AddAsync(cluster);
SetProviderRequest request = new(cluster.Id, "None", null, null, null);
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.Provider.Should().Be(CloudProvider.None);
updated.ProviderCredentials.Should().BeNull();
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange — Non-existent cluster ID.
SetProviderRequest request = new(Guid.NewGuid(), "Cleura", "u", "p", "Sto2");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_InvalidProvider_ReturnsFailure()
{
// Arrange — An unrecognized provider name.
KubernetesCluster cluster = KubernetesCluster.Register(
"test", "https://k8s.example.com", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
SetProviderRequest request = new(cluster.Id, "UnknownCloud", "u", "p", "eu-west-1");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Unsupported provider");
}
}

View File

@@ -0,0 +1,89 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster.Components.Traefik;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests for the Traefik installer's Helm values generation. Verifies that
/// the correct cloud-provider annotations are applied when internal LB mode
/// is enabled, and that no annotations leak through when disabled.
/// </summary>
public class TraefikInstallerTests
{
[Fact]
public void GetTraefikValues_InternalLbWithCleura_IncludesOpenStackAnnotation()
{
// When Traefik is configured as an internal LB on Cleura, it should use
// the OpenStack annotation instead of dumping all provider annotations.
Dictionary<string, string> parameters = new()
{
["internalLoadBalancer"] = "true"
};
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.Cleura);
values.Should().Contain("openstack-internal-load-balancer");
values.Should().NotContain("azure-load-balancer-internal");
}
[Fact]
public void GetTraefikValues_InternalLbWithAzure_IncludesAzureAnnotation()
{
Dictionary<string, string> parameters = new()
{
["internalLoadBalancer"] = "true"
};
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.Azure);
values.Should().Contain("azure-load-balancer-internal");
values.Should().NotContain("openstack-internal-load-balancer");
}
[Fact]
public void GetTraefikValues_NoInternalLb_HasNoAnnotations()
{
// When internalLoadBalancer is not set, no annotations should be emitted
// regardless of the cloud provider.
Dictionary<string, string> parameters = new();
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.Cleura);
values.Should().NotContain("annotations:");
}
[Fact]
public void GetTraefikValues_CustomReplicas_IncludesReplicaCount()
{
// When a custom replica count is specified, the generated values
// should reflect that in the deployment section.
Dictionary<string, string> parameters = new()
{
["replicas"] = "5"
};
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.None);
values.Should().Contain("replicas: 5");
}
[Fact]
public void GetTraefikValues_DashboardEnabled_IncludesDashboardConfig()
{
// When the dashboard is enabled, the generated values should include
// dashboard configuration.
Dictionary<string, string> parameters = new()
{
["dashboardEnabled"] = "true"
};
string values = TraefikInstaller.GetTraefikValues(parameters, CloudProvider.None);
values.Should().Contain("dashboard");
}
}

View File

@@ -0,0 +1,609 @@
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 trust bundle and internal CA components. These two are co-dependent:
///
/// 1. **cert-manager** — prerequisite, already tested elsewhere
/// 2. **trust-manager** — distributes CA bundles to namespaces via Bundle CRDs
/// 3. **internal-ca** — provisions a self-signed CA ClusterIssuer via cert-manager,
/// then adds its root certificate to the trust bundle
///
/// The dependency chain: cert-manager → internal-ca → trust-manager (bundle update)
/// Both internal-ca and trust-manager require cert-manager to be installed first.
/// </summary>
public class TrustBundleAndInternalCATests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> trustManagerInstaller;
private readonly Mock<IComponentInstaller> internalCaInstaller;
public TrustBundleAndInternalCATests()
{
repository = new InMemoryClusterRepository();
trustManagerInstaller = new Mock<IComponentInstaller>();
trustManagerInstaller.Setup(i => i.ComponentName).Returns("trust-manager");
internalCaInstaller = new Mock<IComponentInstaller>();
internalCaInstaller.Setup(i => i.ComponentName).Returns("internal-ca");
}
// ─── Trust Manager Installer ──────────────────────────────────────────────
[Fact]
public async Task TrustManager_Install_DeploysHelmAndCreatesBundle()
{
// Arrange — cert-manager is already installed. Now we deploy trust-manager
// to distribute CA bundles cluster-wide.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
trustManagerInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "trust-manager",
Message: "trust-manager 0.14.0 installed with platform-trust-bundle Bundle",
Actions: new List<string>
{
"Ensured namespace 'cert-manager'",
"Helm install trust-manager v0.14.0",
"Created Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("trust-manager", Version: "0.14.0",
Parameters: new Dictionary<string, string>
{
["bundleName"] = "platform-trust-bundle",
["targetNamespaces"] = "*"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — trust-manager installed and Bundle CR created.
result.IsSuccess.Should().BeTrue();
result.Value!.ComponentName.Should().Be("trust-manager");
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
trustManagerInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o =>
o.Version == "0.14.0" &&
o.Parameters!["bundleName"] == "platform-trust-bundle"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task TrustManager_Configure_AddsCertificatesToBundle()
{
// Arrange — trust-manager is installed. We want to add extra CA certificates
// to the platform trust bundle (e.g., corporate PKI, external services).
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
trustManagerInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "trust-manager",
Message: "Trust bundle updated with 2 additional certificates",
Actions: new List<string>
{
"Added certificate 'corporate-root-ca' to bundle",
"Added certificate 'partner-api-ca' to bundle"
}));
List<IComponentInstaller> installers = new() { trustManagerInstaller.Object, internalCaInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
// Act — The user adds managed certificates to the trust bundle.
ConfigureComponentRequest configRequest = new(
ComponentName: "trust-manager",
Values: new Dictionary<string, string>
{
["additionalCertificates"] = "corporate-root-ca:LS0tLS1CRUdJTi...;partner-api-ca:LS0tLS1CRUdJTi...",
["bundleName"] = "platform-trust-bundle"
});
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("corporate-root-ca"));
}
// ─── Internal CA ──────────────────────────────────────────────────────────
[Fact]
public async Task InternalCA_Install_CreatesSelfSignedCAAndAddsToBundle()
{
// Arrange — cert-manager and trust-manager are installed. Now we create
// an internal CA that signs service certificates and whose root is
// distributed via the trust bundle.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
internalCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "internal-ca",
Message: "Internal CA provisioned and added to trust bundle",
Actions: new List<string>
{
"Created self-signed ClusterIssuer 'selfsigned-bootstrap'",
"Created root CA Certificate 'platform-internal-ca'",
"Created CA ClusterIssuer 'internal-ca'",
"Added root CA to Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("internal-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "platform-internal-ca",
["bundleName"] = "platform-trust-bundle",
["caOrganization"] = "EntKube Platform",
["caDurationDays"] = "3650"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — CA created and injected into trust bundle.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("ClusterIssuer 'internal-ca'"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
internalCaInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["caName"] == "platform-internal-ca" &&
o.Parameters["bundleName"] == "platform-trust-bundle"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task InternalCA_Adopt_DiscoversExistingCAAndRegistersInBundle()
{
// Arrange — A cluster already has a CA ClusterIssuer. The adoption check
// should discover it and register its root cert in the trust bundle.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
internalCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "internal-ca",
Message: "Existing internal CA adopted and added to trust bundle",
Actions: new List<string>
{
"Discovered existing CA ClusterIssuer 'existing-ca'",
"Read root CA certificate from Secret 'existing-ca-secret'",
"Added root CA to Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("internal-ca",
Parameters: new Dictionary<string, string>
{
["adoptExisting"] = "true",
["existingIssuerName"] = "existing-ca",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Existing CA adopted and cert added to bundle.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("Discovered existing CA"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
}
[Fact]
public async Task InternalCA_Configure_RotatesCACertificate()
{
// Arrange — The internal CA already exists. We want to configure it
// (e.g., update the duration or rotate the CA cert).
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
internalCaInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "internal-ca",
Message: "Internal CA reconfigured",
Actions: new List<string>
{
"Updated CA Certificate duration to 7300 days",
"Refreshed root CA in Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
// Act
ComponentConfiguration config = new("cert-manager", new Dictionary<string, string>
{
["caDurationDays"] = "7300",
["bundleName"] = "platform-trust-bundle"
});
// Use the configure path through the deploy handler
// (the handler routes based on whether the component is already installed)
internalCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "internal-ca",
Message: "Internal CA reconfigured",
Actions: new List<string>
{
"Updated CA Certificate duration to 7300 days",
"Refreshed root CA in Bundle 'platform-trust-bundle'"
}));
DeployComponentRequest request = new("internal-ca",
Parameters: config.Values);
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
}
// ─── Dependency Chain ─────────────────────────────────────────────────────
[Fact]
public async Task InternalCA_WithoutCertManager_ReportsFailure()
{
// Arrange — cert-manager is NOT installed. internal-ca cannot function.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
internalCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "internal-ca",
Message: "cert-manager is required but not installed",
Actions: new List<string> { "Prerequisite check failed: cert-manager not found" }));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("internal-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "platform-internal-ca",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — DeployComponentHandler wraps a failed InstallResult in Result.Failure.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("cert-manager");
}
[Fact]
public async Task TrustManager_AddManagedCertificate_UpdatesBundleWithNewCert()
{
// Arrange — trust-manager is installed. We add a new managed certificate
// (e.g., a partner's CA cert that our services need to trust).
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
trustManagerInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "trust-manager",
Message: "Certificate added to trust bundle",
Actions: new List<string>
{
"Created ConfigMap 'managed-cert-partner-api' with certificate data",
"Updated Bundle 'platform-trust-bundle' to include new source"
}));
// Use the configure handler path (component already installed)
List<IComponentInstaller> installers = new() { trustManagerInstaller.Object, internalCaInstaller.Object };
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "trust-manager",
Values: new Dictionary<string, string>
{
["addCertificate"] = "true",
["certificateName"] = "partner-api",
["certificateData"] = "LS0tLS1CRUdJTi...",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert — Certificate stored and bundle updated to reference it.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("ConfigMap"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
}
// ─── Domain-Scoped CA ───────────────────────────────────────────────────
[Fact]
public async Task DomainCA_Install_CreatesSeparateCAForInternalDomains()
{
// Arrange — The platform already has an internal-ca for service mesh certs.
// Now the team wants a SEPARATE CA specifically for *.internal.corp.com
// so they can issue certs for internal services on that domain.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IComponentInstaller> domainCaInstaller = new();
domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca");
domainCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "domain-ca",
Message: "Domain CA 'corp-internal-ca' provisioned for *.internal.corp.com",
Actions: new List<string>
{
"Created root CA Certificate 'corp-internal-ca'",
"Created CA ClusterIssuer 'corp-internal-ca'",
"Created Certificate policy: only signs for [*.internal.corp.com, *.svc.corp.local]",
"Added root CA to Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("domain-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "corp-internal-ca",
["domains"] = "*.internal.corp.com,*.svc.corp.local",
["caOrganization"] = "Corp Internal",
["caDurationDays"] = "1825",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Separate domain CA created with scope restrictions.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("ClusterIssuer 'corp-internal-ca'"));
result.Value.Actions.Should().Contain(a => a.Contains("policy"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
domainCaInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o =>
o.Parameters!["caName"] == "corp-internal-ca" &&
o.Parameters["domains"] == "*.internal.corp.com,*.svc.corp.local"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DomainCA_ImportExternalCA_CreatesIssuerFromProvidedCertAndKey()
{
// Arrange — The team has a public SSL cert from DigiCert for *.example.com
// and wants to use it as a CA/issuer for subdomains, or simply wants
// cert-manager to serve this cert for ingress. We import it.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IComponentInstaller> domainCaInstaller = new();
domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca");
domainCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "domain-ca",
Message: "External CA 'digicert-example-com' imported for *.example.com",
Actions: new List<string>
{
"Created TLS Secret 'digicert-example-com-tls' with imported cert+key",
"Created CA ClusterIssuer 'digicert-example-com'",
"Added CA certificate to Bundle 'platform-trust-bundle'"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("domain-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "digicert-example-com",
["importExternal"] = "true",
["tlsCert"] = "LS0tLS1CRUdJTi...", // base64 PEM cert
["tlsKey"] = "LS0tLS1CRUdJTi...", // base64 PEM key
["domains"] = "*.example.com",
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — External cert imported, ClusterIssuer created, trust bundle updated.
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("TLS Secret"));
result.Value.Actions.Should().Contain(a => a.Contains("ClusterIssuer"));
result.Value.Actions.Should().Contain(a => a.Contains("Bundle"));
}
[Fact]
public async Task DomainCA_ImportExternalCertOnly_AddsToBundleWithoutIssuer()
{
// Arrange — The team has an external CA root cert (no private key) that
// services need to trust (e.g., partner API CA). We just need it in the
// trust bundle — no ClusterIssuer needed since we can't sign certs with it.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IComponentInstaller> domainCaInstaller = new();
domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca");
domainCaInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "domain-ca",
Message: "External CA certificate 'partner-root-ca' added to trust bundle",
Actions: new List<string>
{
"Created ConfigMap 'managed-cert-partner-root-ca' with CA certificate",
"Added certificate to Bundle 'platform-trust-bundle'",
"No ClusterIssuer created (no private key provided — trust-only)"
}));
DeployComponentHandler handler = new(repository, new List<IComponentInstaller>
{
trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object
}, Microsoft.Extensions.Logging.Abstractions.NullLogger<DeployComponentHandler>.Instance);
DeployComponentRequest request = new("domain-ca",
Parameters: new Dictionary<string, string>
{
["caName"] = "partner-root-ca",
["importExternal"] = "true",
["tlsCert"] = "LS0tLS1CRUdJTi...", // cert only, no key
["bundleName"] = "platform-trust-bundle"
});
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Cert added to trust bundle but no issuer (can't sign without key).
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("trust-only"));
result.Value.Actions.Should().NotContain(a => a.Contains("ClusterIssuer 'partner-root-ca'") && !a.Contains("No ClusterIssuer"));
}
[Fact]
public async Task DomainCA_Configure_UpdatesDomainRestrictions()
{
// Arrange — An existing domain CA needs its allowed domains updated.
KubernetesCluster cluster = CreateConnectedCluster();
await repository.AddAsync(cluster);
Mock<IComponentInstaller> domainCaInstaller = new();
domainCaInstaller.Setup(i => i.ComponentName).Returns("domain-ca");
domainCaInstaller
.Setup(i => i.ConfigureAsync(cluster, It.IsAny<ComponentConfiguration>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "domain-ca",
Message: "Domain CA 'corp-internal-ca' reconfigured",
Actions: new List<string>
{
"Updated Certificate policy: [*.internal.corp.com, *.new-domain.corp.com]"
}));
List<IComponentInstaller> installers = new()
{
trustManagerInstaller.Object, internalCaInstaller.Object, domainCaInstaller.Object
};
ConfigureComponentHandler configHandler = new(repository, installers);
ConfigureComponentRequest configRequest = new(
ComponentName: "domain-ca",
Values: new Dictionary<string, string>
{
["caName"] = "corp-internal-ca",
["domains"] = "*.internal.corp.com,*.new-domain.corp.com"
});
// Act
Result<InstallResult> result = await configHandler.HandleAsync(cluster.Id, configRequest);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value!.Actions.Should().Contain(a => a.Contains("new-domain"));
}
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;
}
}

View File

@@ -0,0 +1,212 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.UninstallComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
public class UninstallComponentHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> kyvernoInstaller;
private readonly Mock<IComponentInstaller> monitoringInstaller;
private readonly UninstallComponentHandler handler;
public UninstallComponentHandlerTests()
{
repository = new InMemoryClusterRepository();
kyvernoInstaller = new Mock<IComponentInstaller>();
monitoringInstaller = new Mock<IComponentInstaller>();
kyvernoInstaller.Setup(i => i.ComponentName).Returns("kyverno");
monitoringInstaller.Setup(i => i.ComponentName).Returns("monitoring");
List<IComponentInstaller> installers = new() { kyvernoInstaller.Object, monitoringInstaller.Object };
handler = new UninstallComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<UninstallComponentHandler>.Instance);
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange — request an uninstall on a cluster that doesn't exist.
UninstallComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ClusterNotConnected_ReturnsFailure()
{
// Arrange — Cluster is Pending, can't uninstall from it.
KubernetesCluster cluster = KubernetesCluster.Register(
"pending-cluster", "https://k8s.dev:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
UninstallComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_UnknownComponent_ReturnsFailureWithAvailable()
{
// Arrange — Requesting an uninstall for a component that has no installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
UninstallComponentRequest request = new("nonexistent-thing");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Should list available installers.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("kyverno");
result.Error.Should().Contain("monitoring");
}
[Fact]
public async Task HandleAsync_ValidComponent_DelegatesToInstallerUninstall()
{
// Arrange — Uninstall Kyverno from a connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig-data", Guid.NewGuid(), "prod-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "kyverno",
Message: "Kyverno uninstalled successfully",
Actions: new List<string> { "Helm uninstall kyverno" }));
UninstallComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Should succeed and return the uninstall result.
result.IsSuccess.Should().BeTrue();
result.Value!.Success.Should().BeTrue();
result.Value.ComponentName.Should().Be("kyverno");
result.Value.Actions.Should().Contain("Helm uninstall kyverno");
}
[Fact]
public async Task HandleAsync_InstallerFails_ReturnsFailure()
{
// Arrange — The uninstall encounters an error.
KubernetesCluster cluster = KubernetesCluster.Register(
"flaky-cluster", "https://k8s.flaky:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: false,
ComponentName: "kyverno",
Message: "Helm uninstall failed: resources still in use",
Actions: new List<string> { "Error: resources still in use" }));
UninstallComponentRequest request = new("kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("resources still in use");
}
[Fact]
public async Task HandleAsync_PassesOptionsToInstaller()
{
// Arrange — Uninstall monitoring with a custom namespace.
KubernetesCluster cluster = KubernetesCluster.Register(
"config-cluster", "https://k8s.cfg:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
ComponentInstallOptions? capturedOptions = null;
monitoringInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.Callback<KubernetesCluster, ComponentInstallOptions, CancellationToken>((_, opts, _) => capturedOptions = opts)
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "monitoring",
Message: "Monitoring uninstalled",
Actions: new List<string>()));
UninstallComponentRequest request = new("monitoring", Namespace: "custom-monitoring");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The handler should forward the namespace to the installer.
result.IsSuccess.Should().BeTrue();
capturedOptions.Should().NotBeNull();
capturedOptions!.Namespace.Should().Be("custom-monitoring");
}
[Fact]
public async Task HandleAsync_ComponentNameIsCaseInsensitive()
{
// Arrange — Use mixed case for the component name.
KubernetesCluster cluster = KubernetesCluster.Register(
"case-cluster", "https://k8s.case:6443", "kubeconfig-data", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
kyvernoInstaller.Setup(i => i.UninstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(
Success: true,
ComponentName: "kyverno",
Message: "Uninstalled",
Actions: new List<string>()));
UninstallComponentRequest request = new("Kyverno");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsSuccess.Should().BeTrue();
}
}

View File

@@ -0,0 +1,93 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.UpdateClusterStatus;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Clusters.Tests.Features;
public class UpdateClusterStatusHandlerTests
{
private readonly UpdateClusterStatusHandler handler;
private readonly InMemoryClusterRepository repository;
public UpdateClusterStatusHandlerTests()
{
repository = new InMemoryClusterRepository();
handler = new UpdateClusterStatusHandler(repository);
}
[Fact]
public async Task HandleAsync_MarkConnected_UpdatesClusterStatus()
{
// Arrange — A pending cluster exists.
KubernetesCluster cluster = KubernetesCluster.Register(
"production",
"https://k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"prod-ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
// Act — Mark it as connected (health check passed).
Result result = await handler.HandleAsync(
new UpdateClusterStatusRequest(cluster.Id, ClusterStatus.Connected));
// Assert
result.IsSuccess.Should().BeTrue();
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.Status.Should().Be(ClusterStatus.Connected);
updated.LastHealthCheckAt.Should().NotBeNull();
}
[Fact]
public async Task HandleAsync_MarkUnreachable_UpdatesClusterStatus()
{
// Arrange — A connected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"staging",
"https://staging-k8s.example.com:6443",
"kubeconfig-data",
Guid.NewGuid(),
"staging-ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
// Act — Health check fails.
Result result = await handler.HandleAsync(
new UpdateClusterStatusRequest(cluster.Id, ClusterStatus.Unreachable));
// Assert
result.IsSuccess.Should().BeTrue();
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
updated!.Status.Should().Be(ClusterStatus.Unreachable);
}
[Fact]
public async Task HandleAsync_WithNonExistentCluster_ReturnsFailure()
{
// Arrange
Guid unknownId = Guid.NewGuid();
// Act
Result result = await handler.HandleAsync(
new UpdateClusterStatusRequest(unknownId, ClusterStatus.Connected));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,266 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster;
using EntKube.Clusters.Features.AdoptCluster.UpgradeComponent;
using EntKube.Clusters.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Clusters.Tests.Features;
/// <summary>
/// Tests the upgrade handler — the flow for upgrading an already-installed
/// component to a specific version. Unlike deploy, upgrade validates that
/// the component is installed and requires an explicit target version.
/// </summary>
public class UpgradeComponentHandlerTests
{
private readonly InMemoryClusterRepository repository;
private readonly Mock<IComponentInstaller> harborInstaller;
private readonly UpgradeComponentHandler handler;
public UpgradeComponentHandlerTests()
{
repository = new InMemoryClusterRepository();
harborInstaller = new Mock<IComponentInstaller>();
harborInstaller.Setup(i => i.ComponentName).Returns("harbor");
List<IComponentInstaller> installers = new() { harborInstaller.Object };
handler = new UpgradeComponentHandler(repository, installers, Microsoft.Extensions.Logging.Abstractions.NullLogger<UpgradeComponentHandler>.Instance);
}
[Fact]
public async Task HandleAsync_ClusterNotFound_ReturnsFailure()
{
// Arrange — Upgrading a component on a cluster that doesn't exist.
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(Guid.NewGuid(), request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_ClusterNotConnected_ReturnsFailure()
{
// Arrange — Can't upgrade components on a disconnected cluster.
KubernetesCluster cluster = KubernetesCluster.Register(
"offline-cluster", "https://k8s.dev:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
await repository.AddAsync(cluster);
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("connected");
}
[Fact]
public async Task HandleAsync_UnknownComponent_ReturnsFailure()
{
// Arrange — Trying to upgrade a component that has no installer.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
UpgradeComponentRequest request = new("nonexistent", "1.0.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("No installer found");
}
[Fact]
public async Task HandleAsync_ComponentNotInstalled_ReturnsFailure()
{
// Arrange — Harbor exists as a component but is NotInstalled. You
// can't upgrade something that isn't there — use Deploy first.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> components = new()
{
new ComponentCheckResult("harbor", ComponentStatus.NotInstalled,
new List<string>(), new List<string> { "Not found" })
};
cluster.UpdateComponents(components);
await repository.AddAsync(cluster);
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not installed");
}
[Fact]
public async Task HandleAsync_ComponentNotTracked_ReturnsFailure()
{
// Arrange — The cluster has no components tracked at all. The
// component hasn't been scanned, so we don't know its state.
KubernetesCluster cluster = KubernetesCluster.Register(
"empty-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
await repository.AddAsync(cluster);
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not installed");
}
[Fact]
public async Task HandleAsync_InstalledComponent_CallsInstallerAndUpdatesVersion()
{
// Arrange — Harbor is installed at 1.16.0. The operator wants to
// upgrade to 1.17.0. The handler should call the installer with
// the new version and update the tracked version on success.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> components = new()
{
new ComponentCheckResult("harbor", ComponentStatus.Installed,
new List<string> { "Running" }, new List<string>(),
new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary<string, string>()))
};
cluster.UpdateComponents(components);
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.Is<ComponentInstallOptions>(o => o.Version == "1.17.0"), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "harbor", "Harbor upgraded to 1.17.0", new List<string> { "Helm upgrade harbor v1.17.0" }));
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — The handler should succeed and the tracked version
// should be updated to reflect the upgrade.
result.IsSuccess.Should().BeTrue();
result.Value!.Success.Should().BeTrue();
result.Value.Message.Should().Contain("1.17.0");
KubernetesCluster? updated = await repository.GetByIdAsync(cluster.Id);
ClusterComponent harbor = updated!.Components.First(c => c.ComponentName == "harbor");
harbor.Version.Should().Be("1.17.0");
}
[Fact]
public async Task HandleAsync_InstallerFails_ReturnsFailureWithoutUpdatingVersion()
{
// Arrange — The upgrade attempt fails (e.g., Helm reports an error).
// The tracked version should NOT be updated.
KubernetesCluster cluster = KubernetesCluster.Register(
"prod-cluster", "https://k8s.prod:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> components = new()
{
new ComponentCheckResult("harbor", ComponentStatus.Installed,
new List<string> { "Running" }, new List<string>(),
new DiscoveredConfiguration("1.16.0", "harbor", "harbor", new Dictionary<string, string>()))
};
cluster.UpdateComponents(components);
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(false, "harbor", "Helm upgrade failed: chart version not found", new List<string>()));
UpgradeComponentRequest request = new("harbor", "99.99.99");
// Act
Result<InstallResult> result = await handler.HandleAsync(cluster.Id, request);
// Assert — Failure reported, version unchanged.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("failed");
KubernetesCluster? unchanged = await repository.GetByIdAsync(cluster.Id);
ClusterComponent harbor = unchanged!.Components.First(c => c.ComponentName == "harbor");
harbor.Version.Should().Be("1.16.0");
}
[Fact]
public async Task HandleAsync_PassesExistingNamespaceToInstaller()
{
// Arrange — When upgrading, the handler should pass the component's
// existing namespace so Helm upgrades in the right place.
KubernetesCluster cluster = KubernetesCluster.Register(
"test-cluster", "https://k8s.test:6443", "kubeconfig", Guid.NewGuid(), "ctx", Guid.NewGuid());
cluster.MarkConnected();
List<ComponentCheckResult> components = new()
{
new ComponentCheckResult("harbor", ComponentStatus.Installed,
new List<string>(), new List<string>(),
new DiscoveredConfiguration("1.16.0", "custom-harbor-ns", "harbor", new Dictionary<string, string>()))
};
cluster.UpdateComponents(components);
await repository.AddAsync(cluster);
harborInstaller
.Setup(i => i.InstallAsync(cluster, It.IsAny<ComponentInstallOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new InstallResult(true, "harbor", "Upgraded", new List<string>()));
UpgradeComponentRequest request = new("harbor", "1.17.0");
// Act
await handler.HandleAsync(cluster.Id, request);
// Assert — The installer should receive the existing namespace.
harborInstaller.Verify(i => i.InstallAsync(
cluster,
It.Is<ComponentInstallOptions>(o => o.Namespace == "custom-harbor-ns" && o.Version == "1.17.0"),
It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -0,0 +1,739 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.AdoptCluster.Components.SecurityPolicies;
using FluentAssertions;
using k8s;
using k8s.Models;
using k8s.Autorest;
using Moq;
using Microsoft.Extensions.Logging;
namespace EntKube.Clusters.Tests.Features;
public class WorkloadRemediatorTests
{
private readonly Mock<IKubernetes> mockClient;
private readonly Mock<IAppsV1Operations> appsMock;
private readonly Mock<ILogger<WorkloadRemediator>> mockLogger;
private readonly WorkloadRemediator sut;
public WorkloadRemediatorTests()
{
mockClient = new Mock<IKubernetes>();
appsMock = new Mock<IAppsV1Operations>();
mockClient.Setup(c => c.AppsV1).Returns(appsMock.Object);
mockLogger = new Mock<ILogger<WorkloadRemediator>>();
sut = new WorkloadRemediator(mockLogger.Object);
// Default: empty workloads for all types.
SetupMockDeployments(new V1DeploymentList { Items = new List<V1Deployment>() });
SetupMockStatefulSets(new V1StatefulSetList { Items = new List<V1StatefulSet>() });
SetupMockDaemonSets(new V1DaemonSetList { Items = new List<V1DaemonSet>() });
}
// --- Seccomp Remediation ---
[Fact]
public async Task RemediateAsync_DeploymentWithoutSeccomp_PatchesSeccompProfile()
{
// Arrange — A deployment exists that has no seccomp profile set.
// The remediator should patch it to add RuntimeDefault seccomp.
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — since the mock returns no workloads, no patches needed.
// This verifies the method runs without errors on empty clusters.
result.Should().NotBeNull();
result.PatchedWorkloads.Should().NotBeNull();
result.PolicyExceptions.Should().NotBeNull();
}
[Fact]
public async Task RemediateAsync_ExcludedNamespacesAreSkipped()
{
// Arrange — Deployments in excluded namespaces should not be touched.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("kube-system", "coredns", withSeccomp: false, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — kube-system workload should be excluded, so nothing patched.
result.PatchedWorkloads.Should().BeEmpty();
result.PolicyExceptions.Should().BeEmpty();
}
[Fact]
public async Task RemediateAsync_DeploymentMissingSeccomp_AddsSeccompPatch()
{
// Arrange — A deployment in a tenant namespace is missing seccomp.
// The remediator should patch it to add seccomp RuntimeDefault.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "my-app", withSeccomp: false, withReadonlyRootfs: true)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — The deployment was patched for seccomp compliance.
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "seccomp");
}
[Fact]
public async Task RemediateAsync_DeploymentMissingReadonlyRootfs_AddsReadonlyPatch()
{
// Arrange — A deployment missing readOnlyRootFilesystem on its containers.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "my-app", withSeccomp: true, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "readonlyRootfs");
}
[Fact]
public async Task RemediateAsync_PatchFails_CreatesPolicyException()
{
// Arrange — A deployment that needs patching but the patch fails.
// In that case, a PolicyException should be created for that workload.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "stubborn-app", withSeccomp: false, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchFailure();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — Since the patch failed, we get a policy exception instead.
result.PolicyExceptions.Should().Contain(e =>
e.WorkloadName == "stubborn-app" && e.Namespace == "app-namespace");
}
[Fact]
public async Task RemediateAsync_StatefulSetMissingSeccomp_PatchesIt()
{
// Arrange — StatefulSets should also be remediated.
V1StatefulSetList statefulSets = new()
{
Items = new List<V1StatefulSet>
{
CreateStatefulSet("data-namespace", "postgres", withSeccomp: false, withReadonlyRootfs: true)
}
};
SetupEmptyDeployments();
SetupMockStatefulSets(statefulSets);
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "postgres" && w.Namespace == "data-namespace" && w.Reason == "seccomp");
}
[Fact]
public async Task RemediateAsync_DaemonSetMissingReadonly_PatchesIt()
{
// Arrange — DaemonSets should also be remediated.
V1DaemonSetList daemonSets = new()
{
Items = new List<V1DaemonSet>
{
CreateDaemonSet("monitoring", "node-exporter", withSeccomp: true, withReadonlyRootfs: false)
}
};
SetupEmptyDeployments();
SetupEmptyStatefulSets();
SetupMockDaemonSets(daemonSets);
SetupPatchSuccess();
// Act — monitoring is NOT in excluded list for this test.
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "node-exporter" && w.Namespace == "monitoring" && w.Reason == "readonlyRootfs");
}
[Fact]
public async Task RemediateAsync_CompliantWorkload_NoChanges()
{
// Arrange — A workload that already has seccomp and readonlyRootfs.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "compliant-app", withSeccomp: true, withReadonlyRootfs: true)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — Compliant workload should not be touched.
result.PatchedWorkloads.Should().BeEmpty();
result.PolicyExceptions.Should().BeEmpty();
}
[Fact]
public async Task CreatePolicyExceptionAsync_CreatesValidKyvernoPolicyException()
{
// Arrange — We need to create a PolicyException for a specific workload.
PolicyExceptionRequest request = new(
WorkloadName: "my-app",
Namespace: "app-namespace",
WorkloadKind: "Deployment",
PolicyNames: new List<string> { "require-seccomp-runtime-default", "require-readonly-root-filesystem" });
// Act
object exception = WorkloadRemediator.BuildPolicyException(request);
// Assert — The resulting object should have the correct Kyverno structure.
exception.Should().NotBeNull();
Dictionary<string, object> exceptionDict = (Dictionary<string, object>)exception;
exceptionDict["apiVersion"].Should().Be("kyverno.io/v2");
exceptionDict["kind"].Should().Be("PolicyException");
}
[Fact]
public async Task RemediateAsync_WritableRootFsApp_SkipsReadonlyPatch()
{
// Arrange — A Keycloak deployment in a tenant namespace doesn't have
// readOnlyRootFilesystem set. Because Keycloak requires a writable root,
// the remediator should NOT attempt to patch it for readonly compliance.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("tenant-apps", "keycloak", withSeccomp: true, withReadonlyRootfs: false,
labels: new Dictionary<string, string> { ["app.kubernetes.io/name"] = "keycloak" })
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — No readonlyRootfs patch should be applied, and no exception needed.
result.PatchedWorkloads.Should().NotContain(w => w.Reason == "readonlyRootfs");
result.PolicyExceptions.Should().BeEmpty();
}
[Fact]
public async Task RemediateAsync_MinioStatefulSet_SkipsReadonlyPatch()
{
// Arrange — A MinIO StatefulSet needs a writable root filesystem for data
// storage. It should be skipped for readOnly remediation but still get
// seccomp patched if missing.
V1StatefulSet minioSts = new()
{
Metadata = new V1ObjectMeta { Name = "minio", NamespaceProperty = "tenant-storage" },
Spec = new V1StatefulSetSpec
{
Template = new V1PodTemplateSpec
{
Metadata = new V1ObjectMeta
{
Labels = new Dictionary<string, string> { ["app.kubernetes.io/name"] = "minio" }
},
Spec = new V1PodSpec
{
Containers = new List<V1Container>
{
new() { Name = "minio", Image = "minio/minio:latest", SecurityContext = new V1SecurityContext() }
}
}
}
}
};
SetupMockDeployments(new V1DeploymentList { Items = new List<V1Deployment>() });
SetupMockStatefulSets(new V1StatefulSetList { Items = new List<V1StatefulSet> { minioSts } });
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RemediateAsync(
mockClient.Object,
new List<string> { "kube-system" },
CancellationToken.None);
// Assert — Seccomp should be patched, but readonlyRootfs should NOT.
result.PatchedWorkloads.Should().Contain(w => w.Reason == "seccomp");
result.PatchedWorkloads.Should().NotContain(w => w.Reason == "readonlyRootfs");
}
// --- Helper Methods ---
private V1Deployment CreateDeployment(string ns, string name, bool withSeccomp, bool withReadonlyRootfs, Dictionary<string, string>? labels = null)
{
V1SecurityContext containerSecurity = new()
{
ReadOnlyRootFilesystem = withReadonlyRootfs ? true : null
};
V1PodSecurityContext podSecurity = new();
if (withSeccomp)
{
podSecurity.SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" };
}
return new V1Deployment
{
Metadata = new V1ObjectMeta { Name = name, NamespaceProperty = ns },
Spec = new V1DeploymentSpec
{
Template = new V1PodTemplateSpec
{
Metadata = new V1ObjectMeta { Labels = labels },
Spec = new V1PodSpec
{
SecurityContext = podSecurity,
Containers = new List<V1Container>
{
new() { Name = "main", Image = "docker.io/app:latest", SecurityContext = containerSecurity }
}
}
}
}
};
}
private V1StatefulSet CreateStatefulSet(string ns, string name, bool withSeccomp, bool withReadonlyRootfs)
{
V1SecurityContext containerSecurity = new()
{
ReadOnlyRootFilesystem = withReadonlyRootfs ? true : null
};
V1PodSecurityContext podSecurity = new();
if (withSeccomp)
{
podSecurity.SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" };
}
return new V1StatefulSet
{
Metadata = new V1ObjectMeta { Name = name, NamespaceProperty = ns },
Spec = new V1StatefulSetSpec
{
Template = new V1PodTemplateSpec
{
Spec = new V1PodSpec
{
SecurityContext = podSecurity,
Containers = new List<V1Container>
{
new() { Name = "main", Image = "docker.io/app:latest", SecurityContext = containerSecurity }
}
}
}
}
};
}
private V1DaemonSet CreateDaemonSet(string ns, string name, bool withSeccomp, bool withReadonlyRootfs)
{
V1SecurityContext containerSecurity = new()
{
ReadOnlyRootFilesystem = withReadonlyRootfs ? true : null
};
V1PodSecurityContext podSecurity = new();
if (withSeccomp)
{
podSecurity.SeccompProfile = new V1SeccompProfile { Type = "RuntimeDefault" };
}
return new V1DaemonSet
{
Metadata = new V1ObjectMeta { Name = name, NamespaceProperty = ns },
Spec = new V1DaemonSetSpec
{
Template = new V1PodTemplateSpec
{
Spec = new V1PodSpec
{
SecurityContext = podSecurity,
Containers = new List<V1Container>
{
new() { Name = "main", Image = "docker.io/app:latest", SecurityContext = containerSecurity }
}
}
}
}
};
}
private void SetupMockDeployments(V1DeploymentList deployments)
{
HttpOperationResponse<V1DeploymentList> response = new() { Body = deployments };
appsMock.Setup(a => a.ListDeploymentForAllNamespacesWithHttpMessagesAsync(
It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<int?>(), It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<bool?>(), It.IsAny<int?>(), It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
}
private void SetupEmptyDeployments()
{
SetupMockDeployments(new V1DeploymentList { Items = new List<V1Deployment>() });
}
private void SetupMockStatefulSets(V1StatefulSetList statefulSets)
{
HttpOperationResponse<V1StatefulSetList> response = new() { Body = statefulSets };
appsMock.Setup(a => a.ListStatefulSetForAllNamespacesWithHttpMessagesAsync(
It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<int?>(), It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<bool?>(), It.IsAny<int?>(), It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
}
private void SetupEmptyStatefulSets()
{
SetupMockStatefulSets(new V1StatefulSetList { Items = new List<V1StatefulSet>() });
}
private void SetupMockDaemonSets(V1DaemonSetList daemonSets)
{
HttpOperationResponse<V1DaemonSetList> response = new() { Body = daemonSets };
appsMock.Setup(a => a.ListDaemonSetForAllNamespacesWithHttpMessagesAsync(
It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<int?>(), It.IsAny<bool?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<bool?>(), It.IsAny<int?>(), It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(response);
}
private void SetupEmptyDaemonSets()
{
SetupMockDaemonSets(new V1DaemonSetList { Items = new List<V1DaemonSet>() });
}
private void SetupPatchSuccess()
{
HttpOperationResponse<V1Deployment> deployResponse = new() { Body = new V1Deployment() };
appsMock.Setup(a => a.PatchNamespacedDeploymentWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(deployResponse);
HttpOperationResponse<V1StatefulSet> stsResponse = new() { Body = new V1StatefulSet() };
appsMock.Setup(a => a.PatchNamespacedStatefulSetWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(stsResponse);
HttpOperationResponse<V1DaemonSet> dsResponse = new() { Body = new V1DaemonSet() };
appsMock.Setup(a => a.PatchNamespacedDaemonSetWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(dsResponse);
}
private void SetupPatchFailure()
{
appsMock.Setup(a => a.PatchNamespacedDeploymentWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpOperationException("Patch failed") { Response = new HttpResponseMessageWrapper(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), "") });
appsMock.Setup(a => a.PatchNamespacedStatefulSetWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpOperationException("Patch failed") { Response = new HttpResponseMessageWrapper(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), "") });
appsMock.Setup(a => a.PatchNamespacedDaemonSetWithHttpMessagesAsync(
It.IsAny<V1Patch>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<bool?>(),
It.IsAny<bool?>(),
It.IsAny<IReadOnlyDictionary<string, IReadOnlyList<string>>?>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpOperationException("Patch failed") { Response = new HttpResponseMessageWrapper(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.Forbidden), "") });
}
// --- Revert Patches ---
[Fact]
public async Task RevertPatchesAsync_DeploymentWithSeccomp_RemovesSeccompProfile()
{
// Arrange — A deployment that has seccomp RuntimeDefault set. The revert
// should remove the seccomp profile so the workload no longer requires it.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "my-app", withSeccomp: true, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — The deployment should have been reverted for seccomp.
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "revert-seccomp");
}
[Fact]
public async Task RevertPatchesAsync_DeploymentWithReadonlyRootfs_RemovesReadonly()
{
// Arrange — A deployment that has readOnlyRootFilesystem=true on its containers.
// The revert should set it back to false.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "my-app", withSeccomp: false, withReadonlyRootfs: true)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — The deployment should have been reverted for readonlyRootfs.
result.PatchedWorkloads.Should().Contain(w =>
w.Name == "my-app" && w.Namespace == "app-namespace" && w.Reason == "revert-readonlyRootfs");
}
[Fact]
public async Task RevertPatchesAsync_WritableRootFsApp_SkipsReadonlyButRevertsSeccomp()
{
// Arrange — A Keycloak deployment has both seccomp and readOnlyRootFilesystem.
// Following the Terraform exclusion pattern, Keycloak is in WritableRootFsApps
// so readOnly was never applied by our remediator — we must NOT revert it.
// But seccomp WAS applied to everything, so it should still be reverted.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("tenant-apps", "keycloak", withSeccomp: true, withReadonlyRootfs: true,
labels: new Dictionary<string, string> { ["app.kubernetes.io/name"] = "keycloak" })
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
SetupPatchSuccess();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — Seccomp reverted (applied to all), readOnly NOT reverted (excluded app).
result.PatchedWorkloads.Should().Contain(w => w.Reason == "revert-seccomp");
result.PatchedWorkloads.Should().NotContain(w => w.Reason == "revert-readonlyRootfs");
}
[Fact]
public async Task RevertPatchesAsync_ExcludedNamespace_SkipsWorkloads()
{
// Arrange — Workloads in excluded namespaces should not be reverted.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("kube-system", "coredns", withSeccomp: true, withReadonlyRootfs: true)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — kube-system workloads are excluded, so nothing reverted.
result.PatchedWorkloads.Should().BeEmpty();
}
[Fact]
public async Task RevertPatchesAsync_CompliantAndUnpatched_NoChanges()
{
// Arrange — A workload that has neither seccomp nor readOnlyRootFilesystem
// set. Nothing to revert.
V1DeploymentList deployments = new()
{
Items = new List<V1Deployment>
{
CreateDeployment("app-namespace", "vanilla-app", withSeccomp: false, withReadonlyRootfs: false)
}
};
SetupMockDeployments(deployments);
SetupEmptyStatefulSets();
SetupEmptyDaemonSets();
// Act
RemediationResult result = await sut.RevertPatchesAsync(
mockClient.Object,
new List<string> { "kube-system", "kyverno" },
CancellationToken.None);
// Assert — Nothing to revert.
result.PatchedWorkloads.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,116 @@
using EntKube.Identity.Domain;
using FluentAssertions;
namespace EntKube.Identity.Tests.Domain;
public class CustomerTests
{
[Fact]
public void Create_WithValidInputs_CreatesActiveCustomer()
{
// Arrange — A tenant admin onboards a new customer (team/organization)
// within their tenant. In the terraform reference, these are the "tenants"
// like capioDA, capioonline, volvat — each representing a team that gets
// its own namespaces, quotas, and app deployments per environment.
Guid tenantId = Guid.NewGuid();
// Act
Customer customer = Customer.Create(tenantId, "Capio Data Analytics", "capioda");
// Assert — The customer should be active and linked to its tenant.
customer.Id.Should().NotBe(Guid.Empty);
customer.TenantId.Should().Be(tenantId);
customer.Name.Should().Be("Capio Data Analytics");
customer.Slug.Should().Be("capioda");
customer.Status.Should().Be(CustomerStatus.Active);
customer.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void Create_NormalizesSlugToLowerCase()
{
// Arrange & Act — Slugs should be lowercase for consistent lookups.
Customer customer = Customer.Create(Guid.NewGuid(), "Volvat", "VOLVAT");
// Assert
customer.Slug.Should().Be("volvat");
}
[Fact]
public void Create_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => Customer.Create(Guid.NewGuid(), "", "slug");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("name");
}
[Fact]
public void Create_WithEmptySlug_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => Customer.Create(Guid.NewGuid(), "Some Customer", "");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("slug");
}
[Fact]
public void Create_WithEmptyTenantId_ThrowsArgumentException()
{
// Arrange & Act — Every customer must belong to a tenant.
Action act = () => Customer.Create(Guid.Empty, "Some Customer", "slug");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("tenantId");
}
[Fact]
public void Suspend_SetsStatusToSuspended()
{
// Arrange — An active customer that the admin wants to temporarily disable
// (e.g. during a billing dispute or compliance review).
Customer customer = Customer.Create(Guid.NewGuid(), "Volvat", "volvat");
// Act
customer.Suspend();
// Assert
customer.Status.Should().Be(CustomerStatus.Suspended);
}
[Fact]
public void Activate_SetsStatusToActive()
{
// Arrange — A previously suspended customer being re-enabled.
Customer customer = Customer.Create(Guid.NewGuid(), "Volvat", "volvat");
customer.Suspend();
// Act
customer.Activate();
// Assert
customer.Status.Should().Be(CustomerStatus.Active);
}
}

View File

@@ -0,0 +1,115 @@
using EntKube.Identity.Domain;
using FluentAssertions;
namespace EntKube.Identity.Tests.Domain;
public class EnvironmentTests
{
[Fact]
public void Create_WithValidInputs_CreatesActiveEnvironment()
{
// Arrange — A tenant admin creates a new environment (e.g. "dev")
// for their organization. The environment starts in an Active state
// and is ready to have clusters assigned to it.
Guid tenantId = Guid.NewGuid();
// Act
TenantEnvironment env = TenantEnvironment.Create(tenantId, "Development", "dev");
// Assert — The environment should be active and linked to its tenant.
env.Id.Should().NotBe(Guid.Empty);
env.TenantId.Should().Be(tenantId);
env.Name.Should().Be("Development");
env.Slug.Should().Be("dev");
env.Status.Should().Be(EnvironmentStatus.Active);
env.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void Create_NormalizesSlugToLowerCase()
{
// Arrange & Act — Slugs should always be lowercase for consistent lookups.
TenantEnvironment env = TenantEnvironment.Create(Guid.NewGuid(), "Production", "PROD");
// Assert
env.Slug.Should().Be("prod");
}
[Fact]
public void Create_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => TenantEnvironment.Create(Guid.NewGuid(), "", "dev");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("name");
}
[Fact]
public void Create_WithEmptySlug_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => TenantEnvironment.Create(Guid.NewGuid(), "Development", "");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("slug");
}
[Fact]
public void Create_WithEmptyTenantId_ThrowsArgumentException()
{
// Arrange & Act — Every environment must belong to a tenant.
Action act = () => TenantEnvironment.Create(Guid.Empty, "Development", "dev");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("tenantId");
}
[Fact]
public void Deactivate_SetsStatusToInactive()
{
// Arrange — An active environment that the admin wants to disable
// (e.g. decommissioning a staging environment).
TenantEnvironment env = TenantEnvironment.Create(Guid.NewGuid(), "Staging", "staging");
// Act
env.Deactivate();
// Assert
env.Status.Should().Be(EnvironmentStatus.Inactive);
}
[Fact]
public void Activate_SetsStatusToActive()
{
// Arrange — A previously deactivated environment being re-enabled.
TenantEnvironment env = TenantEnvironment.Create(Guid.NewGuid(), "Staging", "staging");
env.Deactivate();
// Act
env.Activate();
// Assert
env.Status.Should().Be(EnvironmentStatus.Active);
}
}

View File

@@ -0,0 +1,50 @@
using EntKube.Identity.Domain;
using EntKube.Identity.Features.GetTenants;
using EntKube.Identity.Infrastructure;
using FluentAssertions;
namespace EntKube.Identity.Tests.Features;
public class GetTenantsHandlerTests
{
private readonly InMemoryTenantRepository repository;
public GetTenantsHandlerTests()
{
repository = new InMemoryTenantRepository();
}
[Fact]
public async Task GetAllAsync_WithTenants_ReturnsList()
{
// Arrange — Two tenants exist in the repository.
Tenant tenant1 = Tenant.Create("Acme Corp", "acme", Guid.NewGuid());
Tenant tenant2 = Tenant.Create("Globex", "globex", Guid.NewGuid());
await repository.AddAsync(tenant1);
await repository.AddAsync(tenant2);
// Act — Retrieve all tenants via the repository (endpoint uses it directly).
IReadOnlyList<Tenant> tenants = await repository.GetAllAsync();
// Assert
tenants.Should().HaveCount(2);
tenants.Should().Contain(t => t.Name == "Acme Corp");
tenants.Should().Contain(t => t.Name == "Globex");
}
[Fact]
public async Task GetAllAsync_Empty_ReturnsEmptyList()
{
// Act
IReadOnlyList<Tenant> tenants = await repository.GetAllAsync();
// Assert
tenants.Should().BeEmpty();
}
}

View File

@@ -0,0 +1,180 @@
using EntKube.Provisioning.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
public class AppEnvironmentTests
{
// ─── ConfigureDeployment ─────────────────────────────────────────────
[Fact]
public void ConfigureDeployment_WithValidSpec_SetsDeploymentSpec()
{
// Arrange — After adding an environment, the admin configures
// the deployment details: which container image, how many replicas,
// port mappings, and routing. This is the equivalent of writing
// deployment.yaml + service.yaml + httproute.yaml by hand.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
DeploymentSpec spec = new(
Image: "registry.example.com/api",
Tag: "v1.2.3",
Replicas: 2,
ContainerPort: 8080,
ServicePort: 80,
HostName: "api.dev.example.com",
PathPrefix: "/",
EnvironmentVariables: new Dictionary<string, string> { ["ASPNETCORE_ENVIRONMENT"] = "Development" },
Resources: new ResourceSpec("100m", "500m", "128Mi", "512Mi"));
// Act
env.ConfigureDeployment(spec);
// Assert
env.DeploymentSpec.Should().NotBeNull();
env.DeploymentSpec!.Image.Should().Be("registry.example.com/api");
env.DeploymentSpec.Tag.Should().Be("v1.2.3");
env.DeploymentSpec.Replicas.Should().Be(2);
env.DeploymentSpec.ContainerPort.Should().Be(8080);
env.DeploymentSpec.ServicePort.Should().Be(80);
env.DeploymentSpec.HostName.Should().Be("api.dev.example.com");
env.DeploymentSpec.Resources.Should().NotBeNull();
env.SyncStatus.Should().Be(AppSyncStatus.Pending, "changing config resets sync to pending");
}
// ─── ConfigureHelmRelease ────────────────────────────────────────────
[Fact]
public void ConfigureHelmRelease_WithValidSpec_SetsHelmSpec()
{
// Arrange — For a Helm-type app, the admin points to a Helm chart
// repository, selects the chart and version, and provides a values.yaml.
// This is the equivalent of `helm install` with custom values.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "Redis", "redis", AppType.HelmChart);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "redis-dev");
HelmReleaseSpec spec = new(
RepoUrl: "https://charts.bitnami.com/bitnami",
ChartName: "redis",
ChartVersion: "18.6.1",
ValuesYaml: "replica:\n replicaCount: 3");
// Act
env.ConfigureHelmRelease(spec);
// Assert
env.HelmReleaseSpec.Should().NotBeNull();
env.HelmReleaseSpec!.RepoUrl.Should().Be("https://charts.bitnami.com/bitnami");
env.HelmReleaseSpec.ChartName.Should().Be("redis");
env.HelmReleaseSpec.ChartVersion.Should().Be("18.6.1");
env.HelmReleaseSpec.ValuesYaml.Should().Contain("replicaCount: 3");
env.SyncStatus.Should().Be(AppSyncStatus.Pending);
}
// ─── Secrets ─────────────────────────────────────────────────────────
[Fact]
public void AddSecret_WithValidInputs_AddsSecretReference()
{
// Arrange — Secrets for an app environment are stored in the vault.
// Here we add a reference that maps a Kubernetes secret key name
// to the vault path where the actual secret value lives.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
// Act
env.AddSecret("DATABASE_URL", "database-connection-string");
// Assert
env.Secrets.Should().HaveCount(1);
env.Secrets[0].Name.Should().Be("DATABASE_URL");
env.Secrets[0].VaultKey.Should().Be("database-connection-string");
env.SyncStatus.Should().Be(AppSyncStatus.Pending, "adding a secret resets sync");
}
[Fact]
public void AddSecret_DuplicateName_ThrowsInvalidOperation()
{
// Arrange — Each secret name must be unique within an environment.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
env.AddSecret("DATABASE_URL", "db-conn-string");
// Act
Action act = () => env.AddSecret("DATABASE_URL", "another-path");
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*already exists*");
}
[Fact]
public void RemoveSecret_ExistingSecret_RemovesIt()
{
// Arrange
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
env.AddSecret("DATABASE_URL", "db-conn-string");
// Act
env.RemoveSecret("DATABASE_URL");
// Assert
env.Secrets.Should().BeEmpty();
env.SyncStatus.Should().Be(AppSyncStatus.Pending);
}
// ─── Sync Status ─────────────────────────────────────────────────────
[Fact]
public void MarkSynced_UpdatesSyncStatus()
{
// Arrange — The reconciler successfully deployed the app.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
// Act
env.MarkSynced();
// Assert
env.SyncStatus.Should().Be(AppSyncStatus.Synced);
env.LastSyncAt.Should().NotBeNull();
}
[Fact]
public void MarkError_SetsSyncStatusAndMessage()
{
// Arrange — Something went wrong during deployment.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
// Act
env.MarkError("ImagePullBackOff: registry.example.com/api:v1.2.3");
// Assert
env.SyncStatus.Should().Be(AppSyncStatus.Error);
env.SyncStatusMessage.Should().Contain("ImagePullBackOff");
}
}

View File

@@ -0,0 +1,229 @@
using EntKube.Provisioning.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
public class AppTests
{
// ─── Create ──────────────────────────────────────────────────────────
[Fact]
public void Create_WithValidInputs_CreatesActiveApp()
{
// Arrange — A customer admin creates a new app (e.g. "frontend-portal")
// that will eventually be deployed across environments. The app starts
// in an Active state and is ready to have environments added.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
// Act
App app = App.Create(tenantId, customerId, "Frontend Portal", "frontend-portal", AppType.Deployment);
// Assert — The app should be active and linked to its customer and tenant.
app.Id.Should().NotBe(Guid.Empty);
app.TenantId.Should().Be(tenantId);
app.CustomerId.Should().Be(customerId);
app.Name.Should().Be("Frontend Portal");
app.Slug.Should().Be("frontend-portal");
app.Type.Should().Be(AppType.Deployment);
app.Status.Should().Be(AppStatus.Active);
app.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
app.Environments.Should().BeEmpty();
}
[Fact]
public void Create_NormalizesSlugToLowerCase()
{
// Arrange & Act — Slugs should always be lowercase for consistency.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "My App", "MY-APP", AppType.HelmChart);
// Assert
app.Slug.Should().Be("my-app");
}
[Fact]
public void Create_WithEmptyTenantId_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => App.Create(Guid.Empty, Guid.NewGuid(), "App", "app", AppType.Deployment);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("tenantId");
}
[Fact]
public void Create_WithEmptyCustomerId_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => App.Create(Guid.NewGuid(), Guid.Empty, "App", "app", AppType.Deployment);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("customerId");
}
[Fact]
public void Create_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => App.Create(Guid.NewGuid(), Guid.NewGuid(), "", "app", AppType.Deployment);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("name");
}
[Fact]
public void Create_WithEmptySlug_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "", AppType.Deployment);
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("slug");
}
// ─── Suspend / Activate ──────────────────────────────────────────────
[Fact]
public void Suspend_SetsStatusToSuspended()
{
// Arrange — An active app that the admin wants to pause deployments for.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
// Act
app.Suspend();
// Assert
app.Status.Should().Be(AppStatus.Suspended);
}
[Fact]
public void Activate_SetsStatusToActive()
{
// Arrange — A previously suspended app being re-enabled.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
app.Suspend();
// Act
app.Activate();
// Assert
app.Status.Should().Be(AppStatus.Active);
}
// ─── AddEnvironment ──────────────────────────────────────────────────
[Fact]
public void AddEnvironment_WithValidInputs_CreatesAppEnvironment()
{
// Arrange — After creating an app, the admin adds it to a specific
// environment (e.g. "dev"). This links the app to a cluster and
// namespace where it will be deployed.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API Service", "api-service", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
// Act
AppEnvironment env = app.AddEnvironment(environmentId, clusterId, "api-dev");
// Assert — The environment should be created in Pending state.
env.Id.Should().NotBe(Guid.Empty);
env.EnvironmentId.Should().Be(environmentId);
env.ClusterId.Should().Be(clusterId);
env.Namespace.Should().Be("api-dev");
env.SyncStatus.Should().Be(AppSyncStatus.Pending);
app.Environments.Should().HaveCount(1);
}
[Fact]
public void AddEnvironment_DuplicateEnvironmentId_ThrowsInvalidOperation()
{
// Arrange — An app can only be deployed to each environment once.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "app-dev");
// Act
Action act = () => app.AddEnvironment(environmentId, Guid.NewGuid(), "app-dev-2");
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*already*");
}
[Fact]
public void AddEnvironment_WithEmptyNamespace_ThrowsArgumentException()
{
// Arrange & Act
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
Action act = () => app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("ns");
}
// ─── RemoveEnvironment ───────────────────────────────────────────────
[Fact]
public void RemoveEnvironment_ExistingEnvironment_RemovesIt()
{
// Arrange — An app deployed to dev that the admin wants to remove.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "app-dev");
// Act
app.RemoveEnvironment(environmentId);
// Assert
app.Environments.Should().BeEmpty();
}
[Fact]
public void RemoveEnvironment_NonExistent_ThrowsInvalidOperation()
{
// Arrange & Act
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "App", "app", AppType.Deployment);
Action act = () => app.RemoveEnvironment(Guid.NewGuid());
// Assert
act.Should().Throw<InvalidOperationException>()
.WithMessage("*not found*");
}
}

View File

@@ -0,0 +1,360 @@
using EntKube.Provisioning.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
public class GrafanaInstanceTests
{
private readonly Guid environmentId = Guid.NewGuid();
private readonly Guid clusterId = Guid.NewGuid();
// ─── Creation ───────────────────────────────────────────────────────
[Fact]
public void Create_WithValidInputs_CreatesInstanceInPendingState()
{
// Arrange & Act — An operator provisions a Grafana instance for an environment.
// Grafana is per-environment: one centralized visualization for all clusters
// in that environment, deployed to a specific cluster.
GrafanaInstance instance = GrafanaInstance.Create(
environmentId, clusterId, "prod-grafana", "monitoring");
// Assert — The instance should be in Pending state with sensible defaults.
instance.Id.Should().NotBe(Guid.Empty);
instance.EnvironmentId.Should().Be(environmentId);
instance.ClusterId.Should().Be(clusterId);
instance.Name.Should().Be("prod-grafana");
instance.Namespace.Should().Be("monitoring");
instance.Status.Should().Be(GrafanaInstanceStatus.Pending);
instance.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
// Grafana defaults
instance.Persistence.Should().BeFalse();
instance.PersistenceSize.Should().Be("10Gi");
instance.Oidc.Should().BeNull();
instance.Ingress.Should().BeNull();
}
[Fact]
public void Create_WithEmptyEnvironmentId_ThrowsArgumentException()
{
// Arrange & Act — Grafana must belong to an environment.
Action act = () => GrafanaInstance.Create(Guid.Empty, clusterId, "prod-grafana", "monitoring");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("environmentId");
}
[Fact]
public void Create_WithEmptyClusterId_ThrowsArgumentException()
{
// Arrange & Act — Grafana must be deployed to a specific cluster.
Action act = () => GrafanaInstance.Create(environmentId, Guid.Empty, "prod-grafana", "monitoring");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("clusterId");
}
[Fact]
public void Create_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => GrafanaInstance.Create(environmentId, clusterId, "", "monitoring");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("name");
}
[Fact]
public void Create_WithEmptyNamespace_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("ns");
}
// ─── Persistence Configuration ──────────────────────────────────
[Fact]
public void ConfigurePersistence_UpdatesSettings()
{
// Arrange — An operator wants Grafana to keep dashboards across restarts.
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
// Act
instance.ConfigurePersistence(enabled: true, size: "20Gi");
// Assert
instance.Persistence.Should().BeTrue();
instance.PersistenceSize.Should().Be("20Gi");
}
// ─── OIDC Configuration ─────────────────────────────────────────
[Fact]
public void ConfigureOidc_SetsOidcProvider()
{
// Arrange — Integrate Grafana with an OIDC provider (e.g., Entra ID)
// so users authenticate with corporate credentials.
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
// Act
OidcConfiguration oidc = new(
Provider: "generic_oauth",
ClientId: "grafana-client-id",
ClientSecretRef: "vault://monitoring/grafana-oidc-client-secret",
AuthUrl: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize",
TokenUrl: "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token",
ApiUrl: "https://graph.microsoft.com/oidc/userinfo",
Scopes: "openid profile email",
RoleAttributePath: "contains(groups[*], 'admin-group-id') && 'Admin' || 'Viewer'",
AutoLogin: true);
instance.ConfigureOidc(oidc);
// Assert
instance.Oidc.Should().NotBeNull();
instance.Oidc!.Provider.Should().Be("generic_oauth");
instance.Oidc.ClientId.Should().Be("grafana-client-id");
instance.Oidc.ClientSecretRef.Should().Be("vault://monitoring/grafana-oidc-client-secret");
instance.Oidc.AutoLogin.Should().BeTrue();
}
[Fact]
public void ConfigureOidc_WithEmptyClientId_ThrowsArgumentException()
{
// Arrange
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
// Act & Assert
OidcConfiguration oidc = new(
Provider: "generic_oauth",
ClientId: "",
ClientSecretRef: "vault://secret",
AuthUrl: "https://example.com/auth",
TokenUrl: "https://example.com/token",
ApiUrl: "https://example.com/userinfo",
Scopes: "openid",
RoleAttributePath: null,
AutoLogin: false);
Action act = () => instance.ConfigureOidc(oidc);
act.Should().Throw<ArgumentException>();
}
[Fact]
public void RemoveOidc_ClearsOidcConfiguration()
{
// Arrange — A Grafana instance with OIDC configured.
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
OidcConfiguration oidc = new(
Provider: "generic_oauth",
ClientId: "client-id",
ClientSecretRef: "vault://secret",
AuthUrl: "https://example.com/auth",
TokenUrl: "https://example.com/token",
ApiUrl: "https://example.com/userinfo",
Scopes: "openid",
RoleAttributePath: null,
AutoLogin: false);
instance.ConfigureOidc(oidc);
// Act — Remove OIDC, fall back to local auth.
instance.RemoveOidc();
// Assert
instance.Oidc.Should().BeNull();
}
// ─── Ingress ────────────────────────────────────────────────────
[Fact]
public void ConfigureIngress_SetsGrafanaRouting()
{
// Arrange — Publish Grafana externally with its own hostname.
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
// Act
GrafanaIngressConfiguration ingress = new(
Enabled: true,
Provider: IngressProvider.GatewayApi,
Hostname: "grafana.prod01.example.com",
TlsEnabled: true,
TlsCertificateMode: TlsCertificateMode.CertManager,
TlsSecretName: null,
ClusterIssuer: "letsencrypt-prod");
instance.ConfigureIngress(ingress);
// Assert
instance.Ingress.Should().NotBeNull();
instance.Ingress!.Enabled.Should().BeTrue();
instance.Ingress.Provider.Should().Be(IngressProvider.GatewayApi);
instance.Ingress.Hostname.Should().Be("grafana.prod01.example.com");
instance.Ingress.TlsCertificateMode.Should().Be(TlsCertificateMode.CertManager);
instance.Ingress.ClusterIssuer.Should().Be("letsencrypt-prod");
}
[Fact]
public void DisableIngress_ClearsIngressConfig()
{
// Arrange
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
GrafanaIngressConfiguration ingress = new(
Enabled: true,
Provider: IngressProvider.GatewayApi,
Hostname: "grafana.example.com",
TlsEnabled: true,
TlsCertificateMode: TlsCertificateMode.Manual,
TlsSecretName: "grafana-tls");
instance.ConfigureIngress(ingress);
// Act
instance.DisableIngress();
// Assert
instance.Ingress.Should().BeNull();
}
// ─── Dashboards ─────────────────────────────────────────────────
[Fact]
public void AddDashboard_StoresDashboardDefinition()
{
// Arrange — Dashboards are JSON definitions deployed as ConfigMaps
// with the grafana_dashboard=1 label so the sidecar picks them up.
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
// Act — Add a Kubernetes cluster dashboard.
GrafanaDashboard dashboard = new(
Name: "k8s-cluster",
DisplayName: "Kubernetes Cluster",
Category: DashboardCategory.Kubernetes,
JsonContent: """{"dashboard": {"title": "K8s Cluster"}}""");
instance.AddDashboard(dashboard);
// Assert
instance.Dashboards.Should().HaveCount(1);
instance.Dashboards[0].Name.Should().Be("k8s-cluster");
instance.Dashboards[0].Category.Should().Be(DashboardCategory.Kubernetes);
}
[Fact]
public void AddDashboard_WithDuplicateName_ThrowsInvalidOperationException()
{
// Arrange
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
GrafanaDashboard dashboard = new(
Name: "k8s-cluster",
DisplayName: "Kubernetes Cluster",
Category: DashboardCategory.Kubernetes,
JsonContent: """{"dashboard": {}}""");
instance.AddDashboard(dashboard);
// Act & Assert — Can't add two dashboards with the same name.
Action act = () => instance.AddDashboard(dashboard);
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void RemoveDashboard_DeletesByName()
{
// Arrange
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
instance.AddDashboard(new GrafanaDashboard("k8s-cluster", "Kubernetes Cluster", DashboardCategory.Kubernetes, "{}"));
instance.AddDashboard(new GrafanaDashboard("istio-mesh", "Istio Mesh", DashboardCategory.Istio, "{}"));
// Act
instance.RemoveDashboard("k8s-cluster");
// Assert
instance.Dashboards.Should().HaveCount(1);
instance.Dashboards[0].Name.Should().Be("istio-mesh");
}
// ─── Lifecycle ──────────────────────────────────────────────────
[Fact]
public void MarkRunning_SetsStatusToRunning()
{
// Arrange
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
// Act
instance.MarkRunning();
// Assert
instance.Status.Should().Be(GrafanaInstanceStatus.Running);
}
[Fact]
public void MarkDegraded_SetsStatusToDegraded()
{
// Arrange
GrafanaInstance instance = GrafanaInstance.Create(environmentId, clusterId, "prod-grafana", "monitoring");
// Act
instance.MarkDegraded();
// Assert
instance.Status.Should().Be(GrafanaInstanceStatus.Degraded);
}
}

View File

@@ -0,0 +1,192 @@
using EntKube.Provisioning.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
public class IdentityRealmTests
{
[Fact]
public void Create_ValidName_ReturnsRealmInProvisioningState()
{
// When creating a new realm, it starts in Provisioning status
// with the given name and cluster association.
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
IdentityRealm realm = IdentityRealm.Create(environmentId, "my-app", clusterId);
realm.Id.Should().NotBeEmpty();
realm.EnvironmentId.Should().Be(environmentId);
realm.ClusterId.Should().Be(clusterId);
realm.Name.Should().Be("my-app");
realm.Status.Should().Be(IdentityRealmStatus.Provisioning);
realm.Branding.Should().Be(RealmBranding.Default);
realm.Pages.LoginEnabled.Should().BeTrue();
realm.Pages.ProfileEnabled.Should().BeFalse();
}
[Fact]
public void Create_EmptyName_ThrowsArgumentException()
{
// A realm must have a name — empty is not valid.
Action act = () => IdentityRealm.Create(Guid.NewGuid(), "", Guid.NewGuid());
act.Should().Throw<ArgumentException>();
}
[Fact]
public void UpdateBranding_ChangesBrandingAndTimestamp()
{
// After updating branding, the new values stick and
// the last modified timestamp is updated.
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "branded", Guid.NewGuid());
RealmBranding newBranding = new(
LogoUrl: "https://example.com/logo.png",
BackgroundImageUrl: null,
BackgroundColor: "#1a1a2e",
PrimaryColor: "#16213e",
SecondaryColor: "#0f3460",
TextColor: "#ffffff");
realm.UpdateBranding(newBranding);
realm.Branding.LogoUrl.Should().Be("https://example.com/logo.png");
realm.Branding.BackgroundColor.Should().Be("#1a1a2e");
realm.LastModifiedAt.Should().NotBeNull();
}
[Fact]
public void UpdatePages_EnablesProfile()
{
// A realm starts with only login enabled. We can enable profile pages too.
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "with-profile", Guid.NewGuid());
RealmPages pages = new(
LoginEnabled: true,
ProfileEnabled: true,
RegistrationEnabled: false,
ForgotPasswordEnabled: true);
realm.UpdatePages(pages);
realm.Pages.ProfileEnabled.Should().BeTrue();
}
[Fact]
public void AddIdentityProvider_NewProvider_AddsToList()
{
// Adding an OIDC identity provider to the realm.
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "idp-realm", Guid.NewGuid());
RealmIdentityProvider provider = RealmIdentityProvider.Create(
alias: "azure-ad",
displayName: "Azure AD",
type: IdentityProviderType.Oidc,
authorizationUrl: "https://login.microsoftonline.com/authorize",
tokenUrl: "https://login.microsoftonline.com/token",
clientId: "my-client-id");
realm.AddIdentityProvider(provider);
realm.IdentityProviders.Should().HaveCount(1);
realm.IdentityProviders[0].Alias.Should().Be("azure-ad");
realm.IdentityProviders[0].Type.Should().Be(IdentityProviderType.Oidc);
}
[Fact]
public void AddIdentityProvider_DuplicateAlias_Throws()
{
// Each provider must have a unique alias within the realm.
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "dup-realm", Guid.NewGuid());
RealmIdentityProvider first = RealmIdentityProvider.Create("github", "GitHub", IdentityProviderType.GitHub);
RealmIdentityProvider duplicate = RealmIdentityProvider.Create("github", "GitHub Copy", IdentityProviderType.GitHub);
realm.AddIdentityProvider(first);
Action act = () => realm.AddIdentityProvider(duplicate);
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void RemoveIdentityProvider_ExistingAlias_RemovesFromList()
{
// Removing an identity provider by alias.
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "remove-realm", Guid.NewGuid());
RealmIdentityProvider provider = RealmIdentityProvider.Create("google", "Google", IdentityProviderType.Google);
realm.AddIdentityProvider(provider);
realm.RemoveIdentityProvider("google");
realm.IdentityProviders.Should().BeEmpty();
}
[Fact]
public void AddOrganization_NewOrg_AddsToList()
{
// Organizations let you group users within a realm.
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "org-realm", Guid.NewGuid());
RealmOrganization org = RealmOrganization.Create("Acme Corp", "Top-level org");
realm.AddOrganization(org);
realm.Organizations.Should().HaveCount(1);
realm.Organizations[0].Name.Should().Be("Acme Corp");
}
[Fact]
public void Organization_AddChild_CreatesHierarchy()
{
// Organizations support nested children for hierarchical structure.
RealmOrganization parent = RealmOrganization.Create("Acme Corp");
RealmOrganization child = RealmOrganization.Create("Engineering");
RealmOrganization grandchild = RealmOrganization.Create("Backend Team");
parent.AddChild(child);
child.AddChild(grandchild);
parent.Children.Should().HaveCount(1);
parent.Children[0].Name.Should().Be("Engineering");
parent.Children[0].Children.Should().HaveCount(1);
parent.Children[0].Children[0].Name.Should().Be("Backend Team");
}
[Fact]
public void Organization_DuplicateChildName_Throws()
{
// Child names must be unique within a parent.
RealmOrganization parent = RealmOrganization.Create("Acme Corp");
RealmOrganization child1 = RealmOrganization.Create("Engineering");
RealmOrganization child2 = RealmOrganization.Create("Engineering");
parent.AddChild(child1);
Action act = () => parent.AddChild(child2);
act.Should().Throw<InvalidOperationException>();
}
[Fact]
public void MarkActive_ChangesStatusToActive()
{
IdentityRealm realm = IdentityRealm.Create(Guid.NewGuid(), "active-realm", Guid.NewGuid());
realm.MarkActive();
realm.Status.Should().Be(IdentityRealmStatus.Active);
}
}

View File

@@ -0,0 +1,204 @@
using EntKube.Provisioning.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
/// <summary>
/// Tests for the MinioInstance aggregate. This tracks adopted MinIO deployments
/// that the platform uses as backup targets for CNPG clusters.
/// </summary>
public class MinioInstanceTests
{
[Fact]
public void Adopt_WithValidInputs_CreatesInstanceInRunningState()
{
// Arrange & Act — Adopt an existing MinIO deployment discovered on the cluster.
Guid clusterId = Guid.NewGuid();
MinioInstance minio = MinioInstance.Adopt(
clusterId: clusterId,
name: "platform-minio",
ns: "minio-system",
endpoint: "minio.minio-system.svc:9000",
consoleEndpoint: "minio-console.minio-system.svc:9001",
credentialsSecret: "minio-root-credentials",
totalCapacity: "500Gi",
usedCapacity: "120Gi",
buckets: new List<string> { "cnpg-backups", "loki-logs", "velero" });
// Assert — Adopted instances are already running.
minio.Id.Should().NotBe(Guid.Empty);
minio.ClusterId.Should().Be(clusterId);
minio.Name.Should().Be("platform-minio");
minio.Namespace.Should().Be("minio-system");
minio.Endpoint.Should().Be("minio.minio-system.svc:9000");
minio.ConsoleEndpoint.Should().Be("minio-console.minio-system.svc:9001");
minio.CredentialsSecret.Should().Be("minio-root-credentials");
minio.Status.Should().Be(MinioInstanceStatus.Running);
minio.Buckets.Should().HaveCount(3);
minio.Buckets.Should().Contain("cnpg-backups");
}
[Fact]
public void Adopt_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => MinioInstance.Adopt(
Guid.NewGuid(), "", "ns", "endpoint:9000", null,
"secret", "500Gi", null, null);
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("name");
}
[Fact]
public void Adopt_WithEmptyEndpoint_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => MinioInstance.Adopt(
Guid.NewGuid(), "minio", "ns", "", null,
"secret", "500Gi", null, null);
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("endpoint");
}
[Fact]
public void Adopt_WithEmptyCredentials_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => MinioInstance.Adopt(
Guid.NewGuid(), "minio", "ns", "endpoint:9000", null,
"", "500Gi", null, null);
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("credentialsSecret");
}
[Fact]
public void CreateBucket_WhenRunning_AddsBucketToList()
{
// Arrange — A running MinIO instance.
MinioInstance minio = CreateTestInstance();
// Act — CNPG provisioning requests a new bucket for WAL storage.
EntKube.SharedKernel.Domain.Result<string> result = minio.CreateBucket("new-pg-backups");
// Assert
result.IsSuccess.Should().BeTrue();
minio.Buckets.Should().Contain("new-pg-backups");
}
[Fact]
public void CreateBucket_DuplicateName_ReturnsFailure()
{
// Arrange
MinioInstance minio = MinioInstance.Adopt(
Guid.NewGuid(), "minio", "ns", "minio:9000", null,
"secret", "500Gi", null,
new List<string> { "existing-bucket" });
// Act
EntKube.SharedKernel.Domain.Result<string> result = minio.CreateBucket("existing-bucket");
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("already exists");
}
[Fact]
public void CreateBucket_WhenDegraded_ReturnsFailure()
{
// Arrange — A degraded MinIO can't serve new bucket requests.
MinioInstance minio = CreateTestInstance();
minio.MarkDegraded("Storage pool full");
// Act
EntKube.SharedKernel.Domain.Result<string> result = minio.CreateBucket("new-bucket");
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("running");
}
[Fact]
public void MarkDegraded_SetsStatusAndMessage()
{
// Arrange
MinioInstance minio = CreateTestInstance();
// Act
minio.MarkDegraded("Drive offline");
// Assert
minio.Status.Should().Be(MinioInstanceStatus.Degraded);
minio.StatusMessage.Should().Be("Drive offline");
}
[Fact]
public void MarkRunning_ClearsDegradedStatus()
{
// Arrange — A previously degraded instance recovers.
MinioInstance minio = CreateTestInstance();
minio.MarkDegraded("Temporary issue");
// Act
minio.MarkRunning();
// Assert
minio.Status.Should().Be(MinioInstanceStatus.Running);
minio.StatusMessage.Should().BeNull();
}
[Fact]
public void GetBackupCredentials_ReturnsEndpointAndSecret()
{
// Arrange — CNPG needs to know where to send backups.
MinioInstance minio = MinioInstance.Adopt(
Guid.NewGuid(), "minio", "minio-system", "minio.minio-system.svc:9000", null,
"minio-root-credentials", "1Ti", null, null);
// Act
MinioBackupTarget target = minio.GetBackupTarget("cnpg-backups");
// Assert
target.Endpoint.Should().Be("minio.minio-system.svc:9000");
target.Bucket.Should().Be("cnpg-backups");
target.CredentialsSecret.Should().Be("minio-root-credentials");
}
private static MinioInstance CreateTestInstance()
{
return MinioInstance.Adopt(
Guid.NewGuid(), "platform-minio", "minio-system",
"minio.minio-system.svc:9000", "minio-console:9001",
"minio-root-credentials", "500Gi", "120Gi",
new List<string> { "cnpg-backups" });
}
}

View File

@@ -0,0 +1,307 @@
using EntKube.Provisioning.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
/// <summary>
/// Tests for the MinioTenant aggregate. A MinIO Tenant represents an actual storage
/// cluster managed by the MinIO Operator on Kubernetes. Each tenant has one or more
/// pools, each pool defining how many servers and disks to use.
///
/// One MinIO Operator installation can host multiple tenants — each tenant is a
/// separate storage cluster with its own credentials, namespace, and capacity.
/// </summary>
public class MinioTenantTests
{
[Fact]
public void Create_WithValidInputs_CreatesTenantInProvisioningState()
{
// Arrange — A platform admin wants to provision a new MinIO storage tenant
// on a cluster. They specify how much capacity they need: 4 servers, each
// with 4 disks of 100Gi.
Guid clusterId = Guid.NewGuid();
List<MinioTenantPool> pools = new()
{
new MinioTenantPool(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
};
// Act
MinioTenant tenant = MinioTenant.Create(
clusterId: clusterId,
name: "platform-storage",
ns: "minio-tenant-1",
pools: pools);
// Assert — New tenants start in Provisioning state until the operator confirms.
tenant.Id.Should().NotBe(Guid.Empty);
tenant.ClusterId.Should().Be(clusterId);
tenant.Name.Should().Be("platform-storage");
tenant.Namespace.Should().Be("minio-tenant-1");
tenant.Status.Should().Be(MinioTenantStatus.Provisioning);
tenant.Pools.Should().HaveCount(1);
tenant.Pools[0].Servers.Should().Be(4);
tenant.Pools[0].VolumesPerServer.Should().Be(4);
tenant.Pools[0].StorageSize.Should().Be("100Gi");
tenant.Pools[0].StorageClass.Should().Be("ceph-block");
tenant.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
}
[Fact]
public void Create_WithMultiplePools_TracksAllPools()
{
// Arrange — Some deployments need heterogeneous pools:
// one for hot data (fast NVMe) and one for warm data (spinning disk).
List<MinioTenantPool> pools = new()
{
new MinioTenantPool(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "nvme-fast"),
new MinioTenantPool(Servers: 2, VolumesPerServer: 8, StorageSize: "500Gi", StorageClass: "hdd-bulk")
};
// Act
MinioTenant tenant = MinioTenant.Create(Guid.NewGuid(), "multi-pool", "minio-mp", pools);
// Assert
tenant.Pools.Should().HaveCount(2);
tenant.Pools[0].StorageClass.Should().Be("nvme-fast");
tenant.Pools[1].StorageClass.Should().Be("hdd-bulk");
}
[Fact]
public void Create_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act
List<MinioTenantPool> pools = new() { new MinioTenantPool(4, 4, "100Gi", "standard") };
Action act = () => MinioTenant.Create(Guid.NewGuid(), "", "ns", pools);
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("name");
}
[Fact]
public void Create_WithEmptyNamespace_ThrowsArgumentException()
{
// Arrange & Act
List<MinioTenantPool> pools = new() { new MinioTenantPool(4, 4, "100Gi", "standard") };
Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "", pools);
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("ns");
}
[Fact]
public void Create_WithNoPools_ThrowsArgumentException()
{
// Arrange & Act — A tenant must have at least one pool to be meaningful.
Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "ns", new List<MinioTenantPool>());
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("pools");
}
[Fact]
public void Create_WithZeroServers_ThrowsArgumentException()
{
// Arrange & Act — Each pool needs at least one server.
List<MinioTenantPool> pools = new() { new MinioTenantPool(0, 4, "100Gi", "standard") };
Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "ns", pools);
// Assert
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Create_WithZeroVolumes_ThrowsArgumentException()
{
// Arrange & Act — Each pool needs at least one volume per server.
List<MinioTenantPool> pools = new() { new MinioTenantPool(4, 0, "100Gi", "standard") };
Action act = () => MinioTenant.Create(Guid.NewGuid(), "tenant", "ns", pools);
// Assert
act.Should().Throw<ArgumentException>();
}
[Fact]
public void MarkRunning_TransitionsFromProvisioning()
{
// Arrange — After the operator creates the tenant, the reconciler confirms it's running.
MinioTenant tenant = CreateTestTenant();
// Act
tenant.MarkRunning();
// Assert
tenant.Status.Should().Be(MinioTenantStatus.Running);
tenant.StatusMessage.Should().BeNull();
}
[Fact]
public void MarkDegraded_SetsStatusAndReason()
{
// Arrange
MinioTenant tenant = CreateTestTenant();
tenant.MarkRunning();
// Act
tenant.MarkDegraded("1 drive offline");
// Assert
tenant.Status.Should().Be(MinioTenantStatus.Degraded);
tenant.StatusMessage.Should().Be("1 drive offline");
}
[Fact]
public void UpdatePools_ReplacesPoolConfiguration()
{
// Arrange — The admin wants to scale out: add more servers to the pool.
MinioTenant tenant = CreateTestTenant();
tenant.MarkRunning();
List<MinioTenantPool> newPools = new()
{
new MinioTenantPool(Servers: 8, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
};
// Act
tenant.UpdatePools(newPools);
// Assert — Status goes back to Provisioning while the operator reconciles.
tenant.Pools.Should().HaveCount(1);
tenant.Pools[0].Servers.Should().Be(8);
tenant.Status.Should().Be(MinioTenantStatus.Provisioning);
}
[Fact]
public void UpdatePools_WithEmptyPools_ThrowsArgumentException()
{
// Arrange
MinioTenant tenant = CreateTestTenant();
tenant.MarkRunning();
// Act & Assert
Action act = () => tenant.UpdatePools(new List<MinioTenantPool>());
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Decommission_MarksAsDecommissioned()
{
// Arrange — The admin tears down a tenant. The operator will delete resources.
MinioTenant tenant = CreateTestTenant();
tenant.MarkRunning();
// Act
tenant.Decommission();
// Assert
tenant.Status.Should().Be(MinioTenantStatus.Decommissioned);
}
[Fact]
public void GetEndpoint_ReturnsConventionalServiceUrl()
{
// Arrange — MinIO tenants expose storage at <name>-hl.<namespace>.svc:9000
MinioTenant tenant = CreateTestTenant();
// Act
string endpoint = tenant.GetEndpoint();
// Assert
endpoint.Should().Be("platform-storage-hl.minio-tenant-1.svc:9000");
}
[Fact]
public void GetConsoleEndpoint_ReturnsConventionalConsoleUrl()
{
// Arrange
MinioTenant tenant = CreateTestTenant();
// Act
string consoleEndpoint = tenant.GetConsoleEndpoint();
// Assert
consoleEndpoint.Should().Be("platform-storage-console.minio-tenant-1.svc:9443");
}
[Fact]
public void GetCredentialsSecretName_ReturnsConventionalSecretName()
{
// Arrange
MinioTenant tenant = CreateTestTenant();
// Act
string secret = tenant.GetCredentialsSecretName();
// Assert
secret.Should().Be("platform-storage-secret");
}
[Fact]
public void TotalCapacity_CalculatesFromPoolsAsString()
{
// Arrange — Capacity = servers * volumesPerServer * storageSize (per pool).
// With 4 servers × 4 volumes × 100Gi = 1600Gi total.
MinioTenant tenant = CreateTestTenant();
// Act
string capacity = tenant.CalculateTotalCapacity();
// Assert — 4 × 4 × 100 = 1600Gi
capacity.Should().Be("1600Gi");
}
private static MinioTenant CreateTestTenant()
{
List<MinioTenantPool> pools = new()
{
new MinioTenantPool(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
};
return MinioTenant.Create(Guid.NewGuid(), "platform-storage", "minio-tenant-1", pools);
}
}

View File

@@ -0,0 +1,405 @@
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
/// <summary>
/// Tests for the MongoCluster aggregate root. This rich domain model tracks
/// everything about a MongoDB Community Operator cluster: replica set members,
/// storage, backup configuration, databases, users, and version lifecycle.
///
/// MongoDB Community Operator manages MongoDBCommunity CRDs on Kubernetes.
/// Each cluster is a replica set with configurable members, storage, and
/// authentication. Backup is handled via scheduled mongodump jobs to MinIO.
/// </summary>
public class MongoClusterTests
{
// ─── Creation ──────────────────────────────────────────────────────────────
[Fact]
public void Create_WithValidInputs_ReturnsClusterInProvisioningState()
{
// Arrange & Act — A platform admin provisions a new MongoDB cluster
// for shared use on a specific Kubernetes cluster.
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
environmentId: environmentId,
clusterId: clusterId,
name: "platform-mongo",
ns: "mongodb-system",
mongoVersion: "7.0.12",
members: 3,
storageSize: "50Gi",
minioEndpoint: "minio.minio-system.svc:9000",
minioBucket: "mongo-backups",
minioCredentialsSecret: "minio-mongo-credentials");
// Assert — The cluster starts in Provisioning state, waiting for the
// reconciler to create the MongoDBCommunity resource.
mongoCluster.Id.Should().NotBe(Guid.Empty);
mongoCluster.EnvironmentId.Should().Be(environmentId);
mongoCluster.ClusterId.Should().Be(clusterId);
mongoCluster.Name.Should().Be("platform-mongo");
mongoCluster.Namespace.Should().Be("mongodb-system");
mongoCluster.MongoVersion.Should().Be("7.0.12");
mongoCluster.Members.Should().Be(3);
mongoCluster.StorageSize.Should().Be("50Gi");
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Provisioning);
mongoCluster.Databases.Should().BeEmpty();
}
[Fact]
public void Create_WithMinIOBackupConfig_SetsBackupConfiguration()
{
// Arrange & Act — Backup to MinIO is configured at creation time
// for mongodump-based backups. Unlike CNPG WAL archiving, MongoDB
// uses periodic full dumps.
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
environmentId: Guid.NewGuid(),
clusterId: Guid.NewGuid(),
name: "prod-mongo",
ns: "mongodb-prod",
mongoVersion: "7.0.12",
members: 3,
storageSize: "100Gi",
minioEndpoint: "minio.minio-system.svc:9000",
minioBucket: "mongo-dump-archive",
minioCredentialsSecret: "minio-mongo-creds");
// Assert — Backup configuration is stored as part of the aggregate.
mongoCluster.BackupConfig.Should().NotBeNull();
mongoCluster.BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000");
mongoCluster.BackupConfig.Bucket.Should().Be("mongo-dump-archive");
mongoCluster.BackupConfig.CredentialsSecret.Should().Be("minio-mongo-creds");
}
[Fact]
public void Create_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act — The domain rejects invalid cluster names.
Action act = () => Provisioning.Domain.MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "", "ns", "7.0.12", 3, "50Gi",
"minio:9000", "bucket", "secret");
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("name");
}
[Fact]
public void Create_WithZeroMembers_ThrowsArgumentException()
{
// Arrange & Act — Must have at least 1 replica set member.
Action act = () => Provisioning.Domain.MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "7.0.12", 0, "50Gi",
"minio:9000", "bucket", "secret");
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("members");
}
[Fact]
public void Create_WithEmptyMinioEndpoint_CreatesClusterWithoutBackup()
{
// Arrange & Act — Backup is optional. When MinIO endpoint is empty,
// the cluster should be created successfully without backup config.
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "7.0.12", 3, "50Gi",
"", "bucket", "secret");
// Assert — cluster exists but has no backup configuration.
mongoCluster.Should().NotBeNull();
mongoCluster.BackupConfig.Should().BeNull();
}
// ─── Adoption ──────────────────────────────────────────────────────────────
[Fact]
public void Adopt_FromExistingCluster_CreatesRunningClusterWithDiscoveredConfig()
{
// Arrange & Act — When we adopt an existing MongoDBCommunity cluster
// that's already running on K8s, we create it in Running state.
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Adopt(
environmentId: Guid.NewGuid(),
clusterId: Guid.NewGuid(),
name: "existing-mongo",
ns: "mongodb-system",
mongoVersion: "6.0.16",
members: 2,
storageSize: "20Gi",
minioEndpoint: "minio.minio-system.svc:9000",
minioBucket: "existing-backups",
minioCredentialsSecret: "existing-minio-creds",
databases: new List<string> { "app_db", "analytics_db" });
// Assert — Adopted clusters start in Running state and include
// any databases that were already present.
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Running);
mongoCluster.Databases.Should().HaveCount(2);
mongoCluster.Databases.Should().Contain("app_db");
mongoCluster.Databases.Should().Contain("analytics_db");
}
// ─── Status transitions ────────────────────────────────────────────────────
[Fact]
public void MarkRunning_TransitionsFromProvisioning()
{
// Arrange — A freshly created cluster in Provisioning state.
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
// Act — The reconciler confirmed the cluster is ready.
mongoCluster.MarkRunning();
// Assert
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Running);
mongoCluster.LastReconcileAt.Should().NotBeNull();
}
[Fact]
public void MarkDegraded_SetsStatusToDegraded()
{
// Arrange
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
mongoCluster.MarkRunning();
// Act — Reconciler detected an issue.
mongoCluster.MarkDegraded("Replica set member not ready");
// Assert
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Degraded);
mongoCluster.StatusMessage.Should().Be("Replica set member not ready");
}
// ─── Database management ───────────────────────────────────────────────────
[Fact]
public void AddDatabase_WhenRunning_AddsDatabaseToList()
{
// Arrange — A running cluster can have databases added.
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
mongoCluster.MarkRunning();
// Act — An application requests a new database.
Result<string> result = mongoCluster.AddDatabase("my_app_db", "app_user");
// Assert
result.IsSuccess.Should().BeTrue();
mongoCluster.Databases.Should().Contain("my_app_db");
}
[Fact]
public void AddDatabase_WhenNotRunning_ReturnsFailure()
{
// Arrange — A cluster still provisioning can't accept new databases.
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
// Act
Result<string> result = mongoCluster.AddDatabase("my_db", "user");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("running");
}
[Fact]
public void AddDatabase_DuplicateName_ReturnsFailure()
{
// Arrange — A running cluster with one database already.
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
mongoCluster.MarkRunning();
mongoCluster.AddDatabase("existing_db", "user1");
// Act — Try to add a database with the same name.
Result<string> result = mongoCluster.AddDatabase("existing_db", "user2");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("already exists");
}
// ─── Version upgrades ──────────────────────────────────────────────────────
[Fact]
public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion()
{
// Arrange — A running cluster at version 6.0.16.
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "6.0.16", 3, "50Gi",
"minio:9000", "bucket", "secret");
mongoCluster.MarkRunning();
// Act — Platform admin requests upgrade to 7.0.12.
Result<string> result = mongoCluster.RequestUpgrade("7.0.12");
// Assert — The cluster transitions to Upgrading state.
result.IsSuccess.Should().BeTrue();
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Upgrading);
mongoCluster.TargetVersion.Should().Be("7.0.12");
}
[Fact]
public void RequestUpgrade_WhenNotRunning_ReturnsFailure()
{
// Arrange — A cluster still provisioning can't be upgraded.
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
// Act
Result<string> result = mongoCluster.RequestUpgrade("7.0.12");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("running");
}
[Fact]
public void RequestUpgrade_ToSameVersion_ReturnsFailure()
{
// Arrange — A cluster already at version 7.0.12.
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "7.0.12", 3, "50Gi",
"minio:9000", "bucket", "secret");
mongoCluster.MarkRunning();
// Act — Try to upgrade to the same version.
Result<string> result = mongoCluster.RequestUpgrade("7.0.12");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("same version");
}
[Fact]
public void CompleteUpgrade_TransitionsToRunningWithNewVersion()
{
// Arrange — A cluster mid-upgrade.
Provisioning.Domain.MongoCluster mongoCluster = Provisioning.Domain.MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "6.0.16", 3, "50Gi",
"minio:9000", "bucket", "secret");
mongoCluster.MarkRunning();
mongoCluster.RequestUpgrade("7.0.12");
// Act — Reconciler confirmed upgrade completed.
mongoCluster.CompleteUpgrade();
// Assert — Version updated, status back to Running.
mongoCluster.MongoVersion.Should().Be("7.0.12");
mongoCluster.Status.Should().Be(Provisioning.Domain.MongoClusterStatus.Running);
mongoCluster.TargetVersion.Should().BeNull();
}
// ─── Scaling ───────────────────────────────────────────────────────────────
[Fact]
public void ScaleMembers_UpdatesMemberCount()
{
// Arrange — A running 3-member cluster.
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
mongoCluster.MarkRunning();
// Act — Scale up to 5 members for more redundancy.
Result<string> result = mongoCluster.ScaleMembers(5);
// Assert
result.IsSuccess.Should().BeTrue();
mongoCluster.Members.Should().Be(5);
}
[Fact]
public void ScaleMembers_ToZero_ReturnsFailure()
{
// Arrange
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
mongoCluster.MarkRunning();
// Act
Result<string> result = mongoCluster.ScaleMembers(0);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("at least 1");
}
// ─── Backup configuration ──────────────────────────────────────────────────
[Fact]
public void ConfigureBackupSchedule_UpdatesRetentionPolicy()
{
// Arrange — A running cluster needs its backup schedule updated.
Provisioning.Domain.MongoCluster mongoCluster = CreateTestCluster();
mongoCluster.MarkRunning();
// Act — Set backup to run every 6 hours with 30-day retention.
mongoCluster.ConfigureBackupSchedule("0 */6 * * *", retentionDays: 30);
// Assert
mongoCluster.BackupConfig!.Schedule.Should().Be("0 */6 * * *");
mongoCluster.BackupConfig.RetentionDays.Should().Be(30);
}
// ─── Helper ────────────────────────────────────────────────────────────────
private static Provisioning.Domain.MongoCluster CreateTestCluster()
{
return Provisioning.Domain.MongoCluster.Create(
Guid.NewGuid(),
Guid.NewGuid(),
"test-mongo",
"mongodb-system",
"7.0.12",
3,
"50Gi",
"minio.minio-system.svc:9000",
"mongo-backups",
"minio-mongo-credentials");
}
}

View File

@@ -0,0 +1,437 @@
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
/// <summary>
/// Tests for the PostgresCluster aggregate root. This rich domain model tracks
/// everything about a CloudNativePG PostgreSQL cluster: instances, storage,
/// WAL archiving, backup configuration, databases, and version lifecycle.
/// </summary>
public class PostgresClusterTests
{
[Fact]
public void Create_WithValidInputs_ReturnsClusterInProvisioningState()
{
// Arrange & Act — A platform admin provisions a new PostgreSQL cluster
// for shared use on a specific Kubernetes cluster.
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
environmentId: environmentId,
clusterId: clusterId,
name: "platform-db",
ns: "cnpg-system",
postgresVersion: "16.4",
instances: 3,
storageSize: "50Gi",
minioEndpoint: "minio.minio-system.svc:9000",
minioBucket: "cnpg-backups",
minioCredentialsSecret: "minio-cnpg-credentials");
// Assert — The cluster starts in Provisioning state, waiting for the
// reconciler to actually create the CNPG Cluster resource.
pgCluster.Id.Should().NotBe(Guid.Empty);
pgCluster.EnvironmentId.Should().Be(environmentId);
pgCluster.ClusterId.Should().Be(clusterId);
pgCluster.Name.Should().Be("platform-db");
pgCluster.Namespace.Should().Be("cnpg-system");
pgCluster.PostgresVersion.Should().Be("16.4");
pgCluster.Instances.Should().Be(3);
pgCluster.StorageSize.Should().Be("50Gi");
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Provisioning);
pgCluster.Databases.Should().BeEmpty();
}
[Fact]
public void Create_WithMinIOBackupConfig_SetsWalArchiving()
{
// Arrange & Act — WAL archiving to MinIO is configured at creation time
// since it's a hard dependency. The cluster won't be created without it.
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
environmentId: Guid.NewGuid(),
clusterId: Guid.NewGuid(),
name: "prod-db",
ns: "cnpg-prod",
postgresVersion: "16.4",
instances: 3,
storageSize: "100Gi",
minioEndpoint: "minio.minio-system.svc:9000",
minioBucket: "pg-wal-archive",
minioCredentialsSecret: "minio-pg-creds");
// Assert — Backup configuration is stored as part of the aggregate.
pgCluster.BackupConfig.Should().NotBeNull();
pgCluster.BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000");
pgCluster.BackupConfig.Bucket.Should().Be("pg-wal-archive");
pgCluster.BackupConfig.CredentialsSecret.Should().Be("minio-pg-creds");
pgCluster.BackupConfig.WalArchivingEnabled.Should().BeTrue();
}
[Fact]
public void Create_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act — The domain rejects invalid cluster names.
Action act = () => Provisioning.Domain.PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "", "ns", "16.4", 3, "50Gi",
"minio:9000", "bucket", "secret");
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("name");
}
[Fact]
public void Create_WithZeroInstances_ThrowsArgumentException()
{
// Arrange & Act — Must have at least 1 instance.
Action act = () => Provisioning.Domain.PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 0, "50Gi",
"minio:9000", "bucket", "secret");
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("instances");
}
[Fact]
public void Create_WithEmptyMinioEndpoint_CreatesClusterWithoutBackup()
{
// Arrange & Act — Backup is optional. When MinIO endpoint is empty,
// the cluster should be created successfully without backup config.
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 3, "50Gi",
"", "bucket", "secret");
// Assert — cluster exists but has no backup configuration.
pgCluster.Should().NotBeNull();
pgCluster.BackupConfig.Should().BeNull();
}
[Fact]
public void Adopt_FromExistingCluster_CreatesRunningClusterWithDiscoveredConfig()
{
// Arrange & Act — When we adopt an existing CNPG cluster that's already
// running on the K8s cluster, we create it in Running state with the
// discovered configuration.
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Adopt(
environmentId: Guid.NewGuid(),
clusterId: Guid.NewGuid(),
name: "existing-db",
ns: "cnpg-system",
postgresVersion: "15.6",
instances: 2,
storageSize: "20Gi",
minioEndpoint: "minio.minio-system.svc:9000",
minioBucket: "existing-backups",
minioCredentialsSecret: "existing-minio-creds",
databases: new List<string> { "app_db", "analytics_db" });
// Assert — Adopted clusters start in Running state and include
// any databases that were already present.
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running);
pgCluster.Databases.Should().HaveCount(2);
pgCluster.Databases.Should().Contain("app_db");
pgCluster.Databases.Should().Contain("analytics_db");
}
[Fact]
public void MarkRunning_TransitionsFromProvisioning()
{
// Arrange — A freshly created cluster in Provisioning state.
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
// Act — The reconciler confirmed the cluster is ready.
pgCluster.MarkRunning();
// Assert
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running);
pgCluster.LastReconcileAt.Should().NotBeNull();
}
[Fact]
public void AddDatabase_WhenRunning_AddsDatabaseToList()
{
// Arrange — A running cluster can have databases added to it.
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
pgCluster.MarkRunning();
// Act — An application requests a new database be provisioned.
Result<string> result = pgCluster.AddDatabase("my_app_db", "app_user");
// Assert
result.IsSuccess.Should().BeTrue();
pgCluster.Databases.Should().Contain("my_app_db");
}
[Fact]
public void AddDatabase_WhenNotRunning_ReturnsFailure()
{
// Arrange — A cluster that's still provisioning can't accept new databases.
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
// Act
Result<string> result = pgCluster.AddDatabase("my_db", "user");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("running");
}
[Fact]
public void AddDatabase_DuplicateName_ReturnsFailure()
{
// Arrange — A running cluster with one database already.
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
pgCluster.MarkRunning();
pgCluster.AddDatabase("existing_db", "user1");
// Act — Try to add a database with the same name.
Result<string> result = pgCluster.AddDatabase("existing_db", "user2");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("already exists");
}
[Fact]
public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion()
{
// Arrange — A running cluster at version 15.6.
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "15.6", 3, "50Gi",
"minio:9000", "bucket", "secret");
pgCluster.MarkRunning();
// Act — Platform admin requests upgrade to 16.4.
Result<string> result = pgCluster.RequestUpgrade("16.4");
// Assert — The cluster transitions to Upgrading state with the target version recorded.
result.IsSuccess.Should().BeTrue();
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Upgrading);
pgCluster.TargetVersion.Should().Be("16.4");
}
[Fact]
public void RequestUpgrade_WhenNotRunning_ReturnsFailure()
{
// Arrange — A cluster still provisioning can't be upgraded.
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
// Act
Result<string> result = pgCluster.RequestUpgrade("16.4");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("running");
}
[Fact]
public void RequestUpgrade_ToSameVersion_ReturnsFailure()
{
// Arrange — A cluster already at version 16.4.
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "16.4", 3, "50Gi",
"minio:9000", "bucket", "secret");
pgCluster.MarkRunning();
// Act — Try to upgrade to the same version.
Result<string> result = pgCluster.RequestUpgrade("16.4");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("same version");
}
[Fact]
public void CompleteUpgrade_TransitionsToRunningWithNewVersion()
{
// Arrange — A cluster that's mid-upgrade.
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "db", "ns", "15.6", 3, "50Gi",
"minio:9000", "bucket", "secret");
pgCluster.MarkRunning();
pgCluster.RequestUpgrade("16.4");
// Act — The reconciler confirmed the upgrade completed.
pgCluster.CompleteUpgrade();
// Assert — Version updated, status back to Running.
pgCluster.PostgresVersion.Should().Be("16.4");
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Running);
pgCluster.TargetVersion.Should().BeNull();
}
[Fact]
public void ScaleInstances_UpdatesInstanceCount()
{
// Arrange — A running 3-instance cluster.
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
pgCluster.MarkRunning();
// Act — Scale up to 5 instances for more read replicas.
Result<string> result = pgCluster.ScaleInstances(5);
// Assert
result.IsSuccess.Should().BeTrue();
pgCluster.Instances.Should().Be(5);
}
[Fact]
public void ScaleInstances_ToZero_ReturnsFailure()
{
// Arrange
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
pgCluster.MarkRunning();
// Act
Result<string> result = pgCluster.ScaleInstances(0);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("at least 1");
}
[Fact]
public void ConfigureBackupSchedule_UpdatesRetentionPolicy()
{
// Arrange — A running cluster needs its backup schedule updated.
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
pgCluster.MarkRunning();
// Act — Set backup to run every 6 hours with 30-day retention.
pgCluster.ConfigureBackupSchedule("0 */6 * * *", retentionDays: 30);
// Assert
pgCluster.BackupConfig!.Schedule.Should().Be("0 */6 * * *");
pgCluster.BackupConfig.RetentionDays.Should().Be(30);
}
[Fact]
public void MarkDegraded_SetsStatusToDegraded()
{
// Arrange
Provisioning.Domain.PostgresCluster pgCluster = CreateTestCluster();
pgCluster.MarkRunning();
// Act — Reconciler detected an issue.
pgCluster.MarkDegraded("Primary pod not ready");
// Assert
pgCluster.Status.Should().Be(Provisioning.Domain.PostgresClusterStatus.Degraded);
pgCluster.StatusMessage.Should().Be("Primary pod not ready");
}
[Fact]
public void Create_WithS3Region_StoresRegionInBackupConfig()
{
// Arrange & Act — When creating a CNPG cluster backed by an external S3
// provider like Cleura, the region must be captured so barman-cloud can
// use it for AWS Signature V4 authentication.
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
environmentId: Guid.NewGuid(),
clusterId: Guid.NewGuid(),
name: "cleura-db",
ns: "cnpg-prod",
postgresVersion: "16.4",
instances: 3,
storageSize: "100Gi",
minioEndpoint: "https://s3.cleura.cloud",
minioBucket: "cnpg-backups",
minioCredentialsSecret: "cnpg-s3-creds",
s3Region: "eu-north-1");
// Assert — The region is stored in the backup configuration so it can
// be included in the CNPG manifest's s3Credentials.region field.
pgCluster.BackupConfig.Should().NotBeNull();
pgCluster.BackupConfig!.S3Region.Should().Be("eu-north-1");
}
[Fact]
public void Create_WithoutS3Region_DefaultsToNull()
{
// Arrange & Act — MinIO endpoints don't need a region. When omitted,
// S3Region should be null and barman-cloud will skip region config.
Provisioning.Domain.PostgresCluster pgCluster = Provisioning.Domain.PostgresCluster.Create(
environmentId: Guid.NewGuid(),
clusterId: Guid.NewGuid(),
name: "minio-db",
ns: "cnpg-system",
postgresVersion: "16.4",
instances: 3,
storageSize: "50Gi",
minioEndpoint: "minio.minio-system.svc:9000",
minioBucket: "cnpg-backups",
minioCredentialsSecret: "minio-creds");
// Assert
pgCluster.BackupConfig.Should().NotBeNull();
pgCluster.BackupConfig!.S3Region.Should().BeNull();
}
private static Provisioning.Domain.PostgresCluster CreateTestCluster()
{
return Provisioning.Domain.PostgresCluster.Create(
Guid.NewGuid(),
Guid.NewGuid(),
"test-db",
"cnpg-system",
"16.4",
3,
"50Gi",
"minio.minio-system.svc:9000",
"cnpg-backups",
"minio-cnpg-credentials");
}
}

View File

@@ -0,0 +1,275 @@
using EntKube.Provisioning.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
public class PrometheusStackTests
{
private readonly Guid clusterId = Guid.NewGuid();
private readonly Guid environmentId = Guid.NewGuid();
// ─── Creation ───────────────────────────────────────────────────────
[Fact]
public void Create_WithValidInputs_CreatesStackInPendingState()
{
// Arrange & Act — An operator provisions a new Prometheus stack on a cluster.
// Prometheus is per-cluster: each cluster gets its own metrics collector.
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
// Assert — The stack should be in a Pending state, with sensible defaults
// for Prometheus retention, replicas, and Alertmanager.
stack.Id.Should().NotBe(Guid.Empty);
stack.ClusterId.Should().Be(clusterId);
stack.EnvironmentId.Should().Be(environmentId);
stack.Name.Should().Be("prod-prometheus");
stack.Namespace.Should().Be("monitoring");
stack.Status.Should().Be(PrometheusStackStatus.Pending);
stack.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
// Prometheus defaults
stack.Prometheus.Should().NotBeNull();
stack.Prometheus.Retention.Should().Be("30d");
stack.Prometheus.Replicas.Should().Be(2);
stack.Prometheus.StorageSize.Should().Be("50Gi");
// Alertmanager defaults
stack.Alertmanager.Should().NotBeNull();
stack.Alertmanager.Enabled.Should().BeTrue();
stack.Alertmanager.Replicas.Should().Be(2);
}
[Fact]
public void Create_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act — A Prometheus stack must have a name.
Action act = () => PrometheusStack.Create(clusterId, environmentId, "", "monitoring");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("name");
}
[Fact]
public void Create_WithEmptyNamespace_ThrowsArgumentException()
{
// Arrange & Act — A Prometheus stack must have a namespace.
Action act = () => PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("ns");
}
[Fact]
public void Create_WithEmptyClusterId_ThrowsArgumentException()
{
// Arrange & Act — A Prometheus stack must belong to a cluster.
Action act = () => PrometheusStack.Create(Guid.Empty, environmentId, "prod-prometheus", "monitoring");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("clusterId");
}
[Fact]
public void Create_WithEmptyEnvironmentId_ThrowsArgumentException()
{
// Arrange & Act — A Prometheus stack must belong to an environment.
Action act = () => PrometheusStack.Create(clusterId, Guid.Empty, "prod-prometheus", "monitoring");
// Assert
act.Should().Throw<ArgumentException>()
.WithParameterName("environmentId");
}
// ─── Prometheus Configuration ─────────────────────────────────────
[Fact]
public void ConfigurePrometheus_UpdatesRetentionAndReplicas()
{
// Arrange — Start with a Prometheus stack using defaults.
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
// Act — The operator increases retention to 90 days and scales to 3 replicas.
stack.ConfigurePrometheus(retention: "90d", replicas: 3, storageSize: "100Gi", retentionSize: "90GB");
// Assert — Prometheus settings should reflect the new configuration.
stack.Prometheus.Retention.Should().Be("90d");
stack.Prometheus.Replicas.Should().Be(3);
stack.Prometheus.StorageSize.Should().Be("100Gi");
stack.Prometheus.RetentionSize.Should().Be("90GB");
}
[Fact]
public void ConfigurePrometheus_WithInvalidReplicas_ThrowsArgumentException()
{
// Arrange
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
// Act & Assert — Zero replicas doesn't make sense for Prometheus.
Action act = () => stack.ConfigurePrometheus(replicas: 0);
act.Should().Throw<ArgumentException>()
.WithParameterName("replicas");
}
// ─── Alertmanager Configuration ─────────────────────────────────
[Fact]
public void ConfigureAlertmanager_UpdatesReplicasAndEnablement()
{
// Arrange
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
// Act — The operator disables Alertmanager for a dev cluster.
stack.ConfigureAlertmanager(enabled: false, replicas: 1);
// Assert
stack.Alertmanager.Enabled.Should().BeFalse();
stack.Alertmanager.Replicas.Should().Be(1);
}
// ─── Ingress (Prometheus + Alertmanager) ────────────────────────
[Fact]
public void ConfigureIngress_SetsPrometheusAndAlertmanagerRouting()
{
// Arrange — Publish Prometheus and Alertmanager via Gateway API.
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
// Act
IngressConfiguration ingress = new(
Enabled: true,
Provider: IngressProvider.GatewayApi,
PrometheusHostname: "prometheus.prod01.example.com",
AlertmanagerHostname: "alertmanager.prod01.example.com",
TlsEnabled: true,
TlsCertificateMode: TlsCertificateMode.Manual,
TlsSecretName: "monitoring-tls");
stack.ConfigureIngress(ingress);
// Assert
stack.Ingress.Should().NotBeNull();
stack.Ingress!.Enabled.Should().BeTrue();
stack.Ingress.Provider.Should().Be(IngressProvider.GatewayApi);
stack.Ingress.PrometheusHostname.Should().Be("prometheus.prod01.example.com");
stack.Ingress.AlertmanagerHostname.Should().Be("alertmanager.prod01.example.com");
stack.Ingress.TlsEnabled.Should().BeTrue();
stack.Ingress.TlsCertificateMode.Should().Be(TlsCertificateMode.Manual);
stack.Ingress.TlsSecretName.Should().Be("monitoring-tls");
}
[Fact]
public void ConfigureIngress_WithCertManager_DoesNotRequireSecretName()
{
// Arrange
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
// Act — cert-manager with Let's Encrypt handles certificates automatically.
IngressConfiguration ingress = new(
Enabled: true,
Provider: IngressProvider.GatewayApi,
PrometheusHostname: "prometheus.example.com",
AlertmanagerHostname: "alertmanager.example.com",
TlsEnabled: true,
TlsCertificateMode: TlsCertificateMode.CertManager,
TlsSecretName: null,
ClusterIssuer: "letsencrypt-prod");
stack.ConfigureIngress(ingress);
// Assert
stack.Ingress!.TlsCertificateMode.Should().Be(TlsCertificateMode.CertManager);
stack.Ingress.TlsSecretName.Should().BeNull();
stack.Ingress.ClusterIssuer.Should().Be("letsencrypt-prod");
}
[Fact]
public void DisableIngress_ClearsIngressConfig()
{
// Arrange
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
IngressConfiguration ingress = new(
Enabled: true,
Provider: IngressProvider.GatewayApi,
PrometheusHostname: "prometheus.example.com",
AlertmanagerHostname: null,
TlsEnabled: true,
TlsCertificateMode: TlsCertificateMode.Manual,
TlsSecretName: "tls-secret");
stack.ConfigureIngress(ingress);
// Act
stack.DisableIngress();
// Assert
stack.Ingress.Should().BeNull();
}
// ─── Lifecycle ──────────────────────────────────────────────────
[Fact]
public void MarkRunning_SetsStatusToRunning()
{
// Arrange
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
// Act
stack.MarkRunning();
// Assert
stack.Status.Should().Be(PrometheusStackStatus.Running);
}
[Fact]
public void MarkDegraded_SetsStatusToDegraded()
{
// Arrange
PrometheusStack stack = PrometheusStack.Create(clusterId, environmentId, "prod-prometheus", "monitoring");
// Act
stack.MarkDegraded();
// Assert
stack.Status.Should().Be(PrometheusStackStatus.Degraded);
}
}

View File

@@ -0,0 +1,305 @@
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Domain;
/// <summary>
/// Tests for the RedisCluster aggregate root. This rich domain model tracks
/// everything about a Redis instance managed by the OT-OPERATORS Redis Operator:
/// replica count, storage, sentinel mode, persistence, and version lifecycle.
///
/// The OT-OPERATORS Redis Operator manages Redis, RedisCluster, RedisReplication,
/// and RedisSentinel CRDs on Kubernetes. Each Redis instance can be standalone,
/// replicated, or run with Sentinel for HA.
/// </summary>
public class RedisClusterTests
{
// ─── Creation ──────────────────────────────────────────────────────────────
[Fact]
public void Create_WithValidInputs_ReturnsClusterInProvisioningState()
{
// Arrange & Act — A platform admin provisions a new Redis instance
// for shared caching on a specific Kubernetes cluster.
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
environmentId: environmentId,
clusterId: clusterId,
name: "platform-redis",
ns: "redis",
redisVersion: "7.2.5",
replicas: 3,
storageSize: "10Gi",
sentinelEnabled: true);
// Assert — The cluster starts in Provisioning state, waiting for the
// reconciler to create the Redis CR.
redisCluster.Id.Should().NotBe(Guid.Empty);
redisCluster.EnvironmentId.Should().Be(environmentId);
redisCluster.ClusterId.Should().Be(clusterId);
redisCluster.Name.Should().Be("platform-redis");
redisCluster.Namespace.Should().Be("redis");
redisCluster.RedisVersion.Should().Be("7.2.5");
redisCluster.Replicas.Should().Be(3);
redisCluster.StorageSize.Should().Be("10Gi");
redisCluster.SentinelEnabled.Should().BeTrue();
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Provisioning);
}
[Fact]
public void Create_WithEmptyName_ThrowsArgumentException()
{
// Arrange & Act — The domain rejects invalid cluster names.
Action act = () => Provisioning.Domain.RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "", "ns", "7.2.5", 3, "10Gi", true);
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("name");
}
[Fact]
public void Create_WithZeroReplicas_ThrowsArgumentException()
{
// Arrange & Act — Must have at least 1 replica.
Action act = () => Provisioning.Domain.RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.2.5", 0, "10Gi", true);
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("replicas");
}
[Fact]
public void Create_WithEmptyNamespace_ThrowsArgumentException()
{
// Arrange & Act
Action act = () => Provisioning.Domain.RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "", "7.2.5", 3, "10Gi", true);
// Assert
act.Should().Throw<ArgumentException>().WithParameterName("ns");
}
// ─── Adoption ──────────────────────────────────────────────────────────────
[Fact]
public void Adopt_FromExistingInstance_CreatesRunningCluster()
{
// Arrange & Act — When we adopt an existing Redis instance that's
// already running on the K8s cluster, we create it in Running state.
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Adopt(
environmentId: Guid.NewGuid(),
clusterId: Guid.NewGuid(),
name: "existing-redis",
ns: "redis",
redisVersion: "7.0.15",
replicas: 3,
storageSize: "5Gi",
sentinelEnabled: true);
// Assert — Adopted clusters start in Running state.
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Running);
redisCluster.Name.Should().Be("existing-redis");
redisCluster.LastReconcileAt.Should().NotBeNull();
}
// ─── Status transitions ────────────────────────────────────────────────────
[Fact]
public void MarkRunning_TransitionsFromProvisioning()
{
// Arrange — A freshly created cluster in Provisioning state.
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
// Act — The reconciler confirmed the Redis instance is ready.
redisCluster.MarkRunning();
// Assert
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Running);
redisCluster.LastReconcileAt.Should().NotBeNull();
}
[Fact]
public void MarkDegraded_SetsStatusToDegraded()
{
// Arrange
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
redisCluster.MarkRunning();
// Act — Reconciler detected an issue.
redisCluster.MarkDegraded("Replica pod not ready");
// Assert
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Degraded);
redisCluster.StatusMessage.Should().Be("Replica pod not ready");
}
// ─── Version upgrades ──────────────────────────────────────────────────────
[Fact]
public void RequestUpgrade_WhenRunning_SetsUpgradingStatusAndTargetVersion()
{
// Arrange — A running cluster at version 7.0.15.
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.0.15", 3, "10Gi", true);
redisCluster.MarkRunning();
// Act — Platform admin requests upgrade to 7.2.5.
Result<string> result = redisCluster.RequestUpgrade("7.2.5");
// Assert — The cluster transitions to Upgrading state.
result.IsSuccess.Should().BeTrue();
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Upgrading);
redisCluster.TargetVersion.Should().Be("7.2.5");
}
[Fact]
public void RequestUpgrade_WhenNotRunning_ReturnsFailure()
{
// Arrange — A cluster still provisioning can't be upgraded.
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
// Act
Result<string> result = redisCluster.RequestUpgrade("7.2.5");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("running");
}
[Fact]
public void RequestUpgrade_ToSameVersion_ReturnsFailure()
{
// Arrange — A cluster already at version 7.2.5.
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.2.5", 3, "10Gi", true);
redisCluster.MarkRunning();
// Act — Try to upgrade to the same version.
Result<string> result = redisCluster.RequestUpgrade("7.2.5");
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("same version");
}
[Fact]
public void CompleteUpgrade_TransitionsToRunningWithNewVersion()
{
// Arrange — A cluster mid-upgrade.
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.0.15", 3, "10Gi", true);
redisCluster.MarkRunning();
redisCluster.RequestUpgrade("7.2.5");
// Act — Reconciler confirmed upgrade completed.
redisCluster.CompleteUpgrade();
// Assert — Version updated, status back to Running.
redisCluster.RedisVersion.Should().Be("7.2.5");
redisCluster.Status.Should().Be(Provisioning.Domain.RedisClusterStatus.Running);
redisCluster.TargetVersion.Should().BeNull();
}
// ─── Scaling ───────────────────────────────────────────────────────────────
[Fact]
public void ScaleReplicas_UpdatesReplicaCount()
{
// Arrange — A running 3-replica cluster.
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
redisCluster.MarkRunning();
// Act — Scale up to 5 replicas for more read capacity.
Result<string> result = redisCluster.ScaleReplicas(5);
// Assert
result.IsSuccess.Should().BeTrue();
redisCluster.Replicas.Should().Be(5);
}
[Fact]
public void ScaleReplicas_ToZero_ReturnsFailure()
{
// Arrange
Provisioning.Domain.RedisCluster redisCluster = CreateTestCluster();
redisCluster.MarkRunning();
// Act
Result<string> result = redisCluster.ScaleReplicas(0);
// Assert
result.IsSuccess.Should().BeFalse();
result.Error.Should().Contain("at least 1");
}
// ─── Configuration ─────────────────────────────────────────────────────────
[Fact]
public void ConfigureSentinel_EnablesOrDisablesSentinel()
{
// Arrange — A cluster without sentinel.
Provisioning.Domain.RedisCluster redisCluster = Provisioning.Domain.RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "ns", "7.2.5", 3, "10Gi", false);
redisCluster.MarkRunning();
// Act — Enable sentinel for HA.
redisCluster.ConfigureSentinel(true);
// Assert
redisCluster.SentinelEnabled.Should().BeTrue();
}
// ─── Helper ────────────────────────────────────────────────────────────────
private static Provisioning.Domain.RedisCluster CreateTestCluster()
{
return Provisioning.Domain.RedisCluster.Create(
Guid.NewGuid(),
Guid.NewGuid(),
"test-redis",
"redis",
"7.2.5",
3,
"10Gi",
true);
}
}

View File

@@ -10,17 +10,20 @@ public class ServiceInstanceTests
{
// Arrange & Act — Request provisioning of a MinIO instance.
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
ServiceInstance instance = ServiceInstance.Provision(
clusterId,
environmentId,
ServiceType.MinIO,
"tenant-storage",
"minio-system");
"minio-system",
clusterId);
// Assert — Starts pending until the reconciler deploys it.
instance.Id.Should().NotBe(Guid.Empty);
instance.EnvironmentId.Should().Be(environmentId);
instance.ClusterId.Should().Be(clusterId);
instance.ServiceType.Should().Be(ServiceType.MinIO);
instance.Name.Should().Be("tenant-storage");
@@ -34,7 +37,7 @@ public class ServiceInstanceTests
{
// Arrange & Act
Action act = () => ServiceInstance.Provision(Guid.NewGuid(), ServiceType.MinIO, "", "ns");
Action act = () => ServiceInstance.Provision(Guid.NewGuid(), ServiceType.MinIO, "", "ns", Guid.NewGuid());
// Assert
@@ -47,7 +50,7 @@ public class ServiceInstanceTests
{
// Arrange
ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.CloudNativePG, "db", "cnpg-system");
ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.CloudNativePG, "db", "cnpg-system", Guid.NewGuid());
// Act
@@ -64,7 +67,7 @@ public class ServiceInstanceTests
{
// Arrange
ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.Keycloak, "auth", "keycloak-system");
ServiceInstance instance = ServiceInstance.Provision(Guid.NewGuid(), ServiceType.Keycloak, "auth", "keycloak-system", Guid.NewGuid());
instance.MarkRunning();
// Act — Tenant admin decides to tear down this service.
@@ -76,4 +79,48 @@ public class ServiceInstanceTests
instance.DesiredState.Should().Be(ServiceState.Decommissioned);
instance.CurrentState.Should().Be(ServiceState.Running);
}
[Fact]
public void ServiceType_IncludesGitea()
{
// Gitea is a shared service that can be provisioned on a cluster,
// so ServiceType must include it as a valid option.
ServiceType type = ServiceType.Gitea;
type.Should().BeDefined();
}
[Fact]
public void ServiceType_IncludesHarbor()
{
// Harbor is a shared container registry that can be provisioned
// on a cluster, so ServiceType must include it.
ServiceType type = ServiceType.Harbor;
type.Should().BeDefined();
}
[Fact]
public void Provision_Gitea_CreatesInstanceInPendingState()
{
// Gitea should be provisionable just like any other service type.
ServiceInstance instance = ServiceInstance.Provision(
Guid.NewGuid(), ServiceType.Gitea, "team-gitea", "gitea", Guid.NewGuid());
instance.ServiceType.Should().Be(ServiceType.Gitea);
instance.CurrentState.Should().Be(ServiceState.Pending);
}
[Fact]
public void Provision_Harbor_CreatesInstanceInPendingState()
{
// Harbor should be provisionable just like any other service type.
ServiceInstance instance = ServiceInstance.Provision(
Guid.NewGuid(), ServiceType.Harbor, "team-harbor", "harbor", Guid.NewGuid());
instance.ServiceType.Should().Be(ServiceType.Harbor);
instance.CurrentState.Should().Be(ServiceState.Pending);
}
}

View File

@@ -0,0 +1,98 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.AddAppEnvironment;
using EntKube.Provisioning.Features.Apps.Reconcile;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
public class AddAppEnvironmentHandlerTests
{
private readonly AddAppEnvironmentHandler handler;
private readonly InMemoryAppRepository repository;
private readonly Mock<IKubernetesApplyClient> applyClientMock;
public AddAppEnvironmentHandlerTests()
{
repository = new InMemoryAppRepository();
applyClientMock = new Mock<IKubernetesApplyClient>();
handler = new AddAppEnvironmentHandler(repository, applyClientMock.Object);
}
[Fact]
public async Task HandleAsync_WithValidRequest_AddsEnvironmentToApp()
{
// Arrange — An app exists and the admin wants to add it to the dev environment.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AddAppEnvironmentRequest request = new(
AppId: app.Id,
EnvironmentId: environmentId,
ClusterId: clusterId,
Namespace: "api-dev");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — The environment is added and its ID returned.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments.Should().HaveCount(1);
updated.Environments[0].EnvironmentId.Should().Be(environmentId);
updated.Environments[0].ClusterId.Should().Be(clusterId);
updated.Environments[0].Namespace.Should().Be("api-dev");
// Verify that a namespace manifest was applied to the cluster.
applyClientMock.Verify(
c => c.ApplyManifestAsync(clusterId, It.Is<string>(y => y.Contains("kind: Namespace") && y.Contains("name: api-dev")), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_WithNonExistentApp_ReturnsFailure()
{
// Arrange & Act
AddAppEnvironmentRequest request = new(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), "ns");
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_WithDuplicateEnvironment_ReturnsFailure()
{
// Arrange — The app is already deployed to this environment.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
AddAppEnvironmentRequest request = new(app.Id, environmentId, Guid.NewGuid(), "api-dev-2");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("already");
}
}

View File

@@ -0,0 +1,88 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.AddAppSecret;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class AddAppSecretHandlerTests
{
private readonly AddAppSecretHandler handler;
private readonly InMemoryAppRepository repository;
public AddAppSecretHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new AddAppSecretHandler(repository);
}
[Fact]
public async Task HandleAsync_WithValidRequest_AddsSecretToEnvironment()
{
// Arrange — An app deployed to dev needs a database connection string.
// The actual secret value lives in the vault — we just store the reference.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
AddAppSecretRequest request = new(
AppId: app.Id,
EnvironmentId: environmentId,
Name: "DATABASE_URL",
VaultKey: "database-connection-string");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
AppEnvironment env = updated!.Environments[0];
env.Secrets.Should().HaveCount(1);
env.Secrets[0].Name.Should().Be("DATABASE_URL");
env.Secrets[0].VaultKey.Should().Be("database-connection-string");
}
[Fact]
public async Task HandleAsync_NonExistentApp_ReturnsFailure()
{
// Arrange & Act
AddAppSecretRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "KEY", "vault-key");
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_DuplicateSecretName_ReturnsFailure()
{
// Arrange — A secret with this name already exists.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
env.AddSecret("DATABASE_URL", "db-conn");
await repository.AddAsync(app);
AddAppSecretRequest request = new(app.Id, environmentId, "DATABASE_URL", "other-key");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("already exists");
}
}

View File

@@ -0,0 +1,387 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.Reconcile;
using EntKube.Provisioning.Infrastructure;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
public class AppReconcilerTests
{
private readonly InMemoryAppRepository repository;
private readonly Mock<IKubernetesApplyClient> applyClient;
private readonly Mock<ISecretResolver> secretResolver;
private readonly Mock<IClusterGatewayResolver> gatewayResolver;
private readonly AppReconcileHandler handler;
public AppReconcilerTests()
{
repository = new InMemoryAppRepository();
applyClient = new Mock<IKubernetesApplyClient>();
secretResolver = new Mock<ISecretResolver>();
gatewayResolver = new Mock<IClusterGatewayResolver>();
handler = new AppReconcileHandler(repository, applyClient.Object, secretResolver.Object, gatewayResolver.Object);
}
// ─── Deployment-type app reconciliation ──────────────────────────────
[Fact]
public async Task ReconcileAsync_DeploymentApp_AppliesAllManifests()
{
// Arrange — A Deployment-type app with a configured environment.
// The reconciler should generate and apply deployment.yaml, service.yaml,
// and httproute.yaml to the target cluster.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "registry.example.com/api",
Tag: "v1.0.0",
Replicas: 2,
ContainerPort: 8080,
ServicePort: 80,
HostName: "api.dev.example.com",
PathPrefix: "/",
EnvironmentVariables: null,
Resources: null));
await repository.AddAsync(app);
// Act — Reconcile all pending environments.
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Three manifests should be applied: Deployment, Service, HTTPRoute.
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("kind: Deployment")),
It.IsAny<CancellationToken>()), Times.Once);
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("kind: Service")),
It.IsAny<CancellationToken>()), Times.Once);
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("kind: HTTPRoute")),
It.IsAny<CancellationToken>()), Times.Once);
// The environment should be marked as Synced after successful reconciliation.
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Synced);
}
[Fact]
public async Task ReconcileAsync_DeploymentAppWithSecrets_ResolvesAndAppliesSecretManifest()
{
// Arrange — An app with a secret reference. The reconciler should
// resolve the vault key, generate a Secret manifest, and apply it.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
env.AddSecret("DATABASE_URL", "db-connection-string");
await repository.AddAsync(app);
secretResolver.Setup(r => r.ResolveAsync(
tenantId, customerId, "api", envId, "db-connection-string",
It.IsAny<CancellationToken>()))
.ReturnsAsync("postgresql://db:5432/mydb");
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — A Secret manifest should be applied alongside the Deployment.
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("kind: Secret")),
It.IsAny<CancellationToken>()), Times.Once);
// The Deployment manifest should include secretKeyRef for the secret.
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("secretKeyRef:")),
It.IsAny<CancellationToken>()), Times.Once);
}
// ─── HelmChart-type app reconciliation ───────────────────────────────
[Fact]
public async Task ReconcileAsync_HelmChartApp_CallsHelmUpgradeInstall()
{
// Arrange — A HelmChart-type app. The reconciler should call
// HelmUpgradeInstallAsync instead of applying YAML manifests.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "Redis", "redis", AppType.HelmChart);
Guid envId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, clusterId, "redis-dev");
HelmReleaseSpec helmSpec = new(
RepoUrl: "https://charts.bitnami.com/bitnami",
ChartName: "redis",
ChartVersion: "18.6.1",
ValuesYaml: "replica:\n replicaCount: 3");
env.ConfigureHelmRelease(helmSpec);
await repository.AddAsync(app);
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Should call helm upgrade install, not apply manifests.
applyClient.Verify(c => c.HelmUpgradeInstallAsync(
clusterId,
"redis",
"redis-dev",
It.Is<HelmReleaseSpec>(s => s.ChartName == "redis" && s.ChartVersion == "18.6.1"),
It.IsAny<CancellationToken>()), Times.Once);
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Synced);
}
// ─── Error handling ──────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_WhenApplyFails_MarksEnvironmentAsError()
{
// Arrange — The K8s apply call throws an exception.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, clusterId, "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
await repository.AddAsync(app);
applyClient.Setup(c => c.ApplyManifestAsync(
clusterId, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException("Cluster unreachable"));
// Act — Reconcile should not throw — it catches and marks error.
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The environment should be marked with an error.
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Error);
updated.Environments[0].SyncStatusMessage.Should().Contain("Cluster unreachable");
}
// ─── Skipping already-synced environments ────────────────────────────
[Fact]
public async Task ReconcileAsync_AlreadySynced_SkipsReconciliation()
{
// Arrange — An environment that's already synced shouldn't be reprocessed.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
env.MarkSynced();
await repository.AddAsync(app);
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — No K8s calls should be made.
applyClient.Verify(c => c.ApplyManifestAsync(
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
applyClient.Verify(c => c.HelmUpgradeInstallAsync(
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<HelmReleaseSpec>(), It.IsAny<CancellationToken>()), Times.Never);
}
// ─── Suspended apps ──────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_SuspendedApp_SkipsReconciliation()
{
// Arrange — A suspended app should not be reconciled even if
// environments are pending.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), Guid.NewGuid(), "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
app.Suspend();
await repository.AddAsync(app);
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert
applyClient.Verify(c => c.ApplyManifestAsync(
It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
// ─── Gateway auto-resolution ─────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_RouteWithoutGateway_InjectsClusterDefaultGateway()
{
// Arrange — An app with a route that has no GatewayRef specified.
// The reconciler should ask the cluster for its default gateway and
// inject it into the route manifest automatically.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "Web", "web", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), clusterId, "web-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "registry.example.com/web",
Tag: "v1.0.0",
Replicas: 1,
ContainerPort: 8080,
ServicePort: 80,
HostName: null,
PathPrefix: null,
EnvironmentVariables: null,
Resources: null,
Services: new List<ServiceSpec>
{
new("web-http", 80, 8080, "TCP", "ClusterIP")
},
Routes: new List<RouteSpec>
{
new("web-route", "HTTP",
Hostnames: new List<string> { "web.dev.example.com" },
GatewayRef: null,
Rules: new List<RouteRule>
{
new(Matches: new List<RouteMatch> { new("PathPrefix", "/", null) },
BackendRefs: new List<RouteBackendRef> { new("web-http", 80, null) },
Filters: null)
})
}));
await repository.AddAsync(app);
// The cluster's ingress is Istio with an internal gateway.
gatewayResolver.Setup(r => r.GetDefaultGatewayAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResolvedGateway("istio", "internal", "internal-ingress"));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The route manifest should contain the auto-resolved gateway
// parentRef even though the user never specified one.
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("name: internal") && y.Contains("namespace: internal-ingress")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ReconcileAsync_RouteWithExplicitGateway_DoesNotOverride()
{
// Arrange — An app with a route that already has an explicit GatewayRef.
// The reconciler should NOT replace it with the cluster's default.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
App app = App.Create(tenantId, customerId, "API", "api", AppType.Deployment);
AppEnvironment env = app.AddEnvironment(Guid.NewGuid(), clusterId, "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "registry.example.com/api",
Tag: "v1.0.0",
Replicas: 1,
ContainerPort: 8080,
ServicePort: 80,
HostName: null,
PathPrefix: null,
EnvironmentVariables: null,
Resources: null,
Services: new List<ServiceSpec>
{
new("api-http", 80, 8080, "TCP", "ClusterIP")
},
Routes: new List<RouteSpec>
{
new("api-route", "HTTP",
Hostnames: new List<string> { "api.dev.example.com" },
GatewayRef: new GatewayReference("external", "external-ingress"),
Rules: new List<RouteRule>
{
new(Matches: new List<RouteMatch> { new("PathPrefix", "/", null) },
BackendRefs: new List<RouteBackendRef> { new("api-http", 80, null) },
Filters: null)
})
}));
await repository.AddAsync(app);
// The cluster has a default internal gateway, but the route specifies external.
gatewayResolver.Setup(r => r.GetDefaultGatewayAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ResolvedGateway("istio", "internal", "internal-ingress"));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The route should use the explicit "external" gateway, not "internal".
applyClient.Verify(c => c.ApplyManifestAsync(
clusterId,
It.Is<string>(y => y.Contains("name: external") && y.Contains("namespace: external-ingress")),
It.IsAny<CancellationToken>()), Times.Once);
}
}

View File

@@ -0,0 +1,128 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.ConfigureAppEnvironment;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class ConfigureAppEnvironmentHandlerTests
{
private readonly ConfigureAppEnvironmentHandler handler;
private readonly InMemoryAppRepository repository;
public ConfigureAppEnvironmentHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new ConfigureAppEnvironmentHandler(repository);
}
[Fact]
public async Task HandleAsync_DeploymentSpec_ConfiguresEnvironment()
{
// Arrange — An app with an environment that needs deployment config.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
ConfigureDeploymentRequest request = new(
AppId: app.Id,
EnvironmentId: environmentId,
Spec: new DeploymentSpec(
Image: "registry.example.com/api",
Tag: "v1.0.0",
Replicas: 2,
ContainerPort: 8080,
ServicePort: 80,
HostName: "api.dev.example.com",
PathPrefix: "/",
EnvironmentVariables: new Dictionary<string, string> { ["ENV"] = "dev" },
Resources: new ResourceSpec("100m", "500m", "128Mi", "512Mi")));
// Act
Result result = await handler.HandleDeploymentAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
AppEnvironment env = updated!.Environments[0];
env.DeploymentSpec.Should().NotBeNull();
env.DeploymentSpec!.Image.Should().Be("registry.example.com/api");
env.SyncStatus.Should().Be(AppSyncStatus.Pending);
}
[Fact]
public async Task HandleAsync_HelmSpec_ConfiguresEnvironment()
{
// Arrange — A Helm-type app with an environment that needs chart config.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "Redis", "redis", AppType.HelmChart);
Guid environmentId = Guid.NewGuid();
app.AddEnvironment(environmentId, Guid.NewGuid(), "redis-dev");
await repository.AddAsync(app);
ConfigureHelmReleaseRequest request = new(
AppId: app.Id,
EnvironmentId: environmentId,
Spec: new HelmReleaseSpec(
RepoUrl: "https://charts.bitnami.com/bitnami",
ChartName: "redis",
ChartVersion: "18.6.1",
ValuesYaml: "replica:\n replicaCount: 3"));
// Act
Result result = await handler.HandleHelmReleaseAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
AppEnvironment env = updated!.Environments[0];
env.HelmReleaseSpec.Should().NotBeNull();
env.HelmReleaseSpec!.ChartName.Should().Be("redis");
}
[Fact]
public async Task HandleAsync_NonExistentApp_ReturnsFailure()
{
// Arrange & Act
ConfigureDeploymentRequest request = new(Guid.NewGuid(), Guid.NewGuid(),
new DeploymentSpec("img", "v1", 1, 80, 80, null, null, null, null));
Result result = await handler.HandleDeploymentAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_NonExistentEnvironment_ReturnsFailure()
{
// Arrange — App exists but the environment ID doesn't match.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
ConfigureDeploymentRequest request = new(app.Id, Guid.NewGuid(),
new DeploymentSpec("img", "v1", 1, 80, 80, null, null, null, null));
// Act
Result result = await handler.HandleDeploymentAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,107 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.CreateApp;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class CreateAppHandlerTests
{
private readonly CreateAppHandler handler;
private readonly InMemoryAppRepository repository;
public CreateAppHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new CreateAppHandler(repository);
}
[Fact]
public async Task HandleAsync_WithValidRequest_CreatesAppAndReturnsId()
{
// Arrange — A customer admin creates a new Deployment-type app.
CreateAppRequest request = new(
TenantId: Guid.NewGuid(),
CustomerId: Guid.NewGuid(),
Name: "Frontend Portal",
Slug: "frontend-portal",
Type: AppType.Deployment);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — The app is created and its ID returned.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
App? stored = await repository.GetByIdAsync(result.Value!);
stored.Should().NotBeNull();
stored!.Name.Should().Be("Frontend Portal");
stored.Type.Should().Be(AppType.Deployment);
}
[Fact]
public async Task HandleAsync_WithDuplicateSlug_ReturnsFailure()
{
// Arrange — An app with the same slug already exists for this customer.
Guid customerId = Guid.NewGuid();
CreateAppRequest first = new(Guid.NewGuid(), customerId, "App One", "my-app", AppType.Deployment);
await handler.HandleAsync(first);
CreateAppRequest duplicate = new(Guid.NewGuid(), customerId, "App Two", "my-app", AppType.HelmChart);
// Act
Result<Guid> result = await handler.HandleAsync(duplicate);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("already exists");
}
[Fact]
public async Task HandleAsync_WithEmptyName_ReturnsFailure()
{
// Arrange & Act
CreateAppRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "", "slug", AppType.Deployment);
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("name");
}
[Fact]
public async Task HandleAsync_WithHelmChartType_CreatesHelmApp()
{
// Arrange — A customer wants to deploy a Helm chart-based app.
CreateAppRequest request = new(
TenantId: Guid.NewGuid(),
CustomerId: Guid.NewGuid(),
Name: "Redis Cache",
Slug: "redis-cache",
Type: AppType.HelmChart);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
App? stored = await repository.GetByIdAsync(result.Value!);
stored.Should().NotBeNull();
stored!.Type.Should().Be(AppType.HelmChart);
}
}

View File

@@ -0,0 +1,425 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.DatabaseStatusReconcile;
using EntKube.Provisioning.Features.MongoClusters;
using EntKube.Provisioning.Features.PostgresClusters;
using EntKube.Provisioning.Features.RedisClusters;
using EntKube.Provisioning.Infrastructure;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for the DatabaseStatusReconcileHandler, which periodically checks
/// Kubernetes CRD status for CNPG, Redis, and MongoDB clusters and transitions
/// the domain models from Provisioning → Running (or Degraded) based on live state.
///
/// This is the missing piece that caused database clusters to stay stuck in
/// "Provisioning" status forever even after Kubernetes reported them as healthy.
/// </summary>
public class DatabaseStatusReconcileHandlerTests
{
private readonly InMemoryPostgresClusterRepository pgRepository;
private readonly InMemoryRedisClusterRepository redisRepository;
private readonly InMemoryMongoClusterRepository mongoRepository;
private readonly Mock<ICnpgClusterClient> cnpgClient;
private readonly Mock<IRedisClusterClient> redisClient;
private readonly Mock<IMongoClusterClient> mongoClient;
private readonly Mock<IClusterCredentialsResolver> credentialsResolver;
private readonly DatabaseStatusReconcileHandler handler;
private const string FakeKubeConfig = "apiVersion: v1\nclusters: []";
private const string FakeContextName = "test-context";
public DatabaseStatusReconcileHandlerTests()
{
pgRepository = new InMemoryPostgresClusterRepository();
redisRepository = new InMemoryRedisClusterRepository();
mongoRepository = new InMemoryMongoClusterRepository();
cnpgClient = new Mock<ICnpgClusterClient>();
redisClient = new Mock<IRedisClusterClient>();
mongoClient = new Mock<IMongoClusterClient>();
credentialsResolver = new Mock<IClusterCredentialsResolver>();
handler = new DatabaseStatusReconcileHandler(
pgRepository,
redisRepository,
mongoRepository,
cnpgClient.Object,
redisClient.Object,
mongoClient.Object,
credentialsResolver.Object);
}
// ─── CNPG PostgreSQL ─────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_CnpgClusterHealthy_TransitionsToRunning()
{
// Arrange — A freshly provisioned CNPG cluster still in Provisioning state.
// Kubernetes reports it as healthy (phase = "Cluster in healthy state").
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "test-db", "cnpg-system", "16.4", 3, "50Gi");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 3, 3, "test-db-1", null, null));
// Act — The reconciler checks the status and transitions accordingly.
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The cluster should now be Running.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Running);
updated.LastReconcileAt.Should().NotBeNull();
}
[Fact]
public async Task ReconcileAsync_CnpgClusterNotReady_StaysProvisioning()
{
// Arrange — A CNPG cluster where K8s reports it's still setting up
// (not all instances are ready yet).
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "test-db", "cnpg-system", "16.4", 3, "50Gi");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new CnpgClusterStatus("Setting up primary", 0, 3, null, null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Still provisioning; not all instances are ready.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Provisioning);
}
[Fact]
public async Task ReconcileAsync_CnpgClusterAlreadyRunning_SkipsButChecksForDegradation()
{
// Arrange — A cluster that's already Running. The reconciler should check
// its health and mark it Degraded if it has become unhealthy.
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "healthy-db", "cnpg-system", "16.4", 3, "50Gi");
pgCluster.MarkRunning();
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "healthy-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 2, 3, "healthy-db-1", null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Not all instances ready → mark degraded.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Degraded);
}
[Fact]
public async Task ReconcileAsync_CnpgUpgrading_CompletesUpgradeWhenHealthy()
{
// Arrange — A cluster that's mid-upgrade. When K8s reports healthy at the
// target version, the reconciler should complete the upgrade.
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "upgrade-db", "cnpg-system", "15.6", 3, "50Gi");
pgCluster.MarkRunning();
pgCluster.RequestUpgrade("16.4");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "upgrade-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new CnpgClusterStatus("Cluster in healthy state", 3, 3, "upgrade-db-1", null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Upgrade completed, back to Running.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Running);
updated.PostgresVersion.Should().Be("16.4");
}
// ─── Redis ───────────────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_RedisClusterReady_TransitionsToRunning()
{
// Arrange — A freshly provisioned Redis cluster still in Provisioning state.
// Kubernetes reports all replicas are ready.
Guid clusterId = Guid.NewGuid();
RedisCluster redis = RedisCluster.Create(
Guid.NewGuid(), clusterId, "test-redis", "redis", "7.2.5", 3, "10Gi", true);
await redisRepository.AddAsync(redis);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
redisClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-redis", "redis", It.IsAny<CancellationToken>()))
.ReturnsAsync(new RedisCrdStatus("Ready", 3, 3, "test-redis-0"));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The Redis cluster should now be Running.
RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id);
updated!.Status.Should().Be(RedisClusterStatus.Running);
updated.LastReconcileAt.Should().NotBeNull();
}
[Fact]
public async Task ReconcileAsync_RedisClusterNotReady_StaysProvisioning()
{
// Arrange — Redis operator reports Unknown phase with no ready replicas.
Guid clusterId = Guid.NewGuid();
RedisCluster redis = RedisCluster.Create(
Guid.NewGuid(), clusterId, "test-redis", "redis", "7.2.5", 3, "10Gi", true);
await redisRepository.AddAsync(redis);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
redisClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-redis", "redis", It.IsAny<CancellationToken>()))
.ReturnsAsync(new RedisCrdStatus("Unknown", 0, 0, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Still provisioning.
RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id);
updated!.Status.Should().Be(RedisClusterStatus.Provisioning);
}
[Fact]
public async Task ReconcileAsync_RedisUpgrading_CompletesUpgradeWhenReady()
{
// Arrange — Redis cluster mid-upgrade.
Guid clusterId = Guid.NewGuid();
RedisCluster redis = RedisCluster.Create(
Guid.NewGuid(), clusterId, "upgrade-redis", "redis", "7.0.15", 3, "10Gi", true);
redis.MarkRunning();
redis.RequestUpgrade("7.2.5");
await redisRepository.AddAsync(redis);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
redisClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "upgrade-redis", "redis", It.IsAny<CancellationToken>()))
.ReturnsAsync(new RedisCrdStatus("Ready", 3, 3, "upgrade-redis-0"));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Upgrade completed.
RedisCluster? updated = await redisRepository.GetByIdAsync(redis.Id);
updated!.Status.Should().Be(RedisClusterStatus.Running);
updated.RedisVersion.Should().Be("7.2.5");
}
// ─── MongoDB ─────────────────────────────────────────────────────────
[Fact]
public async Task ReconcileAsync_MongoClusterRunningPhase_TransitionsToRunning()
{
// Arrange — A freshly provisioned MongoDB cluster still in Provisioning.
// The operator reports phase = "Running" with all members ready.
Guid clusterId = Guid.NewGuid();
MongoCluster mongo = MongoCluster.Create(
Guid.NewGuid(), clusterId, "test-mongo", "mongodb-system", "7.0.12", 3, "50Gi");
await mongoRepository.AddAsync(mongo);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
mongoClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "test-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MongoCrdStatus("Running", 3, 3, "test-mongo-0", null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — The MongoDB cluster should now be Running.
MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id);
updated!.Status.Should().Be(MongoClusterStatus.Running);
updated.LastReconcileAt.Should().NotBeNull();
}
[Fact]
public async Task ReconcileAsync_MongoClusterFailed_MarksDegraded()
{
// Arrange — A MongoDB cluster where the operator reports phase = "Failed".
// This matches the user's scenario where MongoDB shows "Failed" with 0/3 ready.
Guid clusterId = Guid.NewGuid();
MongoCluster mongo = MongoCluster.Create(
Guid.NewGuid(), clusterId, "failed-mongo", "mongodb-system", "7.0.12", 3, "50Gi");
await mongoRepository.AddAsync(mongo);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
mongoClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "failed-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MongoCrdStatus("Failed", 0, 3, null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Failed phase → Degraded with message.
MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id);
updated!.Status.Should().Be(MongoClusterStatus.Degraded);
updated.StatusMessage.Should().Contain("Failed");
}
[Fact]
public async Task ReconcileAsync_MongoClusterNotReady_StaysProvisioning()
{
// Arrange — MongoDB with pending phase and 0 ready members.
Guid clusterId = Guid.NewGuid();
MongoCluster mongo = MongoCluster.Create(
Guid.NewGuid(), clusterId, "pending-mongo", "mongodb-system", "7.0.12", 3, "50Gi");
await mongoRepository.AddAsync(mongo);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
mongoClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "pending-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MongoCrdStatus("Pending", 0, 3, null, null));
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Still provisioning.
MongoCluster? updated = await mongoRepository.GetByIdAsync(mongo.Id);
updated!.Status.Should().Be(MongoClusterStatus.Provisioning);
}
// ─── Credential resolution failures ──────────────────────────────────
[Fact]
public async Task ReconcileAsync_CredentialResolutionFails_SkipsCluster()
{
// Arrange — Can't reach the Clusters service. The reconciler should skip
// this cluster without crashing and move on to others.
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "unreachable-db", "cnpg-system", "16.4", 3, "50Gi");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync((ClusterCredentials?)null);
// Act — Should not throw.
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Status unchanged.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Provisioning);
}
[Fact]
public async Task ReconcileAsync_StatusQueryReturnsNull_DoesNotChangeStatus()
{
// Arrange — The K8s status query returns null (cluster CR not found).
// The reconciler should leave the status unchanged.
Guid clusterId = Guid.NewGuid();
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), clusterId, "missing-cr-db", "cnpg-system", "16.4", 3, "50Gi");
await pgRepository.AddAsync(pgCluster);
credentialsResolver.Setup(r => r.ResolveAsync(clusterId, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ClusterCredentials(FakeKubeConfig, FakeContextName));
cnpgClient.Setup(c => c.GetClusterStatusAsync(
FakeKubeConfig, FakeContextName, "missing-cr-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync((CnpgClusterStatus?)null);
// Act
await handler.ReconcileAsync(CancellationToken.None);
// Assert — Status unchanged.
PostgresCluster? updated = await pgRepository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Provisioning);
}
}

View File

@@ -0,0 +1,64 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.DecommissionService;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class DecommissionServiceHandlerTests
{
private readonly DecommissionServiceHandler handler;
private readonly InMemoryServiceInstanceRepository repository;
public DecommissionServiceHandlerTests()
{
repository = new InMemoryServiceInstanceRepository();
handler = new DecommissionServiceHandler(repository);
}
[Fact]
public async Task HandleAsync_WithRunningService_SetsDesiredStateToDecommissioned()
{
// Arrange — A running service exists in the repository.
ServiceInstance instance = ServiceInstance.Provision(
Guid.NewGuid(),
ServiceType.MinIO,
"tenant-storage",
"minio-system",
Guid.NewGuid());
instance.MarkRunning();
await repository.AddAsync(instance);
// Act — Request decommission.
Result result = await handler.HandleAsync(instance.Id);
// Assert — The desired state should be Decommissioned.
result.IsSuccess.Should().BeTrue();
ServiceInstance? updated = await repository.GetByIdAsync(instance.Id);
updated.Should().NotBeNull();
updated!.DesiredState.Should().Be(ServiceState.Decommissioned);
}
[Fact]
public async Task HandleAsync_WithNonExistentId_ReturnsFailure()
{
// Arrange — An ID that doesn't exist.
Guid unknownId = Guid.NewGuid();
// Act
Result result = await handler.HandleAsync(unknownId);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,51 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.DeleteApp;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class DeleteAppHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly DeleteAppHandler handler;
public DeleteAppHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new DeleteAppHandler(repository);
}
[Fact]
public async Task HandleAsync_ExistingApp_DeletesSuccessfully()
{
// Arrange — An existing app in the repository.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(app.Id);
// Assert — The app should be gone from the repository.
result.IsSuccess.Should().BeTrue();
App? deleted = await repository.GetByIdAsync(app.Id);
deleted.Should().BeNull();
}
[Fact]
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.HandleAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,103 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Infrastructure;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Integration tests for EF Core provisioning repositories. Uses a real SQLite
/// database to verify that change tracking works correctly when adding child
/// entities with pre-set Guid keys to tracked aggregates.
/// </summary>
public class EfIdentityRealmRepositoryTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ProvisioningDbContext db;
private readonly EfIdentityRealmRepository repository;
public EfIdentityRealmRepositoryTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions<ProvisioningDbContext> options = new DbContextOptionsBuilder<ProvisioningDbContext>()
.UseSqlite(connection)
.Options;
db = new ProvisioningDbContext(options);
db.Database.EnsureCreated();
repository = new EfIdentityRealmRepository(db);
}
[Fact]
public async Task UpdateAsync_AddingIdentityProvider_DoesNotThrowConcurrencyException()
{
// Arrange — Create and persist a realm, then load it back.
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
IdentityRealm realm = IdentityRealm.Create(environmentId, "test-realm", clusterId);
await repository.AddAsync(realm);
IdentityRealm? loaded = await repository.GetByIdAsync(realm.Id);
loaded.Should().NotBeNull();
// Act — Add a new identity provider to the tracked realm and save.
// Before the fix, Update() would mark the new provider as Modified
// instead of Added, causing DbUpdateConcurrencyException.
RealmIdentityProvider idp = RealmIdentityProvider.Create(
"google", "Google", IdentityProviderType.Google);
loaded!.AddIdentityProvider(idp);
Func<Task> act = async () => await repository.UpdateAsync(loaded);
// Assert — Should save without throwing.
await act.Should().NotThrowAsync<DbUpdateConcurrencyException>();
IdentityRealm? reloaded = await repository.GetByIdAsync(realm.Id);
reloaded!.IdentityProviders.Should().HaveCount(1);
reloaded.IdentityProviders[0].Alias.Should().Be("google");
}
[Fact]
public async Task UpdateAsync_AddingOrganization_DoesNotThrowConcurrencyException()
{
// Arrange — Create and persist a realm.
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
IdentityRealm realm = IdentityRealm.Create(environmentId, "org-realm", clusterId);
await repository.AddAsync(realm);
IdentityRealm? loaded = await repository.GetByIdAsync(realm.Id);
loaded.Should().NotBeNull();
// Act — Add a new organization and save.
RealmOrganization org = RealmOrganization.Create("Engineering", "Dev teams");
loaded!.AddOrganization(org);
Func<Task> act = async () => await repository.UpdateAsync(loaded);
// Assert
await act.Should().NotThrowAsync<DbUpdateConcurrencyException>();
IdentityRealm? reloaded = await repository.GetByIdAsync(realm.Id);
reloaded!.Organizations.Should().HaveCount(1);
reloaded.Organizations[0].Name.Should().Be("Engineering");
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,255 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.MinioInstances;
using EntKube.Provisioning.Features.MinioInstances.AdoptMinioInstance;
using EntKube.Provisioning.Features.PostgresClusters;
using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for MinIO adoption and its integration with CNPG cluster provisioning.
/// MinIO must be adopted before CNPG clusters can be created — the create flow
/// resolves the MinIO details by reference (cluster ID) rather than raw values.
/// </summary>
public class MinioAdoptionTests
{
private readonly InMemoryMinioInstanceRepository minioRepository;
private readonly InMemoryPostgresClusterRepository pgRepository;
private readonly Mock<IMinioClient> minioClient;
private readonly Mock<ICnpgClusterClient> cnpgClient;
public MinioAdoptionTests()
{
minioRepository = new InMemoryMinioInstanceRepository();
pgRepository = new InMemoryPostgresClusterRepository();
minioClient = new Mock<IMinioClient>();
cnpgClient = new Mock<ICnpgClusterClient>();
}
// ─── Adopt MinIO ──────────────────────────────────────────────────────────
[Fact]
public async Task AdoptMinio_WithDiscoveredTenant_CreatesRunningInstance()
{
// Arrange — The K8s cluster has an existing MinIO tenant running.
minioClient.Setup(c => c.DiscoverMinioAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DiscoveredMinioInstance(
Name: "platform-minio",
Namespace: "minio-system",
Endpoint: "minio.minio-system.svc:9000",
ConsoleEndpoint: "minio-console.minio-system.svc:9001",
CredentialsSecret: "minio-root-credentials",
TotalCapacity: "1Ti",
UsedCapacity: "250Gi",
Buckets: new List<string> { "loki-logs", "velero-backups" }));
AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object);
AdoptMinioInstanceRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — MinIO adopted and stored with all discovered details.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
IReadOnlyList<MinioInstance> all = await minioRepository.GetAllAsync();
all.Should().HaveCount(1);
all[0].Name.Should().Be("platform-minio");
all[0].Endpoint.Should().Be("minio.minio-system.svc:9000");
all[0].Status.Should().Be(MinioInstanceStatus.Running);
all[0].Buckets.Should().Contain("loki-logs");
all[0].Buckets.Should().Contain("velero-backups");
}
[Fact]
public async Task AdoptMinio_NoTenantFound_ReturnsFailure()
{
// Arrange — No MinIO tenant on this cluster.
minioClient.Setup(c => c.DiscoverMinioAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((DiscoveredMinioInstance?)null);
AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object);
AdoptMinioInstanceRequest request = new(Guid.NewGuid(), "config", "ctx");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("No MinIO");
}
[Fact]
public async Task AdoptMinio_AlreadyAdopted_ReturnsExistingId()
{
// Arrange — MinIO was already adopted for this cluster.
Guid clusterId = Guid.NewGuid();
MinioInstance existing = MinioInstance.Adopt(
clusterId, "platform-minio", "minio-system",
"minio:9000", null, "secret", "500Gi", null, null);
await minioRepository.AddAsync(existing);
minioClient.Setup(c => c.DiscoverMinioAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DiscoveredMinioInstance(
"platform-minio", "minio-system", "minio:9000",
null, "secret", "500Gi", null, null));
AdoptMinioInstanceHandler handler = new(minioRepository, minioClient.Object);
AdoptMinioInstanceRequest request = new(clusterId, "config", "ctx");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Returns the existing ID without creating a duplicate.
result.IsSuccess.Should().BeTrue();
result.Value.Should().Be(existing.Id);
IReadOnlyList<MinioInstance> all = await minioRepository.GetAllAsync();
all.Should().HaveCount(1);
}
// ─── CNPG Create with MinIO Reference ─────────────────────────────────────
[Fact]
public async Task CreatePostgresCluster_WithMinioInstanceId_ResolvesMinioConfig()
{
// Arrange — An adopted MinIO instance exists. When creating a CNPG cluster,
// we reference it by the cluster ID instead of providing raw details.
Guid clusterId = Guid.NewGuid();
MinioInstance minio = MinioInstance.Adopt(
clusterId, "platform-minio", "minio-system",
"minio.minio-system.svc:9000", null,
"minio-root-credentials", "1Ti", null,
new List<string> { "cnpg-backups" });
await minioRepository.AddAsync(minio);
CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository);
CreatePostgresClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: clusterId,
Name: "prod-db",
Namespace: "cnpg-system",
PostgresVersion: "16.4",
Instances: 3,
StorageSize: "50Gi",
KubeConfig: "config",
ContextName: "ctx",
MinioEndpoint: null,
MinioBucket: "cnpg-backups",
MinioCredentialsSecret: null);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — MinIO details resolved from the adopted instance.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<PostgresCluster> clusters = await pgRepository.GetAllAsync();
clusters.Should().HaveCount(1);
clusters[0].BackupConfig!.MinioEndpoint.Should().Be("minio.minio-system.svc:9000");
clusters[0].BackupConfig.CredentialsSecret.Should().Be("minio-root-credentials");
clusters[0].BackupConfig.Bucket.Should().Be("cnpg-backups");
cnpgClient.Verify(c => c.CreateClusterAsync(
"config", "ctx",
It.Is<CnpgClusterSpec>(s =>
s.MinioEndpoint == "minio.minio-system.svc:9000" &&
s.MinioCredentialsSecret == "minio-root-credentials"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreatePostgresCluster_NoMinioAdopted_CreatesWithoutBackup()
{
// Arrange — No MinIO adopted for this cluster, and no explicit endpoint provided.
// The cluster should still be created, just without backup configuration.
CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository);
CreatePostgresClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "prod-db",
Namespace: "cnpg-system",
PostgresVersion: "16.4",
Instances: 3,
StorageSize: "50Gi",
KubeConfig: "config",
ContextName: "ctx",
MinioEndpoint: null,
MinioBucket: "backups",
MinioCredentialsSecret: null);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Cluster is created successfully without backup.
result.IsSuccess.Should().BeTrue();
}
[Fact]
public async Task CreatePostgresCluster_WithExplicitMinioDetails_StillWorks()
{
// Arrange — Explicit MinIO details provided (bypass adoption lookup).
CreatePostgresClusterHandler handler = new(pgRepository, cnpgClient.Object, minioRepository);
CreatePostgresClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "prod-db",
Namespace: "cnpg-system",
PostgresVersion: "16.4",
Instances: 3,
StorageSize: "50Gi",
KubeConfig: "config",
ContextName: "ctx",
MinioEndpoint: "custom-minio:9000",
MinioBucket: "custom-bucket",
MinioCredentialsSecret: "custom-secret");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Explicit values used directly.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<PostgresCluster> clusters = await pgRepository.GetAllAsync();
clusters[0].BackupConfig!.MinioEndpoint.Should().Be("custom-minio:9000");
clusters[0].BackupConfig.Bucket.Should().Be("custom-bucket");
clusters[0].BackupConfig.CredentialsSecret.Should().Be("custom-secret");
}
}

View File

@@ -0,0 +1,256 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.MinioTenants;
using EntKube.Provisioning.Features.MinioTenants.AdoptMinioTenants;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for MinIO Tenant adoption — discovering existing MinIO Tenant CRDs on a
/// Kubernetes cluster and importing them into the platform's domain so they can be
/// viewed, managed, and monitored. This follows the same pattern as CNPG cluster
/// adoption: the Provisioning service scans the cluster, finds what's already running,
/// and creates domain aggregates for each tenant.
/// </summary>
public class MinioTenantAdoptionTests
{
private readonly InMemoryMinioTenantRepository tenantRepository;
private readonly Mock<IMinioTenantClient> tenantClient;
public MinioTenantAdoptionTests()
{
tenantRepository = new InMemoryMinioTenantRepository();
tenantClient = new Mock<IMinioTenantClient>();
}
// ─── Adopt Tenants ──────────────────────────────────────────────────────────
[Fact]
public async Task AdoptTenants_WithExistingTenants_ImportsThemAsRunning()
{
// Arrange — The Kubernetes cluster has two MinIO tenants already running:
// one for platform storage and one for application data. Neither of them
// exists in our repository yet, so adoption should discover and import both.
Guid clusterId = Guid.NewGuid();
tenantClient.Setup(c => c.DiscoverTenantsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMinioTenant>
{
new(
Name: "platform-storage",
Namespace: "minio-platform",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
},
CurrentState: "Initialized",
Buckets: new List<string> { "cnpg-backups", "loki-logs" }),
new(
Name: "app-storage",
Namespace: "minio-apps",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 2, VolumesPerServer: 4, StorageSize: "50Gi", StorageClass: "standard")
},
CurrentState: "Initialized",
Buckets: new List<string> { "user-uploads" })
});
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
AdoptMinioTenantsRequest request = new(
ClusterId: clusterId,
KubeConfig: "kubeconfig-content",
ContextName: "prod-context");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Both tenants adopted with correct details.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
all.Should().HaveCount(2);
MinioTenant platformTenant = all.First(t => t.Name == "platform-storage");
platformTenant.ClusterId.Should().Be(clusterId);
platformTenant.Namespace.Should().Be("minio-platform");
platformTenant.Status.Should().Be(MinioTenantStatus.Running);
platformTenant.Pools.Should().HaveCount(1);
platformTenant.Pools[0].Servers.Should().Be(4);
platformTenant.Pools[0].StorageSize.Should().Be("100Gi");
MinioTenant appTenant = all.First(t => t.Name == "app-storage");
appTenant.Status.Should().Be(MinioTenantStatus.Running);
}
[Fact]
public async Task AdoptTenants_NoTenantsOnCluster_ReturnsEmptyList()
{
// Arrange — The MinIO operator is installed but no tenants are deployed yet.
tenantClient.Setup(c => c.DiscoverTenantsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMinioTenant>());
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Success with empty list, not a failure.
result.IsSuccess.Should().BeTrue();
result.Value.Should().BeEmpty();
}
[Fact]
public async Task AdoptTenants_AlreadyAdopted_DoesNotDuplicate()
{
// Arrange — One tenant was already adopted. Running adoption again should
// skip it and only import new tenants, not create duplicates.
Guid clusterId = Guid.NewGuid();
MinioTenant existing = MinioTenant.Adopt(
clusterId: clusterId,
name: "platform-storage",
ns: "minio-platform",
pools: new List<MinioTenantPool>
{
new(4, 4, "100Gi", "ceph-block")
});
await tenantRepository.AddAsync(existing);
tenantClient.Setup(c => c.DiscoverTenantsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMinioTenant>
{
new(
Name: "platform-storage",
Namespace: "minio-platform",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
},
CurrentState: "Initialized",
Buckets: new List<string> { "cnpg-backups" }),
new(
Name: "new-tenant",
Namespace: "minio-new",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 2, VolumesPerServer: 2, StorageSize: "50Gi", StorageClass: "standard")
},
CurrentState: "Initialized",
Buckets: new List<string>())
});
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
AdoptMinioTenantsRequest request = new(clusterId, "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Only one new tenant adopted, the existing one was skipped.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
all.Should().HaveCount(2);
}
[Fact]
public async Task AdoptTenants_DegradedTenant_MarkedDegraded()
{
// Arrange — A tenant with state "Decommissioning" or some non-healthy
// state should be imported but marked as Degraded, not Running.
tenantClient.Setup(c => c.DiscoverTenantsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMinioTenant>
{
new(
Name: "sick-tenant",
Namespace: "minio-sick",
Pools: new List<DiscoveredMinioPool>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard")
},
CurrentState: "Decommissioning",
Buckets: new List<string>())
});
AdoptMinioTenantsHandler handler = new(tenantRepository, tenantClient.Object);
AdoptMinioTenantsRequest request = new(Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
all[0].Status.Should().Be(MinioTenantStatus.Degraded);
}
// ─── Domain: MinioTenant.Adopt ──────────────────────────────────────────────
[Fact]
public void Adopt_WithValidConfig_CreatesRunningTenant()
{
// Arrange — We discover a healthy tenant with known pools.
List<MinioTenantPool> pools = new()
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
};
// Act
MinioTenant tenant = MinioTenant.Adopt(
clusterId: Guid.NewGuid(),
name: "discovered-tenant",
ns: "minio-discovered",
pools: pools);
// Assert — Adopted tenants start as Running since they're already serving traffic.
tenant.Id.Should().NotBe(Guid.Empty);
tenant.Name.Should().Be("discovered-tenant");
tenant.Namespace.Should().Be("minio-discovered");
tenant.Status.Should().Be(MinioTenantStatus.Running);
tenant.Pools.Should().HaveCount(1);
tenant.Pools[0].Servers.Should().Be(4);
}
[Fact]
public void Adopt_WithEmptyName_Throws()
{
// A tenant with no name is not valid — discovery must provide a name.
Action act = () => MinioTenant.Adopt(
Guid.NewGuid(), "", "ns",
new List<MinioTenantPool> { new(1, 1, "10Gi", "standard") });
act.Should().Throw<ArgumentException>();
}
}

View File

@@ -0,0 +1,209 @@
using EntKube.Provisioning.Features.MinioTenants;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for parsing MinIO tenant credentials from the various secret formats
/// used by different MinIO Operator versions. The operator has changed its
/// credential storage format over time:
///
/// - Legacy (v4): separate "accesskey" and "secretkey" keys in the K8s secret
/// - v5+: a single "config.env" key containing export statements
/// - Console: "CONSOLE_ACCESS_KEY" and "CONSOLE_SECRET_KEY" keys
///
/// The credential parser must handle all formats so mc commands can authenticate
/// against tenants provisioned by any operator version.
/// </summary>
public class MinioTenantCredentialParsingTests
{
// ─── config.env format (MinIO Operator v5+) ────────────────────────────
[Fact]
public void ParseConfigEnv_WithQuotedValues_ExtractsCredentials()
{
// The most common format — double-quoted values with export prefix.
string configEnv = """
export MINIO_ROOT_USER="minio-admin"
export MINIO_ROOT_PASSWORD="super-secret-password"
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio-admin");
result!.Value.secretKey.Should().Be("super-secret-password");
}
[Fact]
public void ParseConfigEnv_WithSingleQuotedValues_ExtractsCredentials()
{
// Some installations use single quotes.
string configEnv = """
export MINIO_ROOT_USER='admin'
export MINIO_ROOT_PASSWORD='p@ssw0rd'
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("admin");
result!.Value.secretKey.Should().Be("p@ssw0rd");
}
[Fact]
public void ParseConfigEnv_WithUnquotedValues_ExtractsCredentials()
{
// Unquoted values — less common but valid.
string configEnv = """
export MINIO_ROOT_USER=minio
export MINIO_ROOT_PASSWORD=minio123
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio");
result!.Value.secretKey.Should().Be("minio123");
}
[Fact]
public void ParseConfigEnv_WithAdditionalEnvVars_IgnoresExtras()
{
// The config.env may contain other env vars beyond credentials.
// We should extract only the root user/password.
string configEnv = """
export MINIO_ROOT_USER="minio"
export MINIO_ROOT_PASSWORD="secret"
export MINIO_BROWSER="on"
export MINIO_STORAGE_CLASS_STANDARD="EC:2"
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio");
result!.Value.secretKey.Should().Be("secret");
}
[Fact]
public void ParseConfigEnv_MissingUser_ReturnsNull()
{
// If the user key is missing, we can't authenticate.
string configEnv = """
export MINIO_ROOT_PASSWORD="secret"
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().BeNull();
}
[Fact]
public void ParseConfigEnv_MissingPassword_ReturnsNull()
{
// If the password key is missing, we can't authenticate.
string configEnv = """
export MINIO_ROOT_USER="admin"
""";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().BeNull();
}
[Fact]
public void ParseConfigEnv_EmptyString_ReturnsNull()
{
// An empty config.env should not crash — just return null.
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials("");
result.Should().BeNull();
}
[Fact]
public void ParseConfigEnv_WithWhitespaceAndEmptyLines_ExtractsCredentials()
{
// Real config files often have trailing newlines and inconsistent whitespace.
string configEnv = "\n export MINIO_ROOT_USER=\"root-user\" \n\n export MINIO_ROOT_PASSWORD=\"root-pass\" \n\n";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("root-user");
result!.Value.secretKey.Should().Be("root-pass");
}
[Fact]
public void ParseConfigEnv_SingleLine_ExtractsCredentials()
{
// Some environments produce config.env as a single line with all exports
// separated by spaces. This happens with MinIO Operator v6 in some configurations.
string configEnv = """export MINIO_ROOT_USER="minio-admin" export MINIO_ROOT_PASSWORD="sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7" export MINIO_BROWSER="on" export MINIO_PROMETHEUS_AUTH_TYPE="public" """;
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio-admin");
result!.Value.secretKey.Should().Be("sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7");
}
[Fact]
public void ParseConfigEnv_RealWorldV6Format_ExtractsCredentials()
{
// Exact format from a real MinIO Operator v6 installation.
string configEnv = "export MINIO_ROOT_USER=\"minio-admin\"\nexport MINIO_ROOT_PASSWORD=\"sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7\"\nexport MINIO_BROWSER=\"on\"\nexport MINIO_PROMETHEUS_AUTH_TYPE=\"public\"\n";
// Act
(string accessKey, string secretKey)? result = KubernetesMinioTenantClient.ParseConfigEnvCredentials(configEnv);
// Assert
result.Should().NotBeNull();
result!.Value.accessKey.Should().Be("minio-admin");
result!.Value.secretKey.Should().Be("sXmLAnMLX3yGoDrKOxUF1C992FWfDBQjVh6HJJU7");
}
}

View File

@@ -0,0 +1,308 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.MinioTenants;
using EntKube.Provisioning.Features.MinioTenants.CreateMinioTenant;
using EntKube.Provisioning.Features.MinioTenants.ConfigureMinioTenant;
using EntKube.Provisioning.Features.MinioTenants.DeleteMinioTenant;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for MinIO Tenant provisioning — the full lifecycle of creating, configuring,
/// and deleting MinIO tenants on a Kubernetes cluster. Unlike MinIO adoption (which
/// discovers existing tenants), this provisions new tenants from scratch via the
/// MinIO Operator's Tenant CRD.
/// </summary>
public class MinioTenantProvisioningTests
{
private readonly InMemoryMinioTenantRepository tenantRepository;
private readonly Mock<IMinioTenantClient> tenantClient;
public MinioTenantProvisioningTests()
{
tenantRepository = new InMemoryMinioTenantRepository();
tenantClient = new Mock<IMinioTenantClient>();
}
// ─── Create Tenant ──────────────────────────────────────────────────────────
[Fact]
public async Task CreateTenant_WithValidConfig_ProvisionsTenantOnCluster()
{
// Arrange — A platform admin wants a new MinIO tenant with 4 servers,
// each with 4 disks of 100Gi, using the ceph-block storage class.
tenantClient.Setup(c => c.ApplyTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
CreateMinioTenantRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "kubeconfig-content",
ContextName: "prod-context",
Name: "platform-storage",
Namespace: "minio-tenant-1",
Pools: new List<PoolSpec>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
});
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Tenant created in domain and applied to K8s cluster.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
all.Should().HaveCount(1);
all[0].Name.Should().Be("platform-storage");
all[0].Namespace.Should().Be("minio-tenant-1");
all[0].Status.Should().Be(MinioTenantStatus.Provisioning);
all[0].Pools.Should().HaveCount(1);
all[0].Pools[0].Servers.Should().Be(4);
all[0].Pools[0].VolumesPerServer.Should().Be(4);
// Verify the K8s client was called to apply the Tenant CRD.
tenantClient.Verify(c => c.ApplyTenantAsync(
"kubeconfig-content", "prod-context",
It.Is<MinioTenant>(t => t.Name == "platform-storage"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateTenant_WithMultiplePools_AllPoolsApplied()
{
// Arrange — Heterogeneous pools: fast NVMe for hot data, HDD for warm.
tenantClient.Setup(c => c.ApplyTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
CreateMinioTenantRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx",
Name: "multi-tier",
Namespace: "minio-multi",
Pools: new List<PoolSpec>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "nvme-fast"),
new(Servers: 2, VolumesPerServer: 8, StorageSize: "500Gi", StorageClass: "hdd-bulk")
});
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
all[0].Pools.Should().HaveCount(2);
}
[Fact]
public async Task CreateTenant_WithInvalidConfig_ReturnsFailure()
{
// Arrange — Zero servers is not valid.
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
CreateMinioTenantRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx",
Name: "bad-tenant",
Namespace: "minio-bad",
Pools: new List<PoolSpec>
{
new(Servers: 0, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard")
});
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Domain validation catches the bad config.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("server");
}
[Fact]
public async Task CreateTenant_KubernetesClientFails_ReturnsFailure()
{
// Arrange — Network issue or RBAC problem prevents applying the CRD.
tenantClient.Setup(c => c.ApplyTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Forbidden: RBAC denied"));
CreateMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
CreateMinioTenantRequest request = new(
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx",
Name: "tenant",
Namespace: "minio-ns",
Pools: new List<PoolSpec>
{
new(Servers: 4, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "standard")
});
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Failure reported without tenant being persisted.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("RBAC");
IReadOnlyList<MinioTenant> all = await tenantRepository.GetAllAsync();
all.Should().BeEmpty();
}
// ─── Configure Tenant ──────────────────────────────────────────────────────
[Fact]
public async Task ConfigureTenant_UpdatesPools_AndReapplies()
{
// Arrange — Existing tenant needs scale-out: from 4 to 8 servers.
MinioTenant existing = MinioTenant.Create(
Guid.NewGuid(), "platform-storage", "minio-tenant-1",
new List<MinioTenantPool> { new(4, 4, "100Gi", "ceph-block") });
existing.MarkRunning();
await tenantRepository.AddAsync(existing);
tenantClient.Setup(c => c.ApplyTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<MinioTenant>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
ConfigureMinioTenantRequest request = new(
TenantId: existing.Id,
KubeConfig: "config",
ContextName: "ctx",
Pools: new List<PoolSpec>
{
new(Servers: 8, VolumesPerServer: 4, StorageSize: "100Gi", StorageClass: "ceph-block")
});
// Act
Result result = await handler.HandleAsync(request);
// Assert — Pools updated, status back to Provisioning, K8s re-applied.
result.IsSuccess.Should().BeTrue();
MinioTenant? updated = await tenantRepository.GetByIdAsync(existing.Id);
updated.Should().NotBeNull();
updated!.Pools[0].Servers.Should().Be(8);
updated.Status.Should().Be(MinioTenantStatus.Provisioning);
tenantClient.Verify(c => c.ApplyTenantAsync(
"config", "ctx",
It.Is<MinioTenant>(t => t.Pools[0].Servers == 8),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ConfigureTenant_NotFound_ReturnsFailure()
{
// Arrange
ConfigureMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
ConfigureMinioTenantRequest request = new(
TenantId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx",
Pools: new List<PoolSpec> { new(4, 4, "100Gi", "standard") });
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
// ─── Delete Tenant ──────────────────────────────────────────────────────────
[Fact]
public async Task DeleteTenant_RemovesFromClusterAndMarksDecommissioned()
{
// Arrange — Platform admin tears down a tenant.
MinioTenant existing = MinioTenant.Create(
Guid.NewGuid(), "old-tenant", "minio-old",
new List<MinioTenantPool> { new(4, 4, "100Gi", "standard") });
existing.MarkRunning();
await tenantRepository.AddAsync(existing);
tenantClient.Setup(c => c.DeleteTenantAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
DeleteMinioTenantRequest request = new(
TenantId: existing.Id,
KubeConfig: "config",
ContextName: "ctx");
// Act
Result result = await handler.HandleAsync(request);
// Assert — Marked decommissioned and deleted from K8s.
result.IsSuccess.Should().BeTrue();
MinioTenant? tenant = await tenantRepository.GetByIdAsync(existing.Id);
tenant.Should().NotBeNull();
tenant!.Status.Should().Be(MinioTenantStatus.Decommissioned);
tenantClient.Verify(c => c.DeleteTenantAsync(
"config", "ctx", "old-tenant", "minio-old",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DeleteTenant_NotFound_ReturnsFailure()
{
// Arrange
DeleteMinioTenantHandler handler = new(tenantRepository, tenantClient.Object);
DeleteMinioTenantRequest request = new(Guid.NewGuid(), "config", "ctx");
// Act
Result result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,642 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.MongoClusters;
using EntKube.Provisioning.Features.MongoClusters.AdoptMongoCluster;
using EntKube.Provisioning.Features.MongoClusters.CreateMongoCluster;
using EntKube.Provisioning.Features.MongoClusters.AddDatabase;
using EntKube.Provisioning.Features.MongoClusters.UpgradeMongoCluster;
using EntKube.Provisioning.Features.MongoClusters.ConfigureMongoCluster;
using EntKube.Provisioning.Features.MongoClusters.RestoreBackup;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for all MongoDB cluster management features. Each test verifies that
/// the handler correctly orchestrates domain logic + K8s client interactions.
/// </summary>
public class MongoClusterFeatureTests
{
private readonly InMemoryMongoClusterRepository repository;
private readonly InMemoryMinioInstanceRepository minioRepository;
private readonly Mock<IMongoClusterClient> mongoClient;
public MongoClusterFeatureTests()
{
repository = new InMemoryMongoClusterRepository();
minioRepository = new InMemoryMinioInstanceRepository();
mongoClient = new Mock<IMongoClusterClient>();
}
// ─── Create Cluster ───────────────────────────────────────────────────────
[Fact]
public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient()
{
// Arrange — A platform admin wants a new 3-member MongoDB replica set
// with mongodump backups to MinIO.
CreateMongoClusterHandler handler = new(repository, mongoClient.Object, minioRepository);
CreateMongoClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "platform-mongo",
Namespace: "mongodb-system",
MongoVersion: "7.0.12",
Members: 3,
StorageSize: "50Gi",
KubeConfig: "apiVersion: v1\nkind: Config",
ContextName: "prod-ctx",
MinioEndpoint: "minio.minio-system.svc:9000",
MinioBucket: "mongo-backups",
MinioCredentialsSecret: "minio-mongo-creds");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Cluster created in repository and K8s client called.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
IReadOnlyList<MongoCluster> clusters = await repository.GetAllAsync();
clusters.Should().HaveCount(1);
clusters[0].Name.Should().Be("platform-mongo");
clusters[0].Status.Should().Be(MongoClusterStatus.Provisioning);
mongoClient.Verify(c => c.CreateClusterAsync(
"apiVersion: v1\nkind: Config",
"prod-ctx",
It.Is<MongoClusterSpec>(s =>
s.Name == "platform-mongo" &&
s.MongoVersion == "7.0.12" &&
s.Members == 3 &&
s.MinioEndpoint == "minio.minio-system.svc:9000"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateCluster_WithMissingName_ReturnsFailure()
{
// Arrange
CreateMongoClusterHandler handler = new(repository, mongoClient.Object, minioRepository);
CreateMongoClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "",
Namespace: "ns",
MongoVersion: "7.0.12",
Members: 3,
StorageSize: "50Gi",
KubeConfig: "config",
ContextName: "ctx",
MinioEndpoint: "minio:9000",
MinioBucket: "bucket",
MinioCredentialsSecret: "secret");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("name");
}
// ─── Adopt Cluster ────────────────────────────────────────────────────────
[Fact]
public async Task AdoptCluster_WithDiscoveredClusters_ImportsAll()
{
// Arrange — The K8s cluster has 2 existing MongoDBCommunity clusters.
mongoClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>
{
new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
new List<string> { "app_db", "analytics_db" },
"minio.minio-system.svc:9000", "mongo-backups", "minio-creds"),
new("staging-mongo", "mongodb-staging", "6.0.16", 1, "20Gi",
new List<string> { "staging_db" },
"minio.minio-system.svc:9000", "mongo-backups-staging", "minio-creds")
});
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
AdoptMongoClustersRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Both clusters adopted and stored in Running state.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
IReadOnlyList<MongoCluster> all = await repository.GetAllAsync();
all.Should().HaveCount(2);
all[0].Status.Should().Be(MongoClusterStatus.Running);
all[0].Databases.Should().Contain("app_db");
all[1].Databases.Should().Contain("staging_db");
}
[Fact]
public async Task AdoptCluster_WithNoClusters_ReturnsEmptyList()
{
// Arrange — No MongoDBCommunity clusters found on K8s.
mongoClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>());
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().BeEmpty();
}
// ─── Add Database ─────────────────────────────────────────────────────────
[Fact]
public async Task AddDatabase_ToRunningCluster_CreatesDatabaseOnK8s()
{
// Arrange — A running MongoDB cluster exists.
MongoCluster mongoCluster = MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
"minio:9000", "bucket", "secret");
mongoCluster.MarkRunning();
await repository.AddAsync(mongoCluster);
AddMongoDatabaseHandler handler = new(repository, mongoClient.Object);
AddMongoDatabaseRequest request = new(
MongoClusterId: mongoCluster.Id,
DatabaseName: "new_app_db",
OwnerRole: "app_user",
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id);
updated!.Databases.Should().Contain("new_app_db");
mongoClient.Verify(c => c.CreateDatabaseAsync(
"config", "ctx", "prod-mongo", "mongodb-system", "new_app_db", "app_user",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task AddDatabase_ToNonExistentCluster_ReturnsFailure()
{
// Arrange
AddMongoDatabaseHandler handler = new(repository, mongoClient.Object);
AddMongoDatabaseRequest request = new(Guid.NewGuid(), "db", "user", "config", "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
// ─── Upgrade Cluster ──────────────────────────────────────────────────────
[Fact]
public async Task UpgradeCluster_WithValidVersion_InitiatesUpgradeOnK8s()
{
// Arrange — A running cluster at version 6.0.16.
MongoCluster mongoCluster = MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "6.0.16", 3, "50Gi",
"minio:9000", "bucket", "secret");
mongoCluster.MarkRunning();
await repository.AddAsync(mongoCluster);
mongoClient.Setup(c => c.GetAvailableVersionsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "6.0.16", "6.0.17", "7.0.12", "7.0.14" });
UpgradeMongoClusterHandler handler = new(repository, mongoClient.Object);
UpgradeMongoClusterRequest request = new(
MongoClusterId: mongoCluster.Id,
TargetVersion: "7.0.12",
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert — Domain transitions to Upgrading, K8s client called.
result.IsSuccess.Should().BeTrue();
MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id);
updated!.Status.Should().Be(MongoClusterStatus.Upgrading);
updated.TargetVersion.Should().Be("7.0.12");
mongoClient.Verify(c => c.UpgradeClusterAsync(
"config", "ctx", "prod-mongo", "mongodb-system", "7.0.12",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpgradeCluster_ToUnavailableVersion_ReturnsFailure()
{
// Arrange — Version 99.0 doesn't exist.
MongoCluster mongoCluster = MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
"minio:9000", "bucket", "secret");
mongoCluster.MarkRunning();
await repository.AddAsync(mongoCluster);
mongoClient.Setup(c => c.GetAvailableVersionsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "7.0.12", "7.0.14" });
UpgradeMongoClusterHandler handler = new(repository, mongoClient.Object);
UpgradeMongoClusterRequest request = new(mongoCluster.Id, "99.0", "config", "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not available");
}
// ─── Configure Cluster ────────────────────────────────────────────────────
[Fact]
public async Task ConfigureCluster_ScaleMembers_UpdatesK8sAndDomain()
{
// Arrange — Scale a running cluster from 3 to 5 members.
MongoCluster mongoCluster = MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
"minio:9000", "bucket", "secret");
mongoCluster.MarkRunning();
await repository.AddAsync(mongoCluster);
ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object);
ConfigureMongoClusterRequest request = new(
MongoClusterId: mongoCluster.Id,
Members: 5,
StorageSize: null,
BackupSchedule: null,
BackupRetentionDays: null,
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id);
updated!.Members.Should().Be(5);
mongoClient.Verify(c => c.UpdateClusterAsync(
"config", "ctx",
It.Is<MongoClusterSpec>(s => s.Members == 5),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ConfigureCluster_UpdateBackupSchedule_UpdatesBackupCronJob()
{
// Arrange — Change backup schedule to every 6 hours with 30-day retention.
MongoCluster mongoCluster = MongoCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
"minio:9000", "bucket", "secret");
mongoCluster.MarkRunning();
await repository.AddAsync(mongoCluster);
ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object);
ConfigureMongoClusterRequest request = new(
MongoClusterId: mongoCluster.Id,
Members: null,
StorageSize: null,
BackupSchedule: "0 */6 * * *",
BackupRetentionDays: 30,
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
MongoCluster? updated = await repository.GetByIdAsync(mongoCluster.Id);
updated!.BackupConfig!.Schedule.Should().Be("0 */6 * * *");
updated.BackupConfig.RetentionDays.Should().Be(30);
mongoClient.Verify(c => c.ConfigureScheduledBackupAsync(
"config", "ctx", "prod-mongo", "mongodb-system", "0 */6 * * *", 30,
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ConfigureCluster_NonExistent_ReturnsFailure()
{
// Arrange
ConfigureMongoClusterHandler handler = new(repository, mongoClient.Object);
ConfigureMongoClusterRequest request = new(
Guid.NewGuid(), Members: 5, StorageSize: null,
BackupSchedule: null, BackupRetentionDays: null,
KubeConfig: "config", ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
// ─── Adopt De-duplication ─────────────────────────────────────────────────
[Fact]
public async Task AdoptCluster_WhenClusterAlreadyAdopted_SkipsDuplicate()
{
// Arrange — The platform already has a cluster adopted. Re-running
// discovery should not create a duplicate but update databases.
Guid clusterId = Guid.NewGuid();
MongoCluster existing = MongoCluster.Adopt(
Guid.NewGuid(), clusterId, "prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
"minio:9000", "mongo-backups", "minio-creds",
databases: new List<string> { "app_db" });
await repository.AddAsync(existing);
mongoClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>
{
new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
new List<string> { "app_db", "analytics_db" },
"minio:9000", "mongo-backups", "minio-creds"),
new("new-mongo", "mongodb-staging", "6.0.17", 1, "20Gi",
new List<string> { "staging_db" },
"minio:9000", "mongo-backups-staging", "minio-creds")
});
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
AdoptMongoClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Only the new cluster is adopted, the existing one is updated.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<MongoCluster> all = await repository.GetByClusterIdAsync(clusterId);
all.Should().HaveCount(2);
all.Should().ContainSingle(c => c.Name == "prod-mongo");
all.Should().ContainSingle(c => c.Name == "new-mongo");
// The existing cluster's databases should be updated.
MongoCluster? updated = all.First(c => c.Name == "prod-mongo");
updated.Databases.Should().Contain("analytics_db");
}
[Fact]
public async Task AdoptCluster_WithClusterWithoutBackup_AdoptsWithDefaults()
{
// Arrange — A MongoDBCommunity cluster with no MinIO backup configured.
mongoClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>
{
new("dev-mongo", "default", "7.0.12", 1, "10Gi",
new List<string> { "myapp" },
null, null, null)
});
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Cluster adopted with default MinIO settings.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(1);
IReadOnlyList<MongoCluster> all = await repository.GetAllAsync();
all[0].Name.Should().Be("dev-mongo");
all[0].Status.Should().Be(MongoClusterStatus.Running);
}
// ─── Restore Backup ───────────────────────────────────────────────────────
[Fact]
public async Task RestoreBackup_WithValidCluster_CallsMongoClient()
{
// Arrange — A running cluster with a backup we want to restore from.
MongoCluster mongoCluster = MongoCluster.Adopt(
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
"minio:9000", "bucket", "secret",
databases: new List<string> { "app_db" });
await repository.AddAsync(mongoCluster);
RestoreMongoBackupHandler handler = new(repository, mongoClient.Object);
RestoreMongoBackupRequest request = new(
MongoClusterId: mongoCluster.Id,
BackupName: "prod-mongo-dump-20250510120000",
RestoredClusterName: "prod-mongo-restored",
KubeConfig: "config",
ContextName: "ctx",
TargetTime: null);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — A new cluster should be created from the backup.
result.IsSuccess.Should().BeTrue();
mongoClient.Verify(c => c.RestoreBackupAsync(
"config", "ctx",
It.Is<RestoreFromMongoBackupSpec>(s =>
s.SourceClusterName == "prod-mongo" &&
s.RestoredClusterName == "prod-mongo-restored" &&
s.BackupName == "prod-mongo-dump-20250510120000" &&
s.Namespace == "mongodb-system"),
It.IsAny<CancellationToken>()), Times.Once);
// The restored cluster should be persisted as a new aggregate.
IReadOnlyList<MongoCluster> all = await repository.GetAllAsync();
all.Should().HaveCount(2);
all.Should().ContainSingle(c => c.Name == "prod-mongo-restored");
}
[Fact]
public async Task RestoreBackup_WithPointInTimeRecovery_PassesTargetTime()
{
// Arrange — Restore to a specific point in time using oplog replay.
MongoCluster mongoCluster = MongoCluster.Adopt(
Guid.NewGuid(), Guid.NewGuid(), "prod-mongo", "mongodb-system", "7.0.12", 3, "50Gi",
"minio:9000", "bucket", "secret");
await repository.AddAsync(mongoCluster);
RestoreMongoBackupHandler handler = new(repository, mongoClient.Object);
DateTimeOffset targetTime = new(2025, 5, 10, 10, 30, 0, TimeSpan.Zero);
RestoreMongoBackupRequest request = new(
MongoClusterId: mongoCluster.Id,
BackupName: null,
RestoredClusterName: "prod-mongo-pitr",
KubeConfig: "config",
ContextName: "ctx",
TargetTime: targetTime);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
mongoClient.Verify(c => c.RestoreBackupAsync(
"config", "ctx",
It.Is<RestoreFromMongoBackupSpec>(s =>
s.TargetTime == targetTime &&
s.RestoredClusterName == "prod-mongo-pitr"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RestoreBackup_SourceClusterNotFound_ReturnsFailure()
{
// Arrange
RestoreMongoBackupHandler handler = new(repository, mongoClient.Object);
RestoreMongoBackupRequest request = new(
Guid.NewGuid(), "backup-name", "restored-cluster", "config", "ctx", null);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
// ─── List Live Databases ──────────────────────────────────────────────────
[Fact]
public async Task AdoptCluster_WithLiveDatabases_IncludesAllDatabases()
{
// Arrange — Discovery from the CRD spec only finds configured databases,
// but the live MongoDB has additional databases created via shell.
mongoClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredMongoCluster>
{
new("prod-mongo", "mongodb-system", "7.0.12", 3, "100Gi",
new List<string> { "app_db" },
"minio:9000", "mongo-backups", "minio-creds")
});
// The live database listing discovers additional databases.
mongoClient.Setup(c => c.ListLiveDatabasesAsync(
It.IsAny<string>(), It.IsAny<string>(),
"prod-mongo", "mongodb-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "app_db", "analytics_db", "reporting_db" });
AdoptMongoClustersHandler handler = new(repository, mongoClient.Object);
AdoptMongoClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — All three databases should be imported.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<MongoCluster> all = await repository.GetAllAsync();
all[0].Databases.Should().HaveCount(3);
all[0].Databases.Should().Contain("app_db");
all[0].Databases.Should().Contain("analytics_db");
all[0].Databases.Should().Contain("reporting_db");
}
}

View File

@@ -0,0 +1,646 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.PostgresClusters;
using EntKube.Provisioning.Features.PostgresClusters.AdoptPostgresCluster;
using EntKube.Provisioning.Features.PostgresClusters.CreatePostgresCluster;
using EntKube.Provisioning.Features.PostgresClusters.AddDatabase;
using EntKube.Provisioning.Features.PostgresClusters.UpgradePostgresCluster;
using EntKube.Provisioning.Features.PostgresClusters.ConfigurePostgresCluster;
using EntKube.Provisioning.Features.PostgresClusters.RestoreBackup;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for all PostgreSQL cluster management features. Each test verifies that
/// the handler correctly orchestrates domain logic + K8s client interactions.
/// </summary>
public class PostgresClusterFeatureTests
{
private readonly InMemoryPostgresClusterRepository repository;
private readonly InMemoryMinioInstanceRepository minioRepository;
private readonly Mock<ICnpgClusterClient> cnpgClient;
public PostgresClusterFeatureTests()
{
repository = new InMemoryPostgresClusterRepository();
minioRepository = new InMemoryMinioInstanceRepository();
cnpgClient = new Mock<ICnpgClusterClient>();
}
// ─── Create Cluster ───────────────────────────────────────────────────────
[Fact]
public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient()
{
// Arrange — A platform admin wants a new 3-instance PostgreSQL cluster
// with WAL archiving to MinIO.
CreatePostgresClusterHandler handler = new(repository, cnpgClient.Object, minioRepository);
CreatePostgresClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "platform-db",
Namespace: "cnpg-system",
PostgresVersion: "16.4",
Instances: 3,
StorageSize: "50Gi",
KubeConfig: "apiVersion: v1\nkind: Config",
ContextName: "prod-ctx",
MinioEndpoint: "minio.minio-system.svc:9000",
MinioBucket: "cnpg-backups",
MinioCredentialsSecret: "minio-cnpg-creds");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Cluster created in repository and K8s client called.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
IReadOnlyList<PostgresCluster> clusters = await repository.GetAllAsync();
clusters.Should().HaveCount(1);
clusters[0].Name.Should().Be("platform-db");
clusters[0].Status.Should().Be(PostgresClusterStatus.Provisioning);
cnpgClient.Verify(c => c.CreateClusterAsync(
"apiVersion: v1\nkind: Config",
"prod-ctx",
It.Is<CnpgClusterSpec>(s =>
s.Name == "platform-db" &&
s.PostgresVersion == "16.4" &&
s.Instances == 3 &&
s.MinioEndpoint == "minio.minio-system.svc:9000"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateCluster_WithMissingName_ReturnsFailure()
{
// Arrange
CreatePostgresClusterHandler handler = new(repository, cnpgClient.Object, minioRepository);
CreatePostgresClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "",
Namespace: "ns",
PostgresVersion: "16.4",
Instances: 3,
StorageSize: "50Gi",
KubeConfig: "config",
ContextName: "ctx",
MinioEndpoint: "minio:9000",
MinioBucket: "bucket",
MinioCredentialsSecret: "secret");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("name");
}
// ─── Adopt Cluster ────────────────────────────────────────────────────────
[Fact]
public async Task AdoptCluster_WithDiscoveredClusters_ImportsAll()
{
// Arrange — The K8s cluster has 2 existing CNPG clusters. We adopt them
// so the platform is aware of what's already running.
cnpgClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>
{
new("prod-db", "cnpg-system", "16.4", 3, "100Gi",
new List<string> { "app_db", "analytics_db" },
"minio.minio-system.svc:9000", "pg-backups", "minio-creds", true),
new("staging-db", "cnpg-staging", "15.6", 1, "20Gi",
new List<string> { "staging_db" },
"minio.minio-system.svc:9000", "pg-backups-staging", "minio-creds", true)
});
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
AdoptPostgresClustersRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Both clusters adopted and stored in Running state.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
IReadOnlyList<PostgresCluster> all = await repository.GetAllAsync();
all.Should().HaveCount(2);
all[0].Status.Should().Be(PostgresClusterStatus.Running);
all[0].Databases.Should().Contain("app_db");
all[1].Databases.Should().Contain("staging_db");
}
[Fact]
public async Task AdoptCluster_WithNoClusters_ReturnsEmptyList()
{
// Arrange — No CNPG clusters found on the K8s cluster.
cnpgClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>());
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
result.Value.Should().BeEmpty();
}
// ─── Add Database ─────────────────────────────────────────────────────────
[Fact]
public async Task AddDatabase_ToRunningCluster_CreatesDatabaseOnK8s()
{
// Arrange — A running PostgreSQL cluster exists.
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
"minio:9000", "bucket", "secret");
pgCluster.MarkRunning();
await repository.AddAsync(pgCluster);
AddDatabaseHandler handler = new(repository, cnpgClient.Object);
AddDatabaseRequest request = new(
PostgresClusterId: pgCluster.Id,
DatabaseName: "new_app_db",
OwnerRole: "app_user",
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id);
updated!.Databases.Should().Contain("new_app_db");
cnpgClient.Verify(c => c.CreateDatabaseAsync(
"config", "ctx", "prod-db", "cnpg-system", "new_app_db", "app_user",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task AddDatabase_ToNonExistentCluster_ReturnsFailure()
{
// Arrange
AddDatabaseHandler handler = new(repository, cnpgClient.Object);
AddDatabaseRequest request = new(Guid.NewGuid(), "db", "user", "config", "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
// ─── Upgrade Cluster ──────────────────────────────────────────────────────
[Fact]
public async Task UpgradeCluster_WithValidVersion_InitiatesUpgradeOnK8s()
{
// Arrange — A running cluster at version 15.6.
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "15.6", 3, "50Gi",
"minio:9000", "bucket", "secret");
pgCluster.MarkRunning();
await repository.AddAsync(pgCluster);
cnpgClient.Setup(c => c.GetAvailableVersionsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "15.6", "15.7", "16.4", "16.5" });
UpgradePostgresClusterHandler handler = new(repository, cnpgClient.Object);
UpgradePostgresClusterRequest request = new(
PostgresClusterId: pgCluster.Id,
TargetVersion: "16.4",
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert — Domain transitions to Upgrading, K8s client called.
result.IsSuccess.Should().BeTrue();
PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id);
updated!.Status.Should().Be(PostgresClusterStatus.Upgrading);
updated.TargetVersion.Should().Be("16.4");
cnpgClient.Verify(c => c.UpgradeClusterAsync(
"config", "ctx", "prod-db", "cnpg-system", "16.4",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpgradeCluster_ToUnavailableVersion_ReturnsFailure()
{
// Arrange — Version 99.0 doesn't exist.
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
"minio:9000", "bucket", "secret");
pgCluster.MarkRunning();
await repository.AddAsync(pgCluster);
cnpgClient.Setup(c => c.GetAvailableVersionsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "16.4", "16.5" });
UpgradePostgresClusterHandler handler = new(repository, cnpgClient.Object);
UpgradePostgresClusterRequest request = new(pgCluster.Id, "99.0", "config", "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not available");
}
// ─── Configure Cluster ────────────────────────────────────────────────────
[Fact]
public async Task ConfigureCluster_ScaleInstances_UpdatesK8sAndDomain()
{
// Arrange — Scale a running cluster from 3 to 5 instances.
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
"minio:9000", "bucket", "secret");
pgCluster.MarkRunning();
await repository.AddAsync(pgCluster);
ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object);
ConfigurePostgresClusterRequest request = new(
PostgresClusterId: pgCluster.Id,
Instances: 5,
StorageSize: null,
BackupSchedule: null,
BackupRetentionDays: null,
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id);
updated!.Instances.Should().Be(5);
cnpgClient.Verify(c => c.UpdateClusterAsync(
"config", "ctx",
It.Is<CnpgClusterSpec>(s => s.Instances == 5),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ConfigureCluster_UpdateBackupSchedule_UpdatesScheduledBackup()
{
// Arrange — Change backup schedule to every 6 hours with 30-day retention.
PostgresCluster pgCluster = PostgresCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
"minio:9000", "bucket", "secret");
pgCluster.MarkRunning();
await repository.AddAsync(pgCluster);
ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object);
ConfigurePostgresClusterRequest request = new(
PostgresClusterId: pgCluster.Id,
Instances: null,
StorageSize: null,
BackupSchedule: "0 */6 * * *",
BackupRetentionDays: 30,
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
PostgresCluster? updated = await repository.GetByIdAsync(pgCluster.Id);
updated!.BackupConfig!.Schedule.Should().Be("0 */6 * * *");
updated.BackupConfig.RetentionDays.Should().Be(30);
cnpgClient.Verify(c => c.ConfigureScheduledBackupAsync(
"config", "ctx", "prod-db", "cnpg-system", "0 */6 * * *", 30,
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ConfigureCluster_NonExistent_ReturnsFailure()
{
// Arrange
ConfigurePostgresClusterHandler handler = new(repository, cnpgClient.Object);
ConfigurePostgresClusterRequest request = new(
Guid.NewGuid(), Instances: 5, StorageSize: null,
BackupSchedule: null, BackupRetentionDays: null,
KubeConfig: "config", ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
// ─── Adopt De-duplication ─────────────────────────────────────────────────
[Fact]
public async Task AdoptCluster_WhenClusterAlreadyAdopted_SkipsDuplicate()
{
// Arrange — The platform already has a cluster with the same name and
// namespace adopted. Re-running discovery should not create a duplicate.
Guid clusterId = Guid.NewGuid();
PostgresCluster existing = PostgresCluster.Adopt(
Guid.NewGuid(), clusterId, "prod-db", "cnpg-system", "16.4", 3, "100Gi",
"minio:9000", "pg-backups", "minio-creds",
databases: new List<string> { "app_db" });
await repository.AddAsync(existing);
cnpgClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>
{
new("prod-db", "cnpg-system", "16.4", 3, "100Gi",
new List<string> { "app_db", "analytics_db" },
"minio:9000", "pg-backups", "minio-creds", true),
new("new-db", "cnpg-staging", "15.8", 1, "20Gi",
new List<string> { "staging_db" },
"minio:9000", "pg-backups-staging", "minio-creds", true)
});
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
AdoptPostgresClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Only the new cluster is adopted, the existing one is updated.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<PostgresCluster> all = await repository.GetByClusterIdAsync(clusterId);
all.Should().HaveCount(2);
all.Should().ContainSingle(c => c.Name == "prod-db");
all.Should().ContainSingle(c => c.Name == "new-db");
// The existing cluster's databases should be updated with newly discovered ones.
PostgresCluster? updated = all.First(c => c.Name == "prod-db");
updated.Databases.Should().Contain("analytics_db");
}
[Fact]
public async Task AdoptCluster_WithClusterWithoutBackup_AdoptsWithDefaults()
{
// Arrange — A CNPG cluster that has no MinIO backup configured.
// The platform should still adopt it with default backup settings.
cnpgClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>
{
new("dev-db", "default", "16.4", 1, "10Gi",
new List<string> { "myapp" },
null, null, null, false)
});
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Cluster adopted with default MinIO settings.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(1);
IReadOnlyList<PostgresCluster> all = await repository.GetAllAsync();
all[0].Name.Should().Be("dev-db");
all[0].Status.Should().Be(PostgresClusterStatus.Running);
}
// ─── Restore Backup ───────────────────────────────────────────────────────
[Fact]
public async Task RestoreBackup_WithValidCluster_CallsCnpgClient()
{
// Arrange — A running cluster with a backup we want to restore from.
// Restoring in CNPG means creating a new cluster from a recovery source.
PostgresCluster pgCluster = PostgresCluster.Adopt(
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
"minio:9000", "bucket", "secret",
databases: new List<string> { "app_db" });
await repository.AddAsync(pgCluster);
RestoreBackupHandler handler = new(repository, cnpgClient.Object);
RestoreBackupRequest request = new(
PostgresClusterId: pgCluster.Id,
BackupName: "prod-db-manual-20250510120000",
RestoredClusterName: "prod-db-restored",
KubeConfig: "config",
ContextName: "ctx",
TargetTime: null);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — A new cluster should be created from the backup.
result.IsSuccess.Should().BeTrue();
cnpgClient.Verify(c => c.RestoreBackupAsync(
"config", "ctx",
It.Is<RestoreFromBackupSpec>(s =>
s.SourceClusterName == "prod-db" &&
s.RestoredClusterName == "prod-db-restored" &&
s.BackupName == "prod-db-manual-20250510120000" &&
s.Namespace == "cnpg-system"),
It.IsAny<CancellationToken>()), Times.Once);
// The restored cluster should be persisted as a new aggregate.
IReadOnlyList<PostgresCluster> all = await repository.GetAllAsync();
all.Should().HaveCount(2);
all.Should().ContainSingle(c => c.Name == "prod-db-restored");
}
[Fact]
public async Task RestoreBackup_WithPointInTimeRecovery_PassesTargetTime()
{
// Arrange — Restore to a specific point in time (PITR).
PostgresCluster pgCluster = PostgresCluster.Adopt(
Guid.NewGuid(), Guid.NewGuid(), "prod-db", "cnpg-system", "16.4", 3, "50Gi",
"minio:9000", "bucket", "secret");
await repository.AddAsync(pgCluster);
RestoreBackupHandler handler = new(repository, cnpgClient.Object);
DateTimeOffset targetTime = new(2025, 5, 10, 10, 30, 0, TimeSpan.Zero);
RestoreBackupRequest request = new(
PostgresClusterId: pgCluster.Id,
BackupName: null,
RestoredClusterName: "prod-db-pitr",
KubeConfig: "config",
ContextName: "ctx",
TargetTime: targetTime);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
cnpgClient.Verify(c => c.RestoreBackupAsync(
"config", "ctx",
It.Is<RestoreFromBackupSpec>(s =>
s.TargetTime == targetTime &&
s.RestoredClusterName == "prod-db-pitr"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task RestoreBackup_SourceClusterNotFound_ReturnsFailure()
{
// Arrange
RestoreBackupHandler handler = new(repository, cnpgClient.Object);
RestoreBackupRequest request = new(
Guid.NewGuid(), "backup-name", "restored-cluster", "config", "ctx", null);
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
// ─── List Live Databases ──────────────────────────────────────────────────
[Fact]
public async Task AdoptCluster_WithLiveDatabases_IncludesAllDatabases()
{
// Arrange — Discovery from the CNPG CR spec only finds the bootstrap
// database, but the live cluster has additional databases created via SQL.
// The adoption should include all of them.
cnpgClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredPostgresCluster>
{
new("prod-db", "cnpg-system", "16.4", 3, "100Gi",
new List<string> { "app_db" },
"minio:9000", "pg-backups", "minio-creds", true)
});
// The live database listing discovers additional databases.
cnpgClient.Setup(c => c.ListLiveDatabasesAsync(
It.IsAny<string>(), It.IsAny<string>(),
"prod-db", "cnpg-system", It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "app_db", "analytics_db", "reporting_db" });
AdoptPostgresClustersHandler handler = new(repository, cnpgClient.Object);
AdoptPostgresClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — All databases from the live cluster are included.
result.IsSuccess.Should().BeTrue();
IReadOnlyList<PostgresCluster> all = await repository.GetAllAsync();
all[0].Databases.Should().HaveCount(3);
all[0].Databases.Should().Contain("app_db");
all[0].Databases.Should().Contain("analytics_db");
all[0].Databases.Should().Contain("reporting_db");
}
}

View File

@@ -0,0 +1,290 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.RedisClusters;
using EntKube.Provisioning.Features.RedisClusters.AdoptRedisCluster;
using EntKube.Provisioning.Features.RedisClusters.CreateRedisCluster;
using EntKube.Provisioning.Features.RedisClusters.UpgradeRedisCluster;
using EntKube.Provisioning.Features.RedisClusters.ConfigureRedisCluster;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
using Moq;
namespace EntKube.Provisioning.Tests.Features;
/// <summary>
/// Tests for all Redis cluster management features. Each test verifies that
/// the handler correctly orchestrates domain logic + K8s client interactions.
/// </summary>
public class RedisClusterFeatureTests
{
private readonly InMemoryRedisClusterRepository repository;
private readonly Mock<IRedisClusterClient> redisClient;
public RedisClusterFeatureTests()
{
repository = new InMemoryRedisClusterRepository();
redisClient = new Mock<IRedisClusterClient>();
}
// ─── Create Cluster ───────────────────────────────────────────────────────
[Fact]
public async Task CreateCluster_WithValidRequest_CreatesAndCallsK8sClient()
{
// Arrange — A platform admin wants a new 3-replica Redis instance
// with sentinel enabled for HA.
CreateRedisClusterHandler handler = new(repository, redisClient.Object);
CreateRedisClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "platform-cache",
Namespace: "redis",
RedisVersion: "7.2.5",
Replicas: 3,
StorageSize: "10Gi",
SentinelEnabled: true,
KubeConfig: "apiVersion: v1\nkind: Config",
ContextName: "prod-ctx");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Cluster created in repository and K8s client called.
result.IsSuccess.Should().BeTrue();
result.Value.Should().NotBe(Guid.Empty);
IReadOnlyList<RedisCluster> clusters = await repository.GetAllAsync();
clusters.Should().HaveCount(1);
clusters[0].Name.Should().Be("platform-cache");
clusters[0].Status.Should().Be(RedisClusterStatus.Provisioning);
redisClient.Verify(c => c.CreateClusterAsync(
"apiVersion: v1\nkind: Config",
"prod-ctx",
It.Is<RedisClusterSpec>(s =>
s.Name == "platform-cache" &&
s.RedisVersion == "7.2.5" &&
s.Replicas == 3 &&
s.SentinelEnabled == true),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task CreateCluster_WithMissingName_ReturnsFailure()
{
// Arrange
CreateRedisClusterHandler handler = new(repository, redisClient.Object);
CreateRedisClusterRequest request = new(
EnvironmentId: Guid.NewGuid(),
ClusterId: Guid.NewGuid(),
Name: "",
Namespace: "redis",
RedisVersion: "7.2.5",
Replicas: 3,
StorageSize: "10Gi",
SentinelEnabled: true,
KubeConfig: "config",
ContextName: "ctx");
// Act
Result<Guid> result = await handler.HandleAsync(request);
// Assert — Domain validation rejects empty names.
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("name");
}
// ─── Adopt Clusters ───────────────────────────────────────────────────────
[Fact]
public async Task AdoptClusters_DiscoversTwoInstances_CreatesBothInRunningState()
{
// Arrange — The K8s cluster has two existing Redis instances.
redisClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredRedisCluster>
{
new("session-cache", "redis", "7.2.5", 3, "10Gi", true),
new("rate-limiter", "redis", "7.0.15", 1, "5Gi", false)
});
AdoptRedisClustersHandler handler = new(repository, redisClient.Object);
AdoptRedisClustersRequest request = new(Guid.NewGuid(), Guid.NewGuid(), "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Both clusters adopted.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(2);
IReadOnlyList<RedisCluster> clusters = await repository.GetAllAsync();
clusters.Should().HaveCount(2);
clusters.Should().OnlyContain(c => c.Status == RedisClusterStatus.Running);
}
[Fact]
public async Task AdoptClusters_AlreadyAdopted_ReturnsExistingIdWithoutDuplicate()
{
// Arrange — One instance already adopted.
Guid clusterId = Guid.NewGuid();
RedisCluster existing = RedisCluster.Adopt(
Guid.NewGuid(), clusterId, "session-cache", "redis", "7.2.5", 3, "10Gi", true);
await repository.AddAsync(existing);
redisClient.Setup(c => c.DiscoverClustersAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<DiscoveredRedisCluster>
{
new("session-cache", "redis", "7.2.5", 3, "10Gi", true)
});
AdoptRedisClustersHandler handler = new(repository, redisClient.Object);
AdoptRedisClustersRequest request = new(Guid.NewGuid(), clusterId, "config", "ctx");
// Act
Result<List<Guid>> result = await handler.HandleAsync(request);
// Assert — Returns existing ID without creating a duplicate.
result.IsSuccess.Should().BeTrue();
result.Value.Should().HaveCount(1);
result.Value![0].Should().Be(existing.Id);
IReadOnlyList<RedisCluster> clusters = await repository.GetAllAsync();
clusters.Should().HaveCount(1);
}
// ─── Upgrade ──────────────────────────────────────────────────────────────
[Fact]
public async Task UpgradeCluster_WithValidVersion_TransitionsToUpgrading()
{
// Arrange — A running Redis instance at version 7.0.15.
RedisCluster redisCluster = RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.0.15", 3, "10Gi", true);
redisCluster.MarkRunning();
await repository.AddAsync(redisCluster);
redisClient.Setup(c => c.GetAvailableVersionsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "7.0.15", "7.2.5" });
UpgradeRedisClusterHandler handler = new(repository, redisClient.Object);
UpgradeRedisClusterRequest request = new(redisCluster.Id, "7.2.5", "config", "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
redisCluster.Status.Should().Be(RedisClusterStatus.Upgrading);
redisCluster.TargetVersion.Should().Be("7.2.5");
redisClient.Verify(c => c.UpgradeClusterAsync(
"config", "ctx", "cache", "redis", "7.2.5",
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpgradeCluster_UnavailableVersion_ReturnsFailure()
{
// Arrange — Target version not in the available list.
RedisCluster redisCluster = RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.0.15", 3, "10Gi", true);
redisCluster.MarkRunning();
await repository.AddAsync(redisCluster);
redisClient.Setup(c => c.GetAvailableVersionsAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string> { "7.0.15" });
UpgradeRedisClusterHandler handler = new(repository, redisClient.Object);
UpgradeRedisClusterRequest request = new(redisCluster.Id, "7.2.5", "config", "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not available");
}
// ─── Configure ────────────────────────────────────────────────────────────
[Fact]
public async Task ConfigureCluster_ScaleReplicas_UpdatesAndCallsK8s()
{
// Arrange — A running 3-replica cluster to be scaled to 5.
RedisCluster redisCluster = RedisCluster.Create(
Guid.NewGuid(), Guid.NewGuid(), "cache", "redis", "7.2.5", 3, "10Gi", true);
redisCluster.MarkRunning();
await repository.AddAsync(redisCluster);
ConfigureRedisClusterHandler handler = new(repository, redisClient.Object);
ConfigureRedisClusterRequest request = new(
redisCluster.Id, Replicas: 5, StorageSize: null, SentinelEnabled: null,
KubeConfig: "config", ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsSuccess.Should().BeTrue();
redisCluster.Replicas.Should().Be(5);
redisClient.Verify(c => c.UpdateClusterAsync(
"config", "ctx",
It.Is<RedisClusterSpec>(s => s.Replicas == 5),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task ConfigureCluster_NonExistent_ReturnsFailure()
{
// Arrange — No cluster with this ID.
ConfigureRedisClusterHandler handler = new(repository, redisClient.Object);
ConfigureRedisClusterRequest request = new(
Guid.NewGuid(), Replicas: 5, StorageSize: null, SentinelEnabled: null,
KubeConfig: "config", ContextName: "ctx");
// Act
Result<string> result = await handler.HandleAsync(request);
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,71 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.RemoveAppEnvironment;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class RemoveAppEnvironmentHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly RemoveAppEnvironmentHandler handler;
public RemoveAppEnvironmentHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new RemoveAppEnvironmentHandler(repository);
}
[Fact]
public async Task HandleAsync_ExistingEnvironment_RemovesSuccessfully()
{
// Arrange — An app with one environment.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(app.Id, envId));
// Assert — The environment should be removed.
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments.Should().BeEmpty();
}
[Fact]
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(Guid.NewGuid(), Guid.NewGuid()));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_NonexistentEnvironment_ReturnsFailure()
{
// Arrange — An app with no environments.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(new RemoveAppEnvironmentRequest(app.Id, Guid.NewGuid()));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,77 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.RemoveAppSecret;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class RemoveAppSecretHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly RemoveAppSecretHandler handler;
public RemoveAppSecretHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new RemoveAppSecretHandler(repository);
}
[Fact]
public async Task HandleAsync_ExistingSecret_RemovesSuccessfully()
{
// Arrange — An app with an environment that has a secret.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
env.AddSecret("DATABASE_URL", "db-conn");
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(
new RemoveAppSecretRequest(app.Id, envId, "DATABASE_URL"));
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].Secrets.Should().BeEmpty();
}
[Fact]
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.HandleAsync(
new RemoveAppSecretRequest(Guid.NewGuid(), Guid.NewGuid(), "KEY"));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
[Fact]
public async Task HandleAsync_NonexistentSecret_ReturnsFailure()
{
// Arrange
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(
new RemoveAppSecretRequest(app.Id, envId, "MISSING"));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,71 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.SuspendActivateApp;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class SuspendActivateAppHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly SuspendActivateAppHandler handler;
public SuspendActivateAppHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new SuspendActivateAppHandler(repository);
}
[Fact]
public async Task SuspendAsync_ActiveApp_SuspendsSuccessfully()
{
// Arrange — An active app.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
await repository.AddAsync(app);
// Act
Result result = await handler.SuspendAsync(app.Id);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Status.Should().Be(AppStatus.Suspended);
}
[Fact]
public async Task ActivateAsync_SuspendedApp_ActivatesSuccessfully()
{
// Arrange — A suspended app.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
app.Suspend();
await repository.AddAsync(app);
// Act
Result result = await handler.ActivateAsync(app.Id);
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Status.Should().Be(AppStatus.Active);
}
[Fact]
public async Task SuspendAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.SuspendAsync(Guid.NewGuid());
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,94 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.Apps.TriggerSync;
using EntKube.Provisioning.Infrastructure;
using EntKube.SharedKernel.Domain;
using FluentAssertions;
namespace EntKube.Provisioning.Tests.Features;
public class TriggerSyncHandlerTests
{
private readonly InMemoryAppRepository repository;
private readonly TriggerSyncHandler handler;
public TriggerSyncHandlerTests()
{
repository = new InMemoryAppRepository();
handler = new TriggerSyncHandler(repository);
}
[Fact]
public async Task HandleAsync_SyncedEnvironment_MarksPending()
{
// Arrange — An environment that was previously synced. The user
// wants to force a re-sync (e.g., after a config change outside
// the UI or to retry after fixing a problem).
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
env.MarkSynced();
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(new TriggerSyncRequest(app.Id, envId));
// Assert — Should be marked Pending so the reconciler picks it up.
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Pending);
}
[Fact]
public async Task HandleAsync_ErrorEnvironment_MarksPendingForRetry()
{
// Arrange — An environment in error state. The admin fixes the issue
// and wants to retry the sync.
App app = App.Create(Guid.NewGuid(), Guid.NewGuid(), "API", "api", AppType.Deployment);
Guid envId = Guid.NewGuid();
AppEnvironment env = app.AddEnvironment(envId, Guid.NewGuid(), "api-dev");
env.ConfigureDeployment(new DeploymentSpec(
Image: "api", Tag: "v1", Replicas: 1,
ContainerPort: 8080, ServicePort: 80,
HostName: null, PathPrefix: null,
EnvironmentVariables: null, Resources: null));
env.MarkError("Connection refused");
await repository.AddAsync(app);
// Act
Result result = await handler.HandleAsync(new TriggerSyncRequest(app.Id, envId));
// Assert
result.IsSuccess.Should().BeTrue();
App? updated = await repository.GetByIdAsync(app.Id);
updated!.Environments[0].SyncStatus.Should().Be(AppSyncStatus.Pending);
}
[Fact]
public async Task HandleAsync_NonexistentApp_ReturnsFailure()
{
// Act
Result result = await handler.HandleAsync(
new TriggerSyncRequest(Guid.NewGuid(), Guid.NewGuid()));
// Assert
result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("not found");
}
}

View File

@@ -0,0 +1,169 @@
using System.Text;
using EntKube.Secrets.Crypto;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Crypto;
/// <summary>
/// Tests for the AES-256-GCM encryption primitive. This is the foundation of
/// the entire secrets manager — every secret is encrypted with AES-256-GCM
/// before being stored. We verify that encryption produces ciphertext that
/// differs from plaintext, that decryption recovers the original data, and
/// that tampering with any part of the ciphertext is detected.
/// </summary>
public class AesGcmEncryptorTests
{
[Fact]
public void Encrypt_ThenDecrypt_RecoversOriginalPlaintext()
{
// Arrange — A 256-bit key and some plaintext to protect.
// This is the most basic contract: encrypt then decrypt = identity.
byte[] key = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("dns-solver-api-token-abc123");
// Act — Encrypt the plaintext, then decrypt the result.
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
byte[] recovered = AesGcmEncryptor.Decrypt(key, ciphertext);
// Assert — The recovered plaintext must match the original exactly.
recovered.Should().BeEquivalentTo(plaintext);
}
[Fact]
public void Encrypt_ProducesDifferentCiphertextEachTime()
{
// Arrange — Same key and same plaintext encrypted twice.
// AES-GCM uses a random nonce, so each encryption should produce
// different ciphertext even for identical inputs.
byte[] key = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("same-secret-value");
// Act
byte[] ciphertext1 = AesGcmEncryptor.Encrypt(key, plaintext);
byte[] ciphertext2 = AesGcmEncryptor.Encrypt(key, plaintext);
// Assert — Ciphertexts must differ (different nonces).
ciphertext1.Should().NotBeEquivalentTo(ciphertext2);
}
[Fact]
public void Encrypt_CiphertextDiffersFromPlaintext()
{
// Arrange
byte[] key = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("my-super-secret-value");
// Act
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
// Assert — The ciphertext must not contain the plaintext verbatim.
string ciphertextStr = Encoding.UTF8.GetString(ciphertext);
ciphertextStr.Should().NotContain("my-super-secret-value");
}
[Fact]
public void Decrypt_WithWrongKey_ThrowsCryptographicException()
{
// Arrange — Encrypt with one key, try to decrypt with a different key.
// AES-GCM's authentication tag ensures this fails loudly.
byte[] correctKey = AesGcmEncryptor.GenerateKey();
byte[] wrongKey = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("secret-data");
byte[] ciphertext = AesGcmEncryptor.Encrypt(correctKey, plaintext);
// Act & Assert — Decryption with the wrong key must fail.
Action act = () => AesGcmEncryptor.Decrypt(wrongKey, ciphertext);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
[Fact]
public void Decrypt_WithTamperedCiphertext_ThrowsCryptographicException()
{
// Arrange — Encrypt normally, then flip a bit in the ciphertext.
// AES-GCM's authenticated encryption detects any tampering.
byte[] key = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("integrity-protected-data");
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
// Tamper with a byte in the encrypted portion (after the nonce).
ciphertext[15] ^= 0xFF;
// Act & Assert — Tampered data must be rejected.
Action act = () => AesGcmEncryptor.Decrypt(key, ciphertext);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
[Fact]
public void GenerateKey_Returns32Bytes()
{
// AES-256 requires a 256-bit (32-byte) key.
byte[] key = AesGcmEncryptor.GenerateKey();
key.Should().HaveCount(32);
}
[Fact]
public void GenerateKey_ProducesUniqueKeys()
{
// Two generated keys must never be the same (CSPRNG guarantee).
byte[] key1 = AesGcmEncryptor.GenerateKey();
byte[] key2 = AesGcmEncryptor.GenerateKey();
key1.Should().NotBeEquivalentTo(key2);
}
[Fact]
public void Encrypt_WithEmptyPlaintext_WorksCorrectly()
{
// Edge case — encrypting an empty byte array should still work.
// Some secrets might be empty during initialization.
byte[] key = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Array.Empty<byte>();
// Act
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
byte[] recovered = AesGcmEncryptor.Decrypt(key, ciphertext);
// Assert
recovered.Should().BeEmpty();
}
[Fact]
public void Encrypt_WithLargePayload_WorksCorrectly()
{
// Secrets can be large (e.g., PEM certificates, JSON blobs).
// Verify AES-GCM handles payloads beyond typical small secrets.
byte[] key = AesGcmEncryptor.GenerateKey();
byte[] plaintext = new byte[64 * 1024]; // 64KB
Random.Shared.NextBytes(plaintext);
// Act
byte[] ciphertext = AesGcmEncryptor.Encrypt(key, plaintext);
byte[] recovered = AesGcmEncryptor.Decrypt(key, ciphertext);
// Assert
recovered.Should().BeEquivalentTo(plaintext);
}
}

View File

@@ -0,0 +1,143 @@
using System.Text;
using EntKube.Secrets.Crypto;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Crypto;
/// <summary>
/// Tests for the envelope encryption engine. Envelope encryption is the core
/// pattern: each secret gets its own Data Encryption Key (DEK), the DEK encrypts
/// the secret, and the Master Encryption Key (MEK) encrypts the DEK. This way:
/// - Rotating the MEK only requires re-encrypting DEKs (not all secrets)
/// - Compromising one DEK only exposes one secret
/// - The MEK never directly touches secret data
/// </summary>
public class EnvelopeEncryptionTests
{
[Fact]
public void Seal_ThenOpen_RecoversOriginalPlaintext()
{
// Arrange — A master key and some secret data to protect.
// "Seal" wraps the data in an envelope (generates DEK, encrypts data,
// encrypts DEK). "Open" reverses the process.
byte[] masterKey = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("azure-dns-client-secret-value");
// Act
EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKey, plaintext);
byte[] recovered = EnvelopeEncryption.Open(masterKey, envelope);
// Assert
recovered.Should().BeEquivalentTo(plaintext);
}
[Fact]
public void Seal_ProducesDifferentEnvelopesForSameData()
{
// Arrange — Each seal operation generates a new random DEK, so
// two envelopes for the same data should be completely different.
byte[] masterKey = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("same-secret");
// Act
EncryptedEnvelope envelope1 = EnvelopeEncryption.Seal(masterKey, plaintext);
EncryptedEnvelope envelope2 = EnvelopeEncryption.Seal(masterKey, plaintext);
// Assert — Both the encrypted DEK and encrypted data should differ.
envelope1.EncryptedDek.Should().NotBeEquivalentTo(envelope2.EncryptedDek);
envelope1.EncryptedData.Should().NotBeEquivalentTo(envelope2.EncryptedData);
}
[Fact]
public void Open_WithWrongMasterKey_Fails()
{
// Arrange — Seal with one MEK, try to open with another.
// The wrong MEK can't decrypt the DEK, so decryption fails.
byte[] correctMek = AesGcmEncryptor.GenerateKey();
byte[] wrongMek = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("secret-data");
EncryptedEnvelope envelope = EnvelopeEncryption.Seal(correctMek, plaintext);
// Act & Assert
Action act = () => EnvelopeEncryption.Open(wrongMek, envelope);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
[Fact]
public void Open_WithTamperedEncryptedDek_Fails()
{
// Arrange — Tamper with the encrypted DEK.
byte[] masterKey = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("integrity-check");
EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKey, plaintext);
// Tamper with the encrypted DEK.
byte[] tamperedDek = (byte[])envelope.EncryptedDek.Clone();
tamperedDek[10] ^= 0xFF;
EncryptedEnvelope tampered = new(tamperedDek, envelope.EncryptedData);
// Act & Assert
Action act = () => EnvelopeEncryption.Open(masterKey, tampered);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
[Fact]
public void Open_WithTamperedEncryptedData_Fails()
{
// Arrange — Tamper with the encrypted data payload.
byte[] masterKey = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("tamper-detection");
EncryptedEnvelope envelope = EnvelopeEncryption.Seal(masterKey, plaintext);
byte[] tamperedData = (byte[])envelope.EncryptedData.Clone();
tamperedData[15] ^= 0xFF;
EncryptedEnvelope tampered = new(envelope.EncryptedDek, tamperedData);
// Act & Assert
Action act = () => EnvelopeEncryption.Open(masterKey, tampered);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
[Fact]
public void ReWrap_ChangesEncryptedDekButPreservesData()
{
// Arrange — Re-wrapping is used during master key rotation. The secret
// data stays the same but the DEK is re-encrypted with the new MEK.
byte[] oldMek = AesGcmEncryptor.GenerateKey();
byte[] newMek = AesGcmEncryptor.GenerateKey();
byte[] plaintext = Encoding.UTF8.GetBytes("rewrap-test-secret");
EncryptedEnvelope original = EnvelopeEncryption.Seal(oldMek, plaintext);
// Act — Re-wrap: decrypt the DEK with old MEK, re-encrypt with new MEK.
EncryptedEnvelope rewrapped = EnvelopeEncryption.ReWrap(oldMek, newMek, original);
// Assert — The rewrapped envelope opens with the new MEK.
byte[] recovered = EnvelopeEncryption.Open(newMek, rewrapped);
recovered.Should().BeEquivalentTo(plaintext);
// Assert — The old MEK no longer works.
Action act = () => EnvelopeEncryption.Open(oldMek, rewrapped);
act.Should().Throw<System.Security.Cryptography.CryptographicException>();
}
}

View File

@@ -0,0 +1,196 @@
using EntKube.Secrets.Crypto;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Crypto;
/// <summary>
/// Tests for Shamir's Secret Sharing scheme. This is the mechanism that protects
/// the master encryption key — the key is split into N shares, and any M of those
/// shares can reconstruct it. Fewer than M shares reveal nothing about the key.
///
/// This is critical for the vault's seal/unseal lifecycle: on initialization the
/// master key is split into shares distributed to key holders. To unseal the vault
/// after a restart, M key holders must each provide their share.
/// </summary>
public class ShamirSecretSharingTests
{
[Fact]
public void Split_ThenCombine_WithExactThreshold_RecoversSecret()
{
// Arrange — A 256-bit master key, split into 5 shares with a threshold of 3.
// Any 3 shares should be enough to recover the original key.
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act — Split into 5 shares, then recombine using exactly 3.
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
byte[] recovered = ShamirSecretSharing.Combine(shares.Take(3).ToList(), threshold: 3);
// Assert — The recovered secret must match the original exactly.
recovered.Should().BeEquivalentTo(secret);
}
[Fact]
public void Split_ThenCombine_WithMoreThanThreshold_RecoversSecret()
{
// Arrange — Using more shares than required should also work.
// This tests that extra shares don't corrupt the reconstruction.
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act — Split into 5, recombine using all 5 (threshold is still 3).
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
byte[] recovered = ShamirSecretSharing.Combine(shares, threshold: 3);
// Assert
recovered.Should().BeEquivalentTo(secret);
}
[Fact]
public void Split_ThenCombine_WithDifferentShareSubsets_AllRecoverSecret()
{
// Arrange — Any combination of M-of-N shares should work, not just
// the first M. This tests that shares 2,3,5 work as well as 1,2,3.
byte[] secret = AesGcmEncryptor.GenerateKey();
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
// Act & Assert — Multiple subsets of 3 shares all recover the secret.
ShamirSecretSharing.Combine(new List<ShamirShare> { shares[0], shares[1], shares[2] }, 3)
.Should().BeEquivalentTo(secret, "shares 1,2,3 should recover the secret");
ShamirSecretSharing.Combine(new List<ShamirShare> { shares[0], shares[2], shares[4] }, 3)
.Should().BeEquivalentTo(secret, "shares 1,3,5 should recover the secret");
ShamirSecretSharing.Combine(new List<ShamirShare> { shares[1], shares[3], shares[4] }, 3)
.Should().BeEquivalentTo(secret, "shares 2,4,5 should recover the secret");
}
[Fact]
public void Split_ProducesCorrectNumberOfShares()
{
// Arrange
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 7, threshold: 4);
// Assert — Must produce exactly the requested number of shares.
shares.Should().HaveCount(7);
}
[Fact]
public void Split_EachShareHasUniqueIndex()
{
// Arrange
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
// Assert — Share indices must be unique (used as x-coordinates in Lagrange interpolation).
List<int> indices = shares.Select(s => s.Index).ToList();
indices.Should().OnlyHaveUniqueItems();
}
[Fact]
public void Split_WithThresholdOf1_EachShareIsTheSecret()
{
// Arrange — Threshold of 1 means any single share recovers the secret.
// This is the degenerate case (no split protection).
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 3, threshold: 1);
// Assert — Each individual share should recover the secret.
foreach (ShamirShare share in shares)
{
byte[] recovered = ShamirSecretSharing.Combine(new List<ShamirShare> { share }, threshold: 1);
recovered.Should().BeEquivalentTo(secret);
}
}
[Fact]
public void Split_WithThresholdEqualsTotal_RequiresAllShares()
{
// Arrange — 3-of-3 means all shares are needed.
byte[] secret = AesGcmEncryptor.GenerateKey();
// Act
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 3, threshold: 3);
byte[] recovered = ShamirSecretSharing.Combine(shares, threshold: 3);
// Assert
recovered.Should().BeEquivalentTo(secret);
}
[Fact]
public void Split_WithInvalidParameters_Throws()
{
// Threshold must be >= 1, total must be >= threshold.
byte[] secret = AesGcmEncryptor.GenerateKey();
Action zeroThreshold = () => ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 0);
zeroThreshold.Should().Throw<ArgumentException>();
Action thresholdExceedsTotal = () => ShamirSecretSharing.Split(secret, totalShares: 3, threshold: 5);
thresholdExceedsTotal.Should().Throw<ArgumentException>();
}
[Fact]
public void Combine_WithTooFewShares_ProducesWrongResult()
{
// Arrange — With fewer than threshold shares, Lagrange interpolation
// produces a different polynomial, so the result should not match.
// This is the information-theoretic security guarantee of Shamir's scheme.
byte[] secret = AesGcmEncryptor.GenerateKey();
List<ShamirShare> shares = ShamirSecretSharing.Split(secret, totalShares: 5, threshold: 3);
// Act — Try to combine only 2 shares when 3 are needed.
byte[] wrongResult = ShamirSecretSharing.Combine(shares.Take(2).ToList(), threshold: 2);
// Assert — The result must NOT equal the original secret.
wrongResult.Should().NotBeEquivalentTo(secret);
}
[Fact]
public void Split_ThenCombine_WorksWithDifferentSecretSizes()
{
// Shamir's scheme works byte-by-byte, so it should handle any size secret.
byte[] smallSecret = new byte[] { 0x42 };
byte[] largeSecret = new byte[64]; // 512-bit key
Random.Shared.NextBytes(largeSecret);
List<ShamirShare> smallShares = ShamirSecretSharing.Split(smallSecret, 3, 2);
List<ShamirShare> largeShares = ShamirSecretSharing.Split(largeSecret, 3, 2);
ShamirSecretSharing.Combine(smallShares.Take(2).ToList(), 2)
.Should().BeEquivalentTo(smallSecret);
ShamirSecretSharing.Combine(largeShares.Take(2).ToList(), 2)
.Should().BeEquivalentTo(largeSecret);
}
}

View File

@@ -0,0 +1,232 @@
using EntKube.Secrets.Domain;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Domain;
public class SecretScopeTests
{
// ─── Factory Method Tests ────────────────────────────────────────────
[Fact]
public void ForCluster_CreatesInfrastructureScope()
{
// Arrange — Infrastructure secrets are scoped to Tenant + Environment + Cluster.
// These are things like kubeconfig credentials, DNS tokens, cloud provider secrets.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
// Act
SecretScope scope = SecretScope.ForCluster(tenantId, environmentId, clusterId);
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().Be(environmentId);
scope.ClusterId.Should().Be(clusterId);
scope.CustomerId.Should().BeNull();
scope.AppName.Should().BeNull();
}
[Fact]
public void ForApp_CreatesApplicationScope()
{
// Arrange — Application secrets are scoped to Tenant + Customer + App + Environment.
// These are things like database connection strings, API keys for third-party services.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
// Act
SecretScope scope = SecretScope.ForApp(tenantId, customerId, "my-api", environmentId);
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().Be(environmentId);
scope.ClusterId.Should().BeNull();
scope.CustomerId.Should().Be(customerId);
scope.AppName.Should().Be("my-api");
}
// ─── Record Construction Tests ───────────────────────────────────────
[Fact]
public void Create_WithAllFields_StoresAllValues()
{
// Arrange — A fully-scoped secret with all fields populated.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
// Act
SecretScope scope = new(tenantId, environmentId, clusterId, customerId, "my-api");
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().Be(environmentId);
scope.ClusterId.Should().Be(clusterId);
scope.CustomerId.Should().Be(customerId);
scope.AppName.Should().Be("my-api");
}
[Fact]
public void Create_WithTenantOnly_AllowsNullOptionalFields()
{
// Arrange & Act — A tenant-wide secret (e.g. a shared API key)
// that isn't scoped to a specific environment, cluster, customer, or app.
Guid tenantId = Guid.NewGuid();
SecretScope scope = new(tenantId, null, null, null, null);
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().BeNull();
scope.ClusterId.Should().BeNull();
scope.CustomerId.Should().BeNull();
scope.AppName.Should().BeNull();
}
[Fact]
public void Create_WithTenantAndEnvironment_ScopesToEnvironment()
{
// Arrange & Act — A secret scoped to a tenant + environment
// (e.g. a dev database password shared across all customers in dev).
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
SecretScope scope = new(tenantId, environmentId, null, null, null);
// Assert
scope.TenantId.Should().Be(tenantId);
scope.EnvironmentId.Should().Be(environmentId);
scope.ClusterId.Should().BeNull();
scope.CustomerId.Should().BeNull();
scope.AppName.Should().BeNull();
}
// ─── BuildPath Tests ─────────────────────────────────────────────────
[Fact]
public void BuildPath_TenantOnly_ReturnsPathWithTenantPrefix()
{
// Arrange — A tenant-scoped secret should have a path like
// "tenants/{tenantId}/secrets/my-key".
Guid tenantId = Guid.NewGuid();
SecretScope scope = new(tenantId, null, null, null, null);
// Act
string path = scope.BuildPath("my-key");
// Assert
path.Should().Be($"tenants/{tenantId}/secrets/my-key");
}
[Fact]
public void BuildPath_TenantAndEnvironment_ReturnsEnvironmentScopedPath()
{
// Arrange
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
SecretScope scope = new(tenantId, environmentId, null, null, null);
// Act
string path = scope.BuildPath("db-password");
// Assert
path.Should().Be($"tenants/{tenantId}/environments/{environmentId}/secrets/db-password");
}
[Fact]
public void BuildPath_ClusterScope_ReturnsClusterScopedPath()
{
// Arrange — Infrastructure secret scoped to a specific cluster.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
Guid clusterId = Guid.NewGuid();
SecretScope scope = SecretScope.ForCluster(tenantId, environmentId, clusterId);
// Act
string path = scope.BuildPath("dns-credential");
// Assert — Path includes the cluster segment.
path.Should().Be(
$"tenants/{tenantId}/environments/{environmentId}/clusters/{clusterId}/secrets/dns-credential");
}
[Fact]
public void BuildPath_AppScope_ReturnsFullAppScopedPath()
{
// Arrange — Application secret: tenant → environment → customer → app.
Guid tenantId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
SecretScope scope = SecretScope.ForApp(tenantId, customerId, "extract01", environmentId);
// Act
string path = scope.BuildPath("api-token");
// Assert
path.Should().Be(
$"tenants/{tenantId}/environments/{environmentId}/customers/{customerId}/apps/extract01/secrets/api-token");
}
[Fact]
public void BuildPath_CustomerWithoutApp_IncludesCustomerScope()
{
// Arrange — Customer-scoped without a specific app.
Guid tenantId = Guid.NewGuid();
Guid environmentId = Guid.NewGuid();
Guid customerId = Guid.NewGuid();
SecretScope scope = new(tenantId, environmentId, null, customerId, null);
// Act
string path = scope.BuildPath("shared-token");
// Assert
path.Should().Be(
$"tenants/{tenantId}/environments/{environmentId}/customers/{customerId}/secrets/shared-token");
}
// ─── Equality ────────────────────────────────────────────────────────
[Fact]
public void Equality_SameValues_AreEqual()
{
// Arrange — Records should have value-based equality.
Guid tenantId = Guid.NewGuid();
Guid envId = Guid.NewGuid();
SecretScope scope1 = new(tenantId, envId, null, null, null);
SecretScope scope2 = new(tenantId, envId, null, null, null);
// Assert
scope1.Should().Be(scope2);
}
}

View File

@@ -0,0 +1,178 @@
using EntKube.Secrets.Crypto;
using EntKube.Secrets.Domain;
using EntKube.Secrets.Infrastructure;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Domain;
/// <summary>
/// Tests for service token authentication. Other EntKube services authenticate
/// to the secrets service using service tokens scoped to specific path prefixes
/// with specific permissions. Tokens are now tenant-scoped.
/// </summary>
public class ServiceTokenTests
{
private readonly InMemoryVaultRepository repository = new();
private readonly byte[] kek = AesGcmEncryptor.GenerateKey();
private readonly Guid tenantId = Guid.NewGuid();
private Vault CreateVault() => new(repository);
[Fact]
public async Task CreateToken_ReturnsTokenWithCorrectPolicies()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "clusters-service",
policies: new List<AccessPolicy>
{
new("clusters/", AccessOperation.Read | AccessOperation.Write | AccessOperation.List),
new("shared/certificates/", AccessOperation.Read)
});
token.Token.Should().NotBeNullOrEmpty();
token.Name.Should().Be("clusters-service");
token.Policies.Should().HaveCount(2);
}
[Fact]
public async Task ValidateToken_WithValidToken_ReturnsTrue()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "test-service",
policies: new List<AccessPolicy>
{
new("test/", AccessOperation.Read)
});
TokenValidationResult result = await vault.ValidateTokenAsync(token.Token);
result.IsValid.Should().BeTrue();
result.Name.Should().Be("test-service");
}
[Fact]
public async Task ValidateToken_WithInvalidToken_ReturnsFalse()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
TokenValidationResult result = await vault.ValidateTokenAsync("invalid-token-that-was-never-issued");
result.IsValid.Should().BeFalse();
}
[Fact]
public async Task CheckAccess_WithMatchingPolicy_AllowsOperation()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "clusters-service",
policies: new List<AccessPolicy>
{
new("clusters/", AccessOperation.Read | AccessOperation.Write)
});
bool canRead = await vault.CheckAccessAsync(token.Token, "clusters/prod/dns-cred", AccessOperation.Read);
bool canWrite = await vault.CheckAccessAsync(token.Token, "clusters/prod/dns-cred", AccessOperation.Write);
canRead.Should().BeTrue();
canWrite.Should().BeTrue();
}
[Fact]
public async Task CheckAccess_WithNoMatchingPolicy_DeniesOperation()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "readonly-service",
policies: new List<AccessPolicy>
{
new("clusters/", AccessOperation.Read)
});
bool canWrite = await vault.CheckAccessAsync(token.Token, "clusters/prod/secret", AccessOperation.Write);
bool canReadOther = await vault.CheckAccessAsync(token.Token, "identity/users/", AccessOperation.Read);
canWrite.Should().BeFalse();
canReadOther.Should().BeFalse();
}
[Fact]
public async Task RevokeToken_PreventsSubsequentValidation()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
ServiceTokenResult token = await vault.CreateServiceTokenAsync(
tenantId,
name: "temporary-service",
policies: new List<AccessPolicy>
{
new("temp/", AccessOperation.Read)
});
TokenValidationResult validBefore = await vault.ValidateTokenAsync(token.Token);
validBefore.IsValid.Should().BeTrue();
await vault.RevokeTokenAsync(token.Token);
TokenValidationResult validAfter = await vault.ValidateTokenAsync(token.Token);
validAfter.IsValid.Should().BeFalse();
}
[Fact]
public async Task ListTokens_ReturnsActiveTokenMetadata()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.CreateServiceTokenAsync(tenantId, "service-a", new List<AccessPolicy>
{
new("a/", AccessOperation.Read)
});
await vault.CreateServiceTokenAsync(tenantId, "service-b", new List<AccessPolicy>
{
new("b/", AccessOperation.Read | AccessOperation.Write)
});
List<ServiceTokenInfo> tokens = await vault.ListTokensAsync(tenantId);
tokens.Should().HaveCount(2);
tokens.Should().Contain(t => t.Name == "service-a");
tokens.Should().Contain(t => t.Name == "service-b");
}
[Fact]
public async Task MultiInstance_TokenCreatedOnOnePodValidatesOnAnother()
{
Vault pod1 = CreateVault();
await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
Vault pod2 = CreateVault();
await pod2.AutoUnsealAsync(kek);
ServiceTokenResult token = await pod1.CreateServiceTokenAsync(
tenantId,
name: "cross-pod-service",
policies: new List<AccessPolicy> { new("shared/", AccessOperation.Read) });
TokenValidationResult result = await pod2.ValidateTokenAsync(token.Token);
result.IsValid.Should().BeTrue();
result.Name.Should().Be("cross-pod-service");
}
}

View File

@@ -0,0 +1,518 @@
using EntKube.Secrets.Crypto;
using EntKube.Secrets.Domain;
using EntKube.Secrets.Infrastructure;
using FluentAssertions;
namespace EntKube.Secrets.Tests.Domain;
/// <summary>
/// Tests for the multi-tenant Vault aggregate. Each tenant gets their own
/// master encryption key (MEK) for cryptographic isolation.
///
/// The vault manages per-tenant MEKs — initialization, auto-unseal, and
/// secret operations are all scoped to a specific tenant.
/// </summary>
public class VaultTests
{
private readonly InMemoryVaultRepository repository = new();
private readonly byte[] kek = AesGcmEncryptor.GenerateKey();
private readonly Guid tenantId = Guid.NewGuid();
private Vault CreateVault() => new(repository);
// ─── Initialization ──────────────────────────────────────────────────
[Fact]
public async Task Initialize_GeneratesMasterKeyAndAutoUnseals()
{
// Arrange — A fresh vault backed by an empty repository.
Vault vault = CreateVault();
// Act — Initialize a tenant with KEK, 5 Shamir shares, threshold of 3.
InitializationResult result = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3);
// Assert — Shares returned for DR, tenant's vault is immediately unsealed.
result.Shares.Should().HaveCount(5);
result.Threshold.Should().Be(3);
vault.IsTenantInitialized(tenantId).Should().BeTrue();
vault.IsTenantSealed(tenantId).Should().BeFalse();
}
[Fact]
public async Task Initialize_WhenAlreadyInitialized_Fails()
{
// A tenant's vault can only be initialized once.
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
Func<Task> act = () => vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*already initialized*");
}
// ─── Auto-Unseal (Normal K8s Startup) ────────────────────────────────
[Fact]
public async Task AutoUnseal_WithCorrectKek_UnsealsAllTenants()
{
// Arrange — Initialize two tenants, then seal both.
Guid tenant1 = Guid.NewGuid();
Guid tenant2 = Guid.NewGuid();
Vault vault = CreateVault();
await vault.InitializeAsync(tenant1, kek, totalShares: 1, threshold: 1);
await vault.InitializeAsync(tenant2, kek, totalShares: 1, threshold: 1);
await vault.SealAsync();
// Act — A new vault instance auto-unseals all tenants.
Vault vault2 = CreateVault();
await vault2.AutoUnsealAsync(kek);
// Assert — Both tenants unsealed.
vault2.IsTenantSealed(tenant1).Should().BeFalse();
vault2.IsTenantSealed(tenant2).Should().BeFalse();
}
[Fact]
public async Task AutoUnseal_WithWrongKek_Fails()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.SealAsync();
byte[] wrongKek = AesGcmEncryptor.GenerateKey();
Vault vault2 = CreateVault();
Func<Task> act = () => vault2.AutoUnsealAsync(wrongKek);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*KEK*");
}
[Fact]
public async Task AutoUnseal_WhenNotInitialized_Fails()
{
Vault vault = CreateVault();
Func<Task> act = () => vault.AutoUnsealAsync(kek);
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*not initialized*");
}
// ─── Manual Unseal (Disaster Recovery) ───────────────────────────────
[Fact]
public async Task ManualUnseal_WithEnoughShares_UnsealsVault()
{
Vault vault = CreateVault();
InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3);
await vault.SealAsync(tenantId);
vault.IsTenantSealed(tenantId).Should().BeTrue();
// Act — Provide 3 of 5 shares.
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]);
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[2]);
UnsealResult unsealResult = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[4]);
unsealResult.IsUnsealed.Should().BeTrue();
vault.IsTenantSealed(tenantId).Should().BeFalse();
}
[Fact]
public async Task ManualUnseal_WithInsufficientShares_RemainsSealed()
{
Vault vault = CreateVault();
InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 5, threshold: 3);
await vault.SealAsync(tenantId);
UnsealResult result1 = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]);
UnsealResult result2 = await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]);
result1.IsUnsealed.Should().BeFalse();
result1.SharesProvided.Should().Be(1);
result1.SharesRequired.Should().Be(3);
result2.IsUnsealed.Should().BeFalse();
result2.SharesProvided.Should().Be(2);
vault.IsTenantSealed(tenantId).Should().BeTrue();
}
// ─── Multi-Instance (Simulating K8s Pods) ────────────────────────────
[Fact]
public async Task MultiInstance_BothPodsCanAutoUnseal()
{
Vault pod1 = CreateVault();
await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
Vault pod2 = CreateVault();
await pod2.AutoUnsealAsync(kek);
pod1.IsTenantSealed(tenantId).Should().BeFalse();
pod2.IsTenantSealed(tenantId).Should().BeFalse();
}
[Fact]
public async Task MultiInstance_SecretWrittenByOnePodReadableByAnother()
{
Vault pod1 = CreateVault();
await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
Vault pod2 = CreateVault();
await pod2.AutoUnsealAsync(kek);
await pod1.PutSecretAsync(tenantId, "clusters/prod/dns-cred", "cloudflare-api-token");
string? value = await pod2.GetSecretAsync(tenantId, "clusters/prod/dns-cred");
value.Should().Be("cloudflare-api-token");
}
[Fact]
public async Task MultiInstance_SealingOnePodDoesNotAffectOther()
{
Vault pod1 = CreateVault();
await pod1.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await pod1.PutSecretAsync(tenantId, "shared/secret", "hello");
Vault pod2 = CreateVault();
await pod2.AutoUnsealAsync(kek);
await pod1.SealAsync(tenantId);
pod1.IsTenantSealed(tenantId).Should().BeTrue();
pod2.IsTenantSealed(tenantId).Should().BeFalse();
string? value = await pod2.GetSecretAsync(tenantId, "shared/secret");
value.Should().Be("hello");
}
// ─── Multi-Tenant Isolation ──────────────────────────────────────────
[Fact]
public async Task MultiTenant_SecretsAreCryptographicallyIsolated()
{
// Each tenant has their own MEK. A secret written for tenant A
// cannot be decrypted by tenant B's MEK.
Guid tenantA = Guid.NewGuid();
Guid tenantB = Guid.NewGuid();
Vault vault = CreateVault();
await vault.InitializeAsync(tenantA, kek, totalShares: 1, threshold: 1);
await vault.InitializeAsync(tenantB, kek, totalShares: 1, threshold: 1);
// Each tenant writes a secret at the same logical key but different paths.
await vault.PutSecretAsync(tenantA, "tenants/a/config/api-key", "tenant-a-secret");
await vault.PutSecretAsync(tenantB, "tenants/b/config/api-key", "tenant-b-secret");
// Each tenant can only read their own secrets.
string? valueA = await vault.GetSecretAsync(tenantA, "tenants/a/config/api-key");
string? valueB = await vault.GetSecretAsync(tenantB, "tenants/b/config/api-key");
valueA.Should().Be("tenant-a-secret");
valueB.Should().Be("tenant-b-secret");
}
[Fact]
public async Task MultiTenant_SealingOneTenantDoesNotAffectAnother()
{
Guid tenantA = Guid.NewGuid();
Guid tenantB = Guid.NewGuid();
Vault vault = CreateVault();
await vault.InitializeAsync(tenantA, kek, totalShares: 1, threshold: 1);
await vault.InitializeAsync(tenantB, kek, totalShares: 1, threshold: 1);
// Seal only tenant A.
await vault.SealAsync(tenantA);
vault.IsTenantSealed(tenantA).Should().BeTrue();
vault.IsTenantSealed(tenantB).Should().BeFalse();
}
// ─── Seal / Unseal Lifecycle ─────────────────────────────────────────
[Fact]
public async Task Seal_ClearsMasterKeyFromMemory()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
vault.IsTenantSealed(tenantId).Should().BeFalse();
await vault.SealAsync(tenantId);
vault.IsTenantSealed(tenantId).Should().BeTrue();
}
[Fact]
public async Task SealThenAutoUnseal_Works()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
await vault.SealAsync();
vault.IsSealed.Should().BeTrue();
await vault.AutoUnsealAsync(kek);
vault.IsTenantSealed(tenantId).Should().BeFalse();
}
[Fact]
public async Task RepeatedSealUnsealCycles_Work()
{
Vault vault = CreateVault();
InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
await vault.SealAsync();
await vault.AutoUnsealAsync(kek);
vault.IsTenantSealed(tenantId).Should().BeFalse();
await vault.SealAsync(tenantId);
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]);
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]);
vault.IsTenantSealed(tenantId).Should().BeFalse();
}
// ─── KEK Rotation ────────────────────────────────────────────────────
[Fact]
public async Task ReWrapMasterKey_AllowsAutoUnsealWithNewKek()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.PutSecretAsync(tenantId, "test/secret", "original-value");
byte[] newKek = AesGcmEncryptor.GenerateKey();
await vault.ReWrapMasterKeyAsync(tenantId, newKek);
await vault.SealAsync();
await vault.AutoUnsealAsync(newKek);
string? value = await vault.GetSecretAsync(tenantId, "test/secret");
value.Should().Be("original-value");
}
[Fact]
public async Task ReWrapMasterKey_OldKekNoLongerWorks()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
byte[] newKek = AesGcmEncryptor.GenerateKey();
await vault.ReWrapMasterKeyAsync(tenantId, newKek);
await vault.SealAsync();
Vault vault2 = CreateVault();
Func<Task> act = () => vault2.AutoUnsealAsync(kek);
await act.Should().ThrowAsync<InvalidOperationException>();
}
// ─── Secret Operations ───────────────────────────────────────────────
[Fact]
public async Task PutSecret_WhenUnsealed_StoresEncryptedSecret()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.PutSecretAsync(tenantId, "clusters/prod/letsencrypt/dns-credential", "cloudflare-api-token-value");
string? retrieved = await vault.GetSecretAsync(tenantId, "clusters/prod/letsencrypt/dns-credential");
retrieved.Should().Be("cloudflare-api-token-value");
}
[Fact]
public async Task PutSecret_WhenSealed_ThrowsVaultSealedException()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.SealAsync(tenantId);
Func<Task> act = () => vault.PutSecretAsync(tenantId, "path/to/secret", "value");
await act.Should().ThrowAsync<VaultSealedException>();
}
[Fact]
public async Task GetSecret_WhenSealed_ThrowsVaultSealedException()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.PutSecretAsync(tenantId, "my/secret", "value");
await vault.SealAsync(tenantId);
Func<Task> act = () => vault.GetSecretAsync(tenantId, "my/secret");
await act.Should().ThrowAsync<VaultSealedException>();
}
[Fact]
public async Task GetSecret_NonExistentPath_ReturnsNull()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
string? result = await vault.GetSecretAsync(tenantId, "does/not/exist");
result.Should().BeNull();
}
[Fact]
public async Task PutSecret_OverwritesExistingSecret()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.PutSecretAsync(tenantId, "config/api-key", "old-value");
await vault.PutSecretAsync(tenantId, "config/api-key", "new-value");
string? result = await vault.GetSecretAsync(tenantId, "config/api-key");
result.Should().Be("new-value");
}
[Fact]
public async Task PutSecret_CreatesVersionHistory()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.PutSecretAsync(tenantId, "config/api-key", "v1");
await vault.PutSecretAsync(tenantId, "config/api-key", "v2");
await vault.PutSecretAsync(tenantId, "config/api-key", "v3");
string? current = await vault.GetSecretAsync(tenantId, "config/api-key");
int versionCount = await vault.GetSecretVersionCountAsync(tenantId, "config/api-key");
current.Should().Be("v3");
versionCount.Should().Be(3);
}
[Fact]
public async Task GetSecretVersion_RetrievesSpecificVersion()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.PutSecretAsync(tenantId, "config/api-key", "version-one");
await vault.PutSecretAsync(tenantId, "config/api-key", "version-two");
await vault.PutSecretAsync(tenantId, "config/api-key", "version-three");
string? v1 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 1);
string? v2 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 2);
string? v3 = await vault.GetSecretVersionAsync(tenantId, "config/api-key", version: 3);
v1.Should().Be("version-one");
v2.Should().Be("version-two");
v3.Should().Be("version-three");
}
[Fact]
public async Task DeleteSecret_SoftDeletesAndPreventsRetrieval()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.PutSecretAsync(tenantId, "temp/secret", "temporary-value");
await vault.DeleteSecretAsync(tenantId, "temp/secret");
string? result = await vault.GetSecretAsync(tenantId, "temp/secret");
result.Should().BeNull();
}
[Fact]
public async Task ListSecrets_ReturnsPathsWithoutValues()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.PutSecretAsync(tenantId, "clusters/prod/dns-cred", "value1");
await vault.PutSecretAsync(tenantId, "clusters/prod/tls-key", "value2");
await vault.PutSecretAsync(tenantId, "clusters/staging/dns-cred", "value3");
List<string> paths = await vault.ListSecretsAsync(tenantId, "clusters/prod/");
paths.Should().HaveCount(2);
paths.Should().Contain("clusters/prod/dns-cred");
paths.Should().Contain("clusters/prod/tls-key");
}
[Fact]
public async Task ListSecrets_WhenSealed_ThrowsVaultSealedException()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.SealAsync(tenantId);
Func<Task> act = () => vault.ListSecretsAsync(tenantId, "any/");
await act.Should().ThrowAsync<VaultSealedException>();
}
// ─── Secrets Persist Across Seal/Unseal ──────────────────────────────
[Fact]
public async Task Secrets_SurviveSealAutoUnsealCycle()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
await vault.PutSecretAsync(tenantId, "persistent/secret", "survive-seal-unseal");
await vault.SealAsync();
await vault.AutoUnsealAsync(kek);
string? recovered = await vault.GetSecretAsync(tenantId, "persistent/secret");
recovered.Should().Be("survive-seal-unseal");
}
[Fact]
public async Task Secrets_SurviveManualShamirUnseal()
{
Vault vault = CreateVault();
InitializationResult initResult = await vault.InitializeAsync(tenantId, kek, totalShares: 3, threshold: 2);
await vault.PutSecretAsync(tenantId, "dr/secret", "disaster-recovery-value");
await vault.SealAsync(tenantId);
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[0]);
await vault.ProvideUnsealShareAsync(tenantId, initResult.Shares[1]);
string? recovered = await vault.GetSecretAsync(tenantId, "dr/secret");
recovered.Should().Be("disaster-recovery-value");
}
// ─── Audit ───────────────────────────────────────────────────────────
[Fact]
public async Task Operations_AreAuditLogged()
{
Vault vault = CreateVault();
await vault.InitializeAsync(tenantId, kek, totalShares: 1, threshold: 1);
await vault.PutSecretAsync(tenantId, "audit/test", "value");
await vault.GetSecretAsync(tenantId, "audit/test");
await vault.DeleteSecretAsync(tenantId, "audit/test");
List<AuditEntry> entries = await vault.GetAuditLogAsync();
entries.Should().Contain(e => e.Operation == "initialize");
entries.Should().Contain(e => e.Operation == "put" && e.Path == "audit/test");
entries.Should().Contain(e => e.Operation == "get" && e.Path == "audit/test");
entries.Should().Contain(e => e.Operation == "delete" && e.Path == "audit/test");
}
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="FluentAssertions" Version="8.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\EntKube.Secrets\EntKube.Secrets.csproj" />
<ProjectReference Include="..\..\src\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
</ItemGroup>
</Project>