using EntKube.Web.Data;
using EntKube.Web.Services;
using FluentAssertions;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Tests;
///
/// Tests for the ComponentCatalog — the static registry of well-known
/// infrastructure components. Verifies that catalog entries are valid,
/// the lookup methods work, and that catalog-based registration flows
/// correctly through the lifecycle service.
///
public class ComponentCatalogTests : IDisposable
{
private readonly SqliteConnection connection;
private readonly ApplicationDbContext db;
private readonly TestDbContextFactory dbFactory;
private readonly ComponentLifecycleService lifecycleService;
private readonly Guid clusterId = Guid.NewGuid();
public ComponentCatalogTests()
{
connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
DbContextOptions options = new DbContextOptionsBuilder()
.UseSqlite(connection)
.Options;
db = new ApplicationDbContext(options);
dbFactory = new TestDbContextFactory(connection);
db.Database.EnsureCreated();
// Seed a tenant, environment, and cluster for integration tests.
Guid tenantId = Guid.NewGuid();
Guid envId = Guid.NewGuid();
Tenant tenant = new() { Id = tenantId, Name = "CatalogTenant", Slug = "catalog" };
Data.Environment env = new() { Id = envId, TenantId = tenantId, Name = "production" };
KubernetesCluster cluster = new()
{
Id = clusterId,
TenantId = tenantId,
EnvironmentId = envId,
Name = "catalog-cluster",
ApiServerUrl = "https://k8s.example.com",
Kubeconfig = "apiVersion: v1\nkind: Config"
};
db.Set().Add(tenant);
db.Set().Add(env);
db.KubernetesClusters.Add(cluster);
db.SaveChanges();
byte[] testRootKey = Convert.FromBase64String("dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhMTIzNDU2Nzg=");
VaultEncryptionService encryption = new(testRootKey);
VaultService vaultService = new(dbFactory, encryption);
lifecycleService = new ComponentLifecycleService(dbFactory, vaultService);
}
public void Dispose()
{
db.Dispose();
connection.Dispose();
}
// ──────── Catalog integrity ────────
[Fact]
public void Catalog_HasEntries()
{
// The catalog should never be empty — we ship with known components.
ComponentCatalog.Entries.Should().NotBeEmpty();
}
[Fact]
public void Catalog_AllEntriesHaveRequiredFields()
{
// Every catalog entry must have all the fields needed for a valid
// Helm install. Missing fields would cause install failures.
foreach (CatalogEntry entry in ComponentCatalog.Entries)
{
entry.Key.Should().NotBeNullOrWhiteSpace($"entry must have a key");
entry.DisplayName.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a display name");
entry.Description.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a description");
entry.Icon.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have an icon");
entry.Category.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a category");
entry.HelmRepoUrl.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a Helm repo URL");
entry.HelmChartName.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a Helm chart name");
entry.DefaultNamespace.Should().NotBeNullOrWhiteSpace($"{entry.Key} must have a default namespace");
}
}
[Fact]
public void Catalog_KeysAreUnique()
{
// Duplicate keys would cause lookup ambiguity.
List keys = ComponentCatalog.Entries.Select(e => e.Key).ToList();
keys.Should().OnlyHaveUniqueItems();
}
// ──────── Lookup ────────
[Fact]
public void GetByKey_ExistingEntry_ReturnsEntry()
{
CatalogEntry? entry = ComponentCatalog.GetByKey("kube-prometheus-stack");
entry.Should().NotBeNull();
entry!.DisplayName.Should().Be("Kube Prometheus Stack");
entry.Category.Should().Be("Monitoring");
}
[Fact]
public void GetByKey_CaseInsensitive_ReturnsEntry()
{
// Operators shouldn't have to remember exact casing.
CatalogEntry? entry = ComponentCatalog.GetByKey("MINIO");
entry.Should().NotBeNull();
entry!.Key.Should().Be("minio");
}
[Fact]
public void GetByKey_Unknown_ReturnsNull()
{
CatalogEntry? entry = ComponentCatalog.GetByKey("nonexistent-component");
entry.Should().BeNull();
}
[Fact]
public void GetByCategory_GroupsCorrectly()
{
IReadOnlyList> groups = ComponentCatalog.GetByCategory();
groups.Should().NotBeEmpty();
groups.SelectMany(g => g).Should().HaveCount(ComponentCatalog.Entries.Count);
}
// ──────── Registration from catalog ────────
[Fact]
public void ToRegistration_FillsAllHelmDetails()
{
// When we create a registration from a catalog entry, it should
// carry over all the Helm details so the operator doesn't need
// to enter them manually.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
ComponentRegistration registration = ComponentCatalog.ToRegistration(entry);
registration.Name.Should().Be("kube-prometheus-stack");
registration.ComponentType.Should().Be("HelmChart");
registration.Namespace.Should().Be("monitoring");
registration.HelmRepoUrl.Should().Be("https://prometheus-community.github.io/helm-charts");
registration.HelmChartName.Should().Be("kube-prometheus-stack");
registration.ReleaseName.Should().Be("kube-prometheus-stack");
registration.HelmValues.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task RegisterFromCatalog_CreatesComponentWithDefaults()
{
// The full flow: pick from catalog → register → component is ready
// with all Helm details pre-filled.
CatalogEntry entry = ComponentCatalog.GetByKey("minio")!;
ComponentRegistration registration = ComponentCatalog.ToRegistration(entry);
ClusterComponent component = await lifecycleService.RegisterComponentAsync(clusterId, registration);
component.Name.Should().Be("minio");
component.HelmRepoUrl.Should().Be("https://charts.min.io/");
component.HelmChartName.Should().Be("minio");
component.Namespace.Should().Be("minio");
component.Status.Should().Be(ComponentStatus.NotInstalled);
component.HelmValues.Should().Contain("rootUser");
}
[Fact]
public async Task RegisterFromCatalog_CanOverrideValues()
{
// Operators should be able to modify the default values before
// registering — for example, changing resource limits or passwords.
CatalogEntry entry = ComponentCatalog.GetByKey("cert-manager")!;
ComponentRegistration registration = ComponentCatalog.ToRegistration(entry);
// Override the values with custom config.
registration.HelmValues = """
crds:
enabled: true
resources:
requests:
memory: 256Mi
cpu: 100m
""";
ClusterComponent component = await lifecycleService.RegisterComponentAsync(clusterId, registration);
component.HelmValues.Should().Contain("256Mi");
component.HelmValues.Should().NotContain("128Mi");
}
[Fact]
public async Task RegisterFromCatalog_DuplicateRejected()
{
// Can't install the same catalog component twice on one cluster.
CatalogEntry entry = ComponentCatalog.GetByKey("traefik")!;
ComponentRegistration registration = ComponentCatalog.ToRegistration(entry);
await lifecycleService.RegisterComponentAsync(clusterId, registration);
// Second attempt should be rejected.
Func secondAttempt = () => lifecycleService.RegisterComponentAsync(
clusterId, ComponentCatalog.ToRegistration(entry));
await secondAttempt.Should().ThrowAsync()
.WithMessage("*already exists*");
}
// ──────── Dependency resolution ────────
[Fact]
public void CheckDependencies_NoDependencies_Satisfied()
{
// Components with no dependencies should always pass the check.
CatalogEntry entry = ComponentCatalog.GetByKey("minio")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []);
result.IsSatisfied.Should().BeTrue();
result.MissingDependencies.Should().BeEmpty();
result.MissingOneOfRequirements.Should().BeEmpty();
}
[Fact]
public void CheckDependencies_DirectDependencyMissing_NotSatisfied()
{
// kube-prometheus-stack requires cert-manager and letsencrypt-issuer.
// If they're not installed, the check should report them missing.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []);
result.IsSatisfied.Should().BeFalse();
result.MissingDependencies.Should().Contain("cert-manager");
result.MissingDependencies.Should().Contain("letsencrypt-issuer");
}
[Fact]
public void CheckDependencies_OneOfMissing_NotSatisfied()
{
// kube-prometheus-stack needs either traefik or istio.
// If neither is present, the one-of requirement should be missing.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(
entry, ["cert-manager", "letsencrypt-issuer"]);
result.IsSatisfied.Should().BeFalse();
result.MissingOneOfRequirements.Should().HaveCount(1);
result.MissingOneOfRequirements[0].Label.Should().Be("Ingress Controller");
}
[Fact]
public void CheckDependencies_TraefikSatisfiesIngress()
{
// Installing traefik should satisfy the "one of ingress" requirement.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(
entry, ["cert-manager", "letsencrypt-issuer", "traefik"]);
result.IsSatisfied.Should().BeTrue();
}
[Fact]
public void CheckDependencies_IstioSatisfiesIngress()
{
// Installing istio should also satisfy the "one of ingress" requirement.
CatalogEntry entry = ComponentCatalog.GetByKey("kube-prometheus-stack")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(
entry, ["cert-manager", "letsencrypt-issuer", "istio"]);
result.IsSatisfied.Should().BeTrue();
}
[Fact]
public void CheckDependencies_IstioRequiresBase()
{
// The istio gateway depends on istio-base (istiod).
CatalogEntry entry = ComponentCatalog.GetByKey("istio")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []);
result.IsSatisfied.Should().BeFalse();
result.MissingDependencies.Should().Contain("istio-base");
}
[Fact]
public void CheckDependencies_IstioWithBase_Satisfied()
{
CatalogEntry entry = ComponentCatalog.GetByKey("istio")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, ["istio-base"]);
result.IsSatisfied.Should().BeTrue();
}
[Fact]
public void CheckDependencies_LetsEncryptRequiresCertManager()
{
CatalogEntry entry = ComponentCatalog.GetByKey("letsencrypt-issuer")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, []);
result.IsSatisfied.Should().BeFalse();
result.MissingDependencies.Should().Contain("cert-manager");
}
[Fact]
public void CheckDependencies_CaseInsensitive()
{
// Installed component names should match case-insensitively.
CatalogEntry entry = ComponentCatalog.GetByKey("letsencrypt-issuer")!;
DependencyCheckResult result = ComponentCatalog.CheckDependencies(entry, ["Cert-Manager"]);
result.IsSatisfied.Should().BeTrue();
}
[Fact]
public void Catalog_IstioBaseExists()
{
// Ensure the istio-base entry exists since istio depends on it.
CatalogEntry? entry = ComponentCatalog.GetByKey("istio-base");
entry.Should().NotBeNull();
entry!.HelmChartName.Should().Be("istiod");
}
[Fact]
public void Catalog_LetsEncryptIssuerExists()
{
CatalogEntry? entry = ComponentCatalog.GetByKey("letsencrypt-issuer");
entry.Should().NotBeNull();
entry!.Dependencies.Should().Contain("cert-manager");
}
}