using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using StellaOps.Scanner.WebService.Contracts; using StellaOps.Scanner.WebService.Serialization; namespace StellaOps.Scanner.WebService.Tests; /// /// Tests verifying Notifier service can ingest scanner events per orchestrator-envelope.schema.json. /// public sealed class NotifierIngestionTests { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; [Fact] public void NotifierMetadata_SerializesCorrectly() { var metadata = new NotifierIngestionMetadata { SeverityThresholdMet = true, NotificationChannels = new[] { "email", "slack" }, DigestEligible = false, ImmediateDispatch = true, Priority = "critical" }; var orchestratorEvent = CreateTestEvent(metadata); var json = OrchestratorEventSerializer.Serialize(orchestratorEvent); var node = JsonNode.Parse(json)?.AsObject(); Assert.NotNull(node); Assert.NotNull(node["notifier"]); var notifierNode = node["notifier"]!.AsObject(); Assert.True(notifierNode["severityThresholdMet"]?.GetValue()); Assert.False(notifierNode["digestEligible"]?.GetValue()); Assert.True(notifierNode["immediateDispatch"]?.GetValue()); Assert.Equal("critical", notifierNode["priority"]?.GetValue()); var channels = notifierNode["notificationChannels"]?.AsArray(); Assert.NotNull(channels); Assert.Equal(2, channels.Count); Assert.Contains("email", channels.Select(c => c?.GetValue())); Assert.Contains("slack", channels.Select(c => c?.GetValue())); } [Fact] public void NotifierMetadata_OmittedWhenNull() { var orchestratorEvent = new OrchestratorEvent { EventId = Guid.NewGuid(), Kind = OrchestratorEventKinds.ScannerReportReady, Version = 1, Tenant = "test-tenant", OccurredAt = DateTimeOffset.UtcNow, Source = "scanner.webservice", IdempotencyKey = "test-key", Payload = new ReportReadyEventPayload { ReportId = "report-123", ImageDigest = "sha256:abc123", GeneratedAt = DateTimeOffset.UtcNow, Verdict = "pass", Summary = new ReportSummaryDto(), Policy = new ReportPolicyDto(), Links = new ReportLinksPayload(), Report = new ReportDocumentDto() }, Notifier = null // Explicitly null }; var json = OrchestratorEventSerializer.Serialize(orchestratorEvent); var node = JsonNode.Parse(json)?.AsObject(); Assert.NotNull(node); Assert.Null(node["notifier"]); // Should be omitted when null } [Theory] [InlineData("critical", true, true)] [InlineData("high", true, false)] [InlineData("medium", false, false)] [InlineData("low", false, false)] public void NotifierMetadata_SeverityThresholdCalculation(string severity, bool expectedThresholdMet, bool expectedImmediate) { var metadata = CreateNotifierMetadataForSeverity(severity); Assert.Equal(expectedThresholdMet, metadata.SeverityThresholdMet); Assert.Equal(expectedImmediate, metadata.ImmediateDispatch); } [Fact] public void ScanStartedEvent_SerializesForNotifier() { var orchestratorEvent = new OrchestratorEvent { EventId = Guid.NewGuid(), Kind = OrchestratorEventKinds.ScannerScanStarted, Version = 1, Tenant = "test-tenant", OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"), Source = "scanner.webservice", IdempotencyKey = "scanner.event.scan.started:test-tenant:scan-001", Payload = new ScanStartedEventPayload { ScanId = "scan-001", JobId = "job-001", Target = new ScanTargetPayload { Type = "container_image", Identifier = "registry.example/app:v1.0.0", Digest = "sha256:abc123def456" }, StartedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"), Status = "started" }, Notifier = new NotifierIngestionMetadata { SeverityThresholdMet = false, DigestEligible = true, ImmediateDispatch = false } }; var json = OrchestratorEventSerializer.Serialize(orchestratorEvent); var node = JsonNode.Parse(json)?.AsObject(); Assert.NotNull(node); Assert.Equal(OrchestratorEventKinds.ScannerScanStarted, node["kind"]?.GetValue()); var payload = node["payload"]?.AsObject(); Assert.NotNull(payload); Assert.Equal("scan-001", payload["scanId"]?.GetValue()); Assert.Equal("started", payload["status"]?.GetValue()); var target = payload["target"]?.AsObject(); Assert.NotNull(target); Assert.Equal("container_image", target["type"]?.GetValue()); } [Fact] public void ScanFailedEvent_SerializesWithErrorDetails() { var orchestratorEvent = new OrchestratorEvent { EventId = Guid.NewGuid(), Kind = OrchestratorEventKinds.ScannerScanFailed, Version = 1, Tenant = "test-tenant", OccurredAt = DateTimeOffset.Parse("2025-12-07T10:05:00Z"), Source = "scanner.webservice", IdempotencyKey = "scanner.event.scan.failed:test-tenant:scan-002", Payload = new ScanFailedEventPayload { ScanId = "scan-002", Target = new ScanTargetPayload { Type = "container_image", Identifier = "registry.example/broken:latest" }, StartedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"), FailedAt = DateTimeOffset.Parse("2025-12-07T10:05:00Z"), DurationMs = 300000, Status = "failed", Error = new ScanErrorPayload { Code = "IMAGE_PULL_FAILED", Message = "Unable to pull image: authentication required", Details = ImmutableDictionary.CreateRange(new[] { KeyValuePair.Create("registry", "registry.example"), KeyValuePair.Create("httpStatus", "401") }), Recoverable = true } }, Notifier = new NotifierIngestionMetadata { SeverityThresholdMet = true, NotificationChannels = new[] { "email", "slack", "pagerduty" }, DigestEligible = false, ImmediateDispatch = true, Priority = "high" } }; var json = OrchestratorEventSerializer.Serialize(orchestratorEvent); var node = JsonNode.Parse(json)?.AsObject(); Assert.NotNull(node); Assert.Equal(OrchestratorEventKinds.ScannerScanFailed, node["kind"]?.GetValue()); var payload = node["payload"]?.AsObject(); Assert.NotNull(payload); Assert.Equal("failed", payload["status"]?.GetValue()); Assert.Equal(300000, payload["durationMs"]?.GetValue()); var error = payload["error"]?.AsObject(); Assert.NotNull(error); Assert.Equal("IMAGE_PULL_FAILED", error["code"]?.GetValue()); Assert.True(error["recoverable"]?.GetValue()); var notifier = node["notifier"]?.AsObject(); Assert.NotNull(notifier); Assert.True(notifier["immediateDispatch"]?.GetValue()); } [Fact] public void VulnerabilityDetectedEvent_SerializesForNotifier() { var orchestratorEvent = new OrchestratorEvent { EventId = Guid.NewGuid(), Kind = OrchestratorEventKinds.ScannerVulnerabilityDetected, Version = 1, Tenant = "test-tenant", OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"), Source = "scanner.webservice", IdempotencyKey = "scanner.event.vulnerability.detected:test-tenant:CVE-2024-9999:pkg:npm/lodash@4.17.20", Payload = new VulnerabilityDetectedEventPayload { ScanId = "scan-001", Vulnerability = new VulnerabilityInfoPayload { Id = "CVE-2024-9999", Severity = "critical", CvssScore = 9.8, CvssVector = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", Title = "Remote Code Execution in lodash", FixAvailable = true, FixedVersion = "4.17.21", KevListed = true, EpssScore = 0.95 }, AffectedComponent = new ComponentInfoPayload { Purl = "pkg:npm/lodash@4.17.20", Name = "lodash", Version = "4.17.20", Ecosystem = "npm", Location = "/app/node_modules/lodash" }, Reachability = "reachable", DetectedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z") }, Notifier = new NotifierIngestionMetadata { SeverityThresholdMet = true, NotificationChannels = new[] { "email", "slack", "pagerduty" }, DigestEligible = false, ImmediateDispatch = true, Priority = "critical" } }; var json = OrchestratorEventSerializer.Serialize(orchestratorEvent); var node = JsonNode.Parse(json)?.AsObject(); Assert.NotNull(node); Assert.Equal(OrchestratorEventKinds.ScannerVulnerabilityDetected, node["kind"]?.GetValue()); var payload = node["payload"]?.AsObject(); Assert.NotNull(payload); var vuln = payload["vulnerability"]?.AsObject(); Assert.NotNull(vuln); Assert.Equal("CVE-2024-9999", vuln["id"]?.GetValue()); Assert.Equal("critical", vuln["severity"]?.GetValue()); Assert.Equal(9.8, vuln["cvssScore"]?.GetValue()); Assert.True(vuln["kevListed"]?.GetValue()); var component = payload["affectedComponent"]?.AsObject(); Assert.NotNull(component); Assert.Equal("pkg:npm/lodash@4.17.20", component["purl"]?.GetValue()); Assert.Equal("reachable", payload["reachability"]?.GetValue()); } [Fact] public void SbomGeneratedEvent_SerializesForNotifier() { var orchestratorEvent = new OrchestratorEvent { EventId = Guid.NewGuid(), Kind = OrchestratorEventKinds.ScannerSbomGenerated, Version = 1, Tenant = "test-tenant", OccurredAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"), Source = "scanner.webservice", IdempotencyKey = "scanner.event.sbom.generated:test-tenant:sbom-001", Payload = new SbomGeneratedEventPayload { ScanId = "scan-001", SbomId = "sbom-001", Target = new ScanTargetPayload { Type = "container_image", Identifier = "registry.example/app:v1.0.0", Digest = "sha256:abc123def456" }, GeneratedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"), Format = "cyclonedx", SpecVersion = "1.6", ComponentCount = 127, SbomRef = "s3://sboms/sbom-001.json", Digest = "sha256:sbom-digest-789" }, Notifier = new NotifierIngestionMetadata { SeverityThresholdMet = false, DigestEligible = true, ImmediateDispatch = false } }; var json = OrchestratorEventSerializer.Serialize(orchestratorEvent); var node = JsonNode.Parse(json)?.AsObject(); Assert.NotNull(node); Assert.Equal(OrchestratorEventKinds.ScannerSbomGenerated, node["kind"]?.GetValue()); var payload = node["payload"]?.AsObject(); Assert.NotNull(payload); Assert.Equal("sbom-001", payload["sbomId"]?.GetValue()); Assert.Equal("cyclonedx", payload["format"]?.GetValue()); Assert.Equal("1.6", payload["specVersion"]?.GetValue()); Assert.Equal(127, payload["componentCount"]?.GetValue()); } [Fact] public void AllEventKinds_HaveCorrectFormat() { Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerReportReady); Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanCompleted); Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanStarted); Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerScanFailed); Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerSbomGenerated); Assert.Matches(@"^scanner\.event\.[a-z]+\.[a-z]+$", OrchestratorEventKinds.ScannerVulnerabilityDetected); } [Fact] public void NotifierChannels_SupportAllChannelTypes() { var validChannels = new[] { "email", "slack", "teams", "webhook", "pagerduty" }; foreach (var channel in validChannels) { var metadata = new NotifierIngestionMetadata { SeverityThresholdMet = true, NotificationChannels = new[] { channel }, DigestEligible = true, ImmediateDispatch = false }; var orchestratorEvent = CreateTestEvent(metadata); var json = OrchestratorEventSerializer.Serialize(orchestratorEvent); var node = JsonNode.Parse(json)?.AsObject(); Assert.NotNull(node); var notifier = node["notifier"]?.AsObject(); Assert.NotNull(notifier); var channels = notifier["notificationChannels"]?.AsArray(); Assert.NotNull(channels); Assert.Contains(channel, channels.Select(c => c?.GetValue())); } } private static OrchestratorEvent CreateTestEvent(NotifierIngestionMetadata? notifier) { return new OrchestratorEvent { EventId = Guid.NewGuid(), Kind = OrchestratorEventKinds.ScannerReportReady, Version = 1, Tenant = "test-tenant", OccurredAt = DateTimeOffset.UtcNow, Source = "scanner.webservice", IdempotencyKey = "test-key", Payload = new ReportReadyEventPayload { ReportId = "report-123", ImageDigest = "sha256:abc123", GeneratedAt = DateTimeOffset.UtcNow, Verdict = "pass", Summary = new ReportSummaryDto(), Policy = new ReportPolicyDto(), Links = new ReportLinksPayload(), Report = new ReportDocumentDto() }, Notifier = notifier }; } private static NotifierIngestionMetadata CreateNotifierMetadataForSeverity(string severity) { return severity.ToLowerInvariant() switch { "critical" => new NotifierIngestionMetadata { SeverityThresholdMet = true, NotificationChannels = new[] { "email", "slack", "pagerduty" }, DigestEligible = false, ImmediateDispatch = true, Priority = "critical" }, "high" => new NotifierIngestionMetadata { SeverityThresholdMet = true, NotificationChannels = new[] { "email", "slack" }, DigestEligible = false, ImmediateDispatch = false, Priority = "high" }, _ => new NotifierIngestionMetadata { SeverityThresholdMet = false, DigestEligible = true, ImmediateDispatch = false, Priority = "normal" } }; } }