too much for one commit
This commit is contained in:
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
152
tests/EntKube.Clusters.Tests/Features/CleuraClientTests.cs
Normal file
152
tests/EntKube.Clusters.Tests/Features/CleuraClientTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
183
tests/EntKube.Clusters.Tests/Features/ClusterSettingsTests.cs
Normal file
183
tests/EntKube.Clusters.Tests/Features/ClusterSettingsTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
348
tests/EntKube.Clusters.Tests/Features/GiteaComponentTests.cs
Normal file
348
tests/EntKube.Clusters.Tests/Features/GiteaComponentTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
728
tests/EntKube.Clusters.Tests/Features/HarborTests.cs
Normal file
728
tests/EntKube.Clusters.Tests/Features/HarborTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
194
tests/EntKube.Clusters.Tests/Features/IngressCheckTests.cs
Normal file
194
tests/EntKube.Clusters.Tests/Features/IngressCheckTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
127
tests/EntKube.Clusters.Tests/Features/IngressInstallerTests.cs
Normal file
127
tests/EntKube.Clusters.Tests/Features/IngressInstallerTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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" }));
|
||||
}
|
||||
}
|
||||
546
tests/EntKube.Clusters.Tests/Features/KeycloakTests.cs
Normal file
546
tests/EntKube.Clusters.Tests/Features/KeycloakTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
748
tests/EntKube.Clusters.Tests/Features/LetsEncryptCheckTests.cs
Normal file
748
tests/EntKube.Clusters.Tests/Features/LetsEncryptCheckTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
111
tests/EntKube.Clusters.Tests/Features/SetProviderHandlerTests.cs
Normal file
111
tests/EntKube.Clusters.Tests/Features/SetProviderHandlerTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
739
tests/EntKube.Clusters.Tests/Features/WorkloadRemediatorTests.cs
Normal file
739
tests/EntKube.Clusters.Tests/Features/WorkloadRemediatorTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
116
tests/EntKube.Identity.Tests/Domain/CustomerTests.cs
Normal file
116
tests/EntKube.Identity.Tests/Domain/CustomerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
115
tests/EntKube.Identity.Tests/Domain/EnvironmentTests.cs
Normal file
115
tests/EntKube.Identity.Tests/Domain/EnvironmentTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
180
tests/EntKube.Provisioning.Tests/Domain/AppEnvironmentTests.cs
Normal file
180
tests/EntKube.Provisioning.Tests/Domain/AppEnvironmentTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
229
tests/EntKube.Provisioning.Tests/Domain/AppTests.cs
Normal file
229
tests/EntKube.Provisioning.Tests/Domain/AppTests.cs
Normal 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*");
|
||||
}
|
||||
}
|
||||
360
tests/EntKube.Provisioning.Tests/Domain/GrafanaInstanceTests.cs
Normal file
360
tests/EntKube.Provisioning.Tests/Domain/GrafanaInstanceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
192
tests/EntKube.Provisioning.Tests/Domain/KeycloakRealmTests.cs
Normal file
192
tests/EntKube.Provisioning.Tests/Domain/KeycloakRealmTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
204
tests/EntKube.Provisioning.Tests/Domain/MinioInstanceTests.cs
Normal file
204
tests/EntKube.Provisioning.Tests/Domain/MinioInstanceTests.cs
Normal 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" });
|
||||
}
|
||||
}
|
||||
307
tests/EntKube.Provisioning.Tests/Domain/MinioTenantTests.cs
Normal file
307
tests/EntKube.Provisioning.Tests/Domain/MinioTenantTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
405
tests/EntKube.Provisioning.Tests/Domain/MongoClusterTests.cs
Normal file
405
tests/EntKube.Provisioning.Tests/Domain/MongoClusterTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
437
tests/EntKube.Provisioning.Tests/Domain/PostgresClusterTests.cs
Normal file
437
tests/EntKube.Provisioning.Tests/Domain/PostgresClusterTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
275
tests/EntKube.Provisioning.Tests/Domain/PrometheusStackTests.cs
Normal file
275
tests/EntKube.Provisioning.Tests/Domain/PrometheusStackTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
305
tests/EntKube.Provisioning.Tests/Domain/RedisClusterTests.cs
Normal file
305
tests/EntKube.Provisioning.Tests/Domain/RedisClusterTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
387
tests/EntKube.Provisioning.Tests/Features/AppReconcilerTests.cs
Normal file
387
tests/EntKube.Provisioning.Tests/Features/AppReconcilerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
1107
tests/EntKube.Provisioning.Tests/Features/ManifestGeneratorTests.cs
Normal file
1107
tests/EntKube.Provisioning.Tests/Features/ManifestGeneratorTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
255
tests/EntKube.Provisioning.Tests/Features/MinioAdoptionTests.cs
Normal file
255
tests/EntKube.Provisioning.Tests/Features/MinioAdoptionTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
169
tests/EntKube.Secrets.Tests/Crypto/AesGcmEncryptorTests.cs
Normal file
169
tests/EntKube.Secrets.Tests/Crypto/AesGcmEncryptorTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
143
tests/EntKube.Secrets.Tests/Crypto/EnvelopeEncryptionTests.cs
Normal file
143
tests/EntKube.Secrets.Tests/Crypto/EnvelopeEncryptionTests.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
196
tests/EntKube.Secrets.Tests/Crypto/ShamirSecretSharingTests.cs
Normal file
196
tests/EntKube.Secrets.Tests/Crypto/ShamirSecretSharingTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
232
tests/EntKube.Secrets.Tests/Domain/SecretScopeTests.cs
Normal file
232
tests/EntKube.Secrets.Tests/Domain/SecretScopeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
178
tests/EntKube.Secrets.Tests/Domain/ServiceTokenTests.cs
Normal file
178
tests/EntKube.Secrets.Tests/Domain/ServiceTokenTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
518
tests/EntKube.Secrets.Tests/Domain/VaultTests.cs
Normal file
518
tests/EntKube.Secrets.Tests/Domain/VaultTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
23
tests/EntKube.Secrets.Tests/EntKube.Secrets.Tests.csproj
Normal file
23
tests/EntKube.Secrets.Tests/EntKube.Secrets.Tests.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user