using EntKube.Web.Data; using EntKube.Web.Services; using FluentAssertions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace EntKube.Web.Tests; /// /// Tests for PrometheusService — the service that connects to a kube-prometheus-stack /// running in a cluster and retrieves health/status metrics. These tests verify the /// data-layer plumbing (finding clusters, validating configuration) and response /// parsing. Actual Prometheus queries require a live cluster. /// public class PrometheusServiceTests : IDisposable { private readonly SqliteConnection connection; private readonly ApplicationDbContext db; private readonly TestDbContextFactory dbFactory; private readonly PrometheusService sut; public PrometheusServiceTests() { 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(); sut = new PrometheusService(dbFactory); } public void Dispose() { db.Dispose(); connection.Dispose(); } // ──────── GetClusterHealthAsync — failure paths ──────── [Fact] public async Task GetClusterHealthAsync_ClusterNotFound_ReturnsFailure() { // Arrange — use a random cluster ID that doesn't exist in the database. Guid nonExistentId = Guid.NewGuid(); // Act KubernetesOperationResult result = await sut.GetClusterHealthAsync(nonExistentId); // Assert — should fail gracefully with a clear message. result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain("not found"); } [Fact] public async Task GetClusterHealthAsync_NoPrometheusComponent_ReturnsFailure() { // Arrange — cluster exists but has no prometheus component configured. Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestTenant", Slug = "test" }; Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "prod" }; KubernetesCluster cluster = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Name = "prod-cluster", ApiServerUrl = "https://k8s.example.com", Kubeconfig = "apiVersion: v1\nkind: Config" }; db.Set().Add(tenant); db.Set().Add(env); db.KubernetesClusters.Add(cluster); await db.SaveChangesAsync(); // Act KubernetesOperationResult result = await sut.GetClusterHealthAsync(cluster.Id); // Assert — no prometheus component means we can't query metrics. result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain("prometheus"); } [Fact] public async Task GetClusterHealthAsync_NoKubeconfig_ReturnsFailure() { // Arrange — cluster has prometheus component but no kubeconfig stored. Tenant tenant = new() { Id = Guid.NewGuid(), Name = "TestTenant2", Slug = "test2" }; Data.Environment env = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, Name = "prod" }; KubernetesCluster cluster = new() { Id = Guid.NewGuid(), TenantId = tenant.Id, EnvironmentId = env.Id, Name = "no-kubeconfig-cluster", ApiServerUrl = "https://k8s.example.com", Kubeconfig = null }; ClusterComponent prometheusComponent = new() { Id = Guid.NewGuid(), ClusterId = cluster.Id, Name = "kube-prometheus-stack", ComponentType = "HelmChart", Configuration = """{"namespace":"monitoring","serviceName":"prometheus-kube-prometheus-prometheus","servicePort":9090}""" }; db.Set().Add(tenant); db.Set().Add(env); db.KubernetesClusters.Add(cluster); db.ClusterComponents.Add(prometheusComponent); await db.SaveChangesAsync(); // Act KubernetesOperationResult result = await sut.GetClusterHealthAsync(cluster.Id); // Assert — without kubeconfig we can't connect to the cluster. result.IsSuccess.Should().BeFalse(); result.Error.Should().Contain("kubeconfig"); } // ──────── ParsePrometheusResponse — parsing metric results ──────── [Fact] public void ParseInstantQueryResult_ValidVectorResponse_ReturnsValues() { // Arrange — a typical Prometheus instant query response for `up` metric. string json = """ { "status": "success", "data": { "resultType": "vector", "result": [ {"metric": {"job": "node-exporter", "instance": "node1:9100"}, "value": [1700000000, "1"]}, {"metric": {"job": "node-exporter", "instance": "node2:9100"}, "value": [1700000000, "1"]}, {"metric": {"job": "node-exporter", "instance": "node3:9100"}, "value": [1700000000, "0"]} ] } } """; // Act List results = PrometheusService.ParseInstantQueryResult(json); // Assert — three results parsed, with correct values. results.Should().HaveCount(3); results[0].Value.Should().Be(1.0); results[1].Value.Should().Be(1.0); results[2].Value.Should().Be(0.0); results[0].Labels.Should().ContainKey("instance").WhoseValue.Should().Be("node1:9100"); } [Fact] public void ParseInstantQueryResult_EmptyResult_ReturnsEmptyList() { // Arrange — Prometheus returns success but no matching series. string json = """ { "status": "success", "data": { "resultType": "vector", "result": [] } } """; // Act List results = PrometheusService.ParseInstantQueryResult(json); // Assert results.Should().BeEmpty(); } [Fact] public void ParseInstantQueryResult_ErrorResponse_ReturnsEmptyList() { // Arrange — Prometheus returns an error status. string json = """ { "status": "error", "errorType": "bad_data", "error": "invalid expression" } """; // Act List results = PrometheusService.ParseInstantQueryResult(json); // Assert — graceful degradation, no exception thrown. results.Should().BeEmpty(); } [Fact] public void ParseInstantQueryResult_ScalarResponse_ReturnsSingleValue() { // Arrange — a scalar query result (e.g. `count(up)`). string json = """ { "status": "success", "data": { "resultType": "scalar", "result": [1700000000, "42"] } } """; // Act List results = PrometheusService.ParseInstantQueryResult(json); // Assert results.Should().HaveCount(1); results[0].Value.Should().Be(42.0); } // ──────── GetPrometheusConfig — extracting config from component ──────── [Fact] public void GetPrometheusConfig_ValidJson_ReturnsConfig() { // Arrange — a component with properly formatted configuration JSON. ClusterComponent component = new() { Id = Guid.NewGuid(), ClusterId = Guid.NewGuid(), Name = "kube-prometheus-stack", ComponentType = "HelmChart", Configuration = """{"namespace":"monitoring","serviceName":"prometheus-kube-prometheus-prometheus","servicePort":9090}""" }; // Act PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component); // Assert config.Should().NotBeNull(); config!.Namespace.Should().Be("monitoring"); config.ServiceName.Should().Be("prometheus-kube-prometheus-prometheus"); config.ServicePort.Should().Be(9090); } [Fact] public void GetPrometheusConfig_NullConfiguration_ReturnsDefaultConfig() { // Arrange — component exists but has no configuration set yet. // We should return sensible defaults for kube-prometheus-stack. ClusterComponent component = new() { Id = Guid.NewGuid(), ClusterId = Guid.NewGuid(), Name = "kube-prometheus-stack", ComponentType = "HelmChart", Configuration = null }; // Act PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component); // Assert — defaults for standard kube-prometheus-stack helm install. config.Should().NotBeNull(); config!.Namespace.Should().Be("monitoring"); config.ServiceName.Should().Be("prometheus-kube-prometheus-prometheus"); config.ServicePort.Should().Be(9090); } [Fact] public void GetPrometheusConfig_InvalidJson_ReturnsDefaultConfig() { // Arrange — configuration is corrupt JSON. ClusterComponent component = new() { Id = Guid.NewGuid(), ClusterId = Guid.NewGuid(), Name = "kube-prometheus-stack", ComponentType = "HelmChart", Configuration = "not valid json {" }; // Act PrometheusConfig? config = PrometheusService.GetPrometheusConfig(component); // Assert — graceful fallback to defaults. config.Should().NotBeNull(); config!.Namespace.Should().Be("monitoring"); } // ──────── ParseRangeQueryResult — time-series parsing ──────── [Fact] public void ParseRangeQueryResult_ValidMatrixResponse_ReturnsTimeSeries() { // Arrange — a typical Prometheus range query response with matrix data. // Each result is a series with multiple [timestamp, value] pairs over time. string json = """ { "status": "success", "data": { "resultType": "matrix", "result": [ { "metric": {"instance": "node1:9100"}, "values": [ [1700000000, "45.2"], [1700000060, "47.1"], [1700000120, "43.8"], [1700000180, "50.3"] ] } ] } } """; // Act List results = PrometheusService.ParseRangeQueryResult(json); // Assert — one series with four data points. results.Should().HaveCount(1); results[0].Labels.Should().ContainKey("instance").WhoseValue.Should().Be("node1:9100"); results[0].DataPoints.Should().HaveCount(4); results[0].DataPoints[0].Value.Should().BeApproximately(45.2, 0.01); results[0].DataPoints[3].Value.Should().BeApproximately(50.3, 0.01); results[0].DataPoints[0].Timestamp.Should().BeAfter(DateTime.UnixEpoch); } [Fact] public void ParseRangeQueryResult_MultipleSeriesResponse_ReturnsAll() { // Arrange — two series (e.g. CPU per node) in a range query. string json = """ { "status": "success", "data": { "resultType": "matrix", "result": [ { "metric": {"node": "node1"}, "values": [[1700000000, "30.0"], [1700000060, "35.0"]] }, { "metric": {"node": "node2"}, "values": [[1700000000, "60.0"], [1700000060, "62.5"]] } ] } } """; // Act List results = PrometheusService.ParseRangeQueryResult(json); // Assert results.Should().HaveCount(2); results[0].Labels["node"].Should().Be("node1"); results[1].Labels["node"].Should().Be("node2"); results[1].DataPoints[1].Value.Should().BeApproximately(62.5, 0.01); } [Fact] public void ParseRangeQueryResult_EmptyResult_ReturnsEmptyList() { // Arrange string json = """ { "status": "success", "data": { "resultType": "matrix", "result": [] } } """; // Act List results = PrometheusService.ParseRangeQueryResult(json); // Assert results.Should().BeEmpty(); } // ──────── ParseAlertmanagerAlerts — alertmanager response parsing ──────── [Fact] public void ParseAlertmanagerAlerts_ValidResponse_ReturnsAlerts() { // Arrange — Alertmanager /api/v2/alerts returns an array of alert objects. string json = """ [ { "labels": {"alertname": "HighCPU", "severity": "warning", "instance": "node1:9100"}, "annotations": {"summary": "CPU usage is above 90%", "description": "Node node1 has high CPU load"}, "startsAt": "2026-05-16T10:30:00Z", "endsAt": "0001-01-01T00:00:00Z", "status": {"state": "active"}, "fingerprint": "abc123" }, { "labels": {"alertname": "PodCrashLooping", "severity": "critical", "namespace": "default", "pod": "api-7f8b9-xyz"}, "annotations": {"summary": "Pod is crash looping"}, "startsAt": "2026-05-16T09:15:00Z", "endsAt": "0001-01-01T00:00:00Z", "status": {"state": "active"}, "fingerprint": "def456" } ] """; // Act List alerts = PrometheusService.ParseAlertmanagerAlerts(json); // Assert — two alerts parsed with correct severity and labels. alerts.Should().HaveCount(2); alerts[0].Name.Should().Be("HighCPU"); alerts[0].Severity.Should().Be("warning"); alerts[0].Summary.Should().Contain("CPU usage"); alerts[0].State.Should().Be("active"); alerts[0].Labels.Should().ContainKey("instance"); alerts[1].Name.Should().Be("PodCrashLooping"); alerts[1].Severity.Should().Be("critical"); } [Fact] public void ParseAlertmanagerAlerts_EmptyArray_ReturnsEmptyList() { // Arrange string json = "[]"; // Act List alerts = PrometheusService.ParseAlertmanagerAlerts(json); // Assert alerts.Should().BeEmpty(); } [Fact] public void ParseAlertmanagerAlerts_InvalidJson_ReturnsEmptyList() { // Arrange — corrupt JSON should degrade gracefully. string json = "not valid json {"; // Act List alerts = PrometheusService.ParseAlertmanagerAlerts(json); // Assert alerts.Should().BeEmpty(); } // ──────── ParseAlertmanagerSilences — silence parsing ──────── [Fact] public void ParseAlertmanagerSilences_ValidResponse_ReturnsSilences() { // Arrange — Alertmanager /api/v2/silences response. string json = """ [ { "id": "silence-001", "status": {"state": "active"}, "comment": "Maintenance window for node upgrade", "createdBy": "ops-team", "startsAt": "2026-05-16T08:00:00Z", "endsAt": "2026-05-16T12:00:00Z", "matchers": [ {"name": "instance", "value": "node1:9100", "isRegex": false, "isEqual": true} ] }, { "id": "silence-002", "status": {"state": "expired"}, "comment": "Past maintenance", "createdBy": "admin", "startsAt": "2026-05-15T00:00:00Z", "endsAt": "2026-05-15T04:00:00Z", "matchers": [ {"name": "alertname", "value": "Watchdog", "isRegex": false, "isEqual": true} ] } ] """; // Act List silences = PrometheusService.ParseAlertmanagerSilences(json); // Assert — two silences, one active and one expired. silences.Should().HaveCount(2); silences[0].Id.Should().Be("silence-001"); silences[0].State.Should().Be("active"); silences[0].Comment.Should().Contain("Maintenance"); silences[0].CreatedBy.Should().Be("ops-team"); silences[0].Matchers.Should().HaveCount(1); silences[0].Matchers[0].Name.Should().Be("instance"); silences[1].State.Should().Be("expired"); } }