using EntKube.Web.Data; using EntKube.Web.Services; using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace EntKube.Web.Tests; /// /// Tests for ExternalRouteService — manages exposing components externally /// via Gateway API HTTPRoutes. Covers route creation, validation, duplicate /// detection, YAML generation, and gateway resolution. /// public class ExternalRouteServiceTests : IDisposable { private readonly SqliteConnection connection; private readonly ApplicationDbContext db; private readonly TestDbContextFactory dbFactory; private readonly ExternalRouteService sut; private readonly Guid clusterId = Guid.NewGuid(); private readonly Guid componentId = Guid.NewGuid(); public ExternalRouteServiceTests() { 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 cluster with traefik installed and a monitoring component. Guid tenantId = Guid.NewGuid(); Guid envId = Guid.NewGuid(); Tenant tenant = new() { Id = tenantId, Name = "RouteTenant", Slug = "route" }; Data.Environment env = new() { Id = envId, TenantId = tenantId, Name = "production" }; KubernetesCluster cluster = new() { Id = clusterId, TenantId = tenantId, EnvironmentId = envId, Name = "route-cluster", ApiServerUrl = "https://k8s.example.com", Kubeconfig = "apiVersion: v1\nkind: Config" }; ClusterComponent traefik = new() { Id = Guid.NewGuid(), ClusterId = clusterId, Name = "traefik", ComponentType = "HelmChart", Namespace = "traefik", Status = ComponentStatus.Installed }; ClusterComponent monitoring = new() { Id = componentId, ClusterId = clusterId, Name = "kube-prometheus-stack", ComponentType = "HelmChart", Namespace = "monitoring", ReleaseName = "kube-prometheus-stack", Status = ComponentStatus.Installed }; db.Set().Add(tenant); db.Set().Add(env); db.KubernetesClusters.Add(cluster); db.ClusterComponents.AddRange(traefik, monitoring); db.SaveChanges(); sut = new ExternalRouteService(dbFactory); } public void Dispose() { db.Dispose(); connection.Dispose(); } // ──────── Route creation ──────── [Fact] public async Task AddRoute_WithClusterIssuer_CreatesRoute() { // The simplest happy path — expose Grafana with Let's Encrypt. ExternalRouteRequest request = new() { Hostname = "grafana.example.com", ServiceName = "kube-prometheus-stack-grafana", ServicePort = 80, TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; ExternalRoute route = await sut.AddRouteAsync(componentId, request); route.Hostname.Should().Be("grafana.example.com"); route.ServiceName.Should().Be("kube-prometheus-stack-grafana"); route.ServicePort.Should().Be(80); route.TlsMode.Should().Be(TlsMode.ClusterIssuer); route.ClusterIssuerName.Should().Be("letsencrypt-prod"); route.GatewayName.Should().Be("traefik-gateway"); route.GatewayNamespace.Should().Be("traefik"); } [Fact] public async Task AddRoute_WithManualTls_CreatesRoute() { // Manual TLS — operator provides their own certificate. ExternalRouteRequest request = new() { Hostname = "prometheus.example.com", ServicePort = 9090, TlsMode = TlsMode.Manual, TlsCertificate = "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", TlsPrivateKey = "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----" }; ExternalRoute route = await sut.AddRouteAsync(componentId, request); route.TlsMode.Should().Be(TlsMode.Manual); route.TlsCertificate.Should().StartWith("-----BEGIN CERTIFICATE-----"); route.TlsPrivateKey.Should().StartWith("-----BEGIN PRIVATE KEY-----"); } [Fact] public async Task AddRoute_DefaultsServiceNameFromComponent() { // When no service name is specified, use the component's release name. ExternalRouteRequest request = new() { Hostname = "alerts.example.com", ServicePort = 9093, TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; ExternalRoute route = await sut.AddRouteAsync(componentId, request); route.ServiceName.Should().Be("kube-prometheus-stack"); } // ──────── Validation ──────── [Fact] public async Task AddRoute_EmptyHostname_Throws() { ExternalRouteRequest request = new() { Hostname = " ", TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; Func act = () => sut.AddRouteAsync(componentId, request); await act.Should().ThrowAsync() .WithMessage("*Hostname is required*"); } [Fact] public async Task AddRoute_ClusterIssuer_MissingIssuerName_Throws() { ExternalRouteRequest request = new() { Hostname = "app.example.com", TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = null }; Func act = () => sut.AddRouteAsync(componentId, request); await act.Should().ThrowAsync() .WithMessage("*ClusterIssuer name is required*"); } [Fact] public async Task AddRoute_ManualTls_MissingCert_Throws() { ExternalRouteRequest request = new() { Hostname = "app.example.com", TlsMode = TlsMode.Manual, TlsCertificate = null }; Func act = () => sut.AddRouteAsync(componentId, request); await act.Should().ThrowAsync() .WithMessage("*TLS certificate is required*"); } [Fact] public async Task AddRoute_DuplicateHostname_Throws() { // Can't use the same hostname twice on the same cluster. ExternalRouteRequest request = new() { Hostname = "unique.example.com", TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; await sut.AddRouteAsync(componentId, request); Func duplicate = () => sut.AddRouteAsync(componentId, request); await duplicate.Should().ThrowAsync() .WithMessage("*already in use*"); } // ──────── Route retrieval and deletion ──────── [Fact] public async Task GetRoutes_ReturnsComponentRoutes() { ExternalRouteRequest request1 = new() { Hostname = "a.example.com", TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; ExternalRouteRequest request2 = new() { Hostname = "b.example.com", TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; await sut.AddRouteAsync(componentId, request1); await sut.AddRouteAsync(componentId, request2); List routes = await sut.GetRoutesAsync(componentId); routes.Should().HaveCount(2); routes.Select(r => r.Hostname).Should().BeEquivalentTo(["a.example.com", "b.example.com"]); } [Fact] public async Task DeleteRoute_RemovesRoute() { ExternalRouteRequest request = new() { Hostname = "deleteme.example.com", TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; ExternalRoute route = await sut.AddRouteAsync(componentId, request); await sut.DeleteRouteAsync(route.Id); List routes = await sut.GetRoutesAsync(componentId); routes.Should().BeEmpty(); } // ──────── YAML generation ──────── [Fact] public async Task GenerateHttpRouteYaml_ClusterIssuer_IncludesAnnotation() { ExternalRouteRequest request = new() { Hostname = "grafana.example.com", ServiceName = "kube-prometheus-stack-grafana", ServicePort = 80, TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; ExternalRoute route = await sut.AddRouteAsync(componentId, request); string yaml = await sut.GenerateHttpRouteYamlAsync(route.Id); yaml.Should().Contain("kind: HTTPRoute"); yaml.Should().Contain("grafana.example.com"); yaml.Should().Contain("cert-manager.io/cluster-issuer: \"letsencrypt-prod\""); yaml.Should().Contain("name: traefik-gateway"); yaml.Should().Contain("kube-prometheus-stack-grafana"); } [Fact] public async Task GenerateHttpRouteYaml_ManualTls_ReferencesSecret() { ExternalRouteRequest request = new() { Hostname = "manual.example.com", ServiceName = "my-service", ServicePort = 443, TlsMode = TlsMode.Manual, TlsCertificate = "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----" }; ExternalRoute route = await sut.AddRouteAsync(componentId, request); string yaml = await sut.GenerateHttpRouteYamlAsync(route.Id); yaml.Should().Contain("kind: HTTPRoute"); yaml.Should().Contain("manual.example.com"); yaml.Should().Contain("my-service-tls"); yaml.Should().Contain("kind: Secret"); } [Fact] public void GenerateTlsSecretYaml_ManualMode_GeneratesSecret() { ExternalRoute route = new() { Id = Guid.NewGuid(), ComponentId = componentId, Hostname = "secure.example.com", ServiceName = "my-svc", ServicePort = 443, TlsMode = TlsMode.Manual, TlsCertificate = "CERT_DATA", TlsPrivateKey = "KEY_DATA", Component = new ClusterComponent { Id = componentId, ClusterId = clusterId, Name = "test", ComponentType = "HelmChart", Namespace = "apps" } }; string yaml = ExternalRouteService.GenerateTlsSecretYaml(route); yaml.Should().Contain("kind: Secret"); yaml.Should().Contain("type: kubernetes.io/tls"); yaml.Should().Contain("namespace: apps"); yaml.Should().Contain("my-svc-tls"); } [Fact] public void GenerateTlsSecretYaml_ClusterIssuerMode_ReturnsEmpty() { // No Secret needed for automatic TLS — cert-manager handles it. ExternalRoute route = new() { Id = Guid.NewGuid(), ComponentId = componentId, Hostname = "auto.example.com", ServiceName = "svc", ServicePort = 80, TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; string yaml = ExternalRouteService.GenerateTlsSecretYaml(route); yaml.Should().BeEmpty(); } // ──────── Gateway resolution ──────── [Fact] public async Task AddRoute_ResolvesTraefikGateway() { // Cluster has Traefik installed, so gateway should resolve to traefik-gateway. ExternalRouteRequest request = new() { Hostname = "gw-test.example.com", TlsMode = TlsMode.ClusterIssuer, ClusterIssuerName = "letsencrypt-prod" }; ExternalRoute route = await sut.AddRouteAsync(componentId, request); route.GatewayName.Should().Be("traefik-gateway"); route.GatewayNamespace.Should().Be("traefik"); } }