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"
}
};
}
}