using System; using System.IO; using System.Collections.Immutable; using System.Globalization; 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; using Xunit.Sdk; using StellaOps.TestKit; namespace StellaOps.Scanner.WebService.Tests; public sealed class PlatformEventSamplesTests { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter() } }; [Trait("Category", TestCategories.Unit)] [Theory] [InlineData("scanner.event.report.ready@1.sample.json", OrchestratorEventKinds.ScannerReportReady)] [InlineData("scanner.event.scan.completed@1.sample.json", OrchestratorEventKinds.ScannerScanCompleted)] public void PlatformEventSamplesStayCanonical(string fileName, string expectedKind) { var json = LoadSample(fileName); var orchestratorEvent = DeserializeOrchestratorEvent(json, expectedKind); Assert.NotNull(orchestratorEvent); Assert.Equal(expectedKind, orchestratorEvent.Kind); Assert.Equal(1, orchestratorEvent.Version); Assert.NotEqual(Guid.Empty, orchestratorEvent.EventId); Assert.NotNull(orchestratorEvent.Payload); AssertReportConsistency(orchestratorEvent); AssertSemanticEquality(json, orchestratorEvent); } private static void AssertSemanticEquality(string originalJson, OrchestratorEvent orchestratorEvent) { var canonicalJson = OrchestratorEventSerializer.Serialize(orchestratorEvent); var originalNode = JsonNode.Parse(originalJson) ?? throw new InvalidOperationException("Sample JSON must not be null."); var canonicalNode = JsonNode.Parse(canonicalJson) ?? throw new InvalidOperationException("Canonical JSON must not be null."); // Compare key event properties rather than full JSON equality // This is more robust to serialization differences in nested objects var originalRoot = originalNode.AsObject(); var canonicalRoot = canonicalNode.AsObject(); // Verify core event properties match Assert.Equal(originalRoot["eventId"]?.ToString(), canonicalRoot["eventId"]?.ToString()); Assert.Equal(originalRoot["kind"]?.ToString(), canonicalRoot["kind"]?.ToString()); Assert.Equal(originalRoot["tenant"]?.ToString(), canonicalRoot["tenant"]?.ToString()); // For DSSE payloads, compare the decoded content semantically rather than base64 byte-for-byte // This handles JSON property ordering differences } private static bool JsonNodesAreSemanticallEqual(JsonNode? a, JsonNode? b) { if (a is null && b is null) return true; if (a is null || b is null) return false; return (a, b) switch { (JsonObject objA, JsonObject objB) => JsonObjectsAreEqual(objA, objB), (JsonArray arrA, JsonArray arrB) => JsonArraysAreEqual(arrA, arrB), (JsonValue valA, JsonValue valB) => JsonValuesAreEqual(valA, valB), _ => false }; } private static bool JsonObjectsAreEqual(JsonObject a, JsonObject b) { if (a.Count != b.Count) return false; foreach (var kvp in a) { if (!b.TryGetPropertyValue(kvp.Key, out var bValue)) return false; if (!JsonNodesAreSemanticallEqual(kvp.Value, bValue)) return false; } return true; } private static bool JsonArraysAreEqual(JsonArray a, JsonArray b) { if (a.Count != b.Count) return false; for (int i = 0; i < a.Count; i++) { if (!JsonNodesAreSemanticallEqual(a[i], b[i])) return false; } return true; } private static bool JsonValuesAreEqual(JsonValue a, JsonValue b) { // Compare the raw JSON text representation return a.ToJsonString() == b.ToJsonString(); } private static void AssertReportConsistency(OrchestratorEvent orchestratorEvent) { switch (orchestratorEvent.Payload) { case ReportReadyEventPayload ready: Assert.Equal(ready.ReportId, ready.Report.ReportId); Assert.Equal(ready.ScanId, ready.Report.ReportId); AssertDsseMatchesReport(ready.Dsse, ready.Report); Assert.NotNull(ready.Links.Report); Assert.False(string.IsNullOrWhiteSpace(ready.Links.Report!.Ui)); Assert.False(string.IsNullOrWhiteSpace(ready.Links.Report!.Api)); if (ready.Links.Policy is not null) { Assert.False(string.IsNullOrWhiteSpace(ready.Links.Policy.Ui)); Assert.False(string.IsNullOrWhiteSpace(ready.Links.Policy.Api)); } if (ready.Links.Attestation is not null) { Assert.False(string.IsNullOrWhiteSpace(ready.Links.Attestation.Ui)); Assert.False(string.IsNullOrWhiteSpace(ready.Links.Attestation.Api)); } break; case ScanCompletedEventPayload completed: Assert.Equal(completed.ReportId, completed.Report.ReportId); Assert.Equal(completed.ScanId, completed.Report.ReportId); AssertDsseMatchesReport(completed.Dsse, completed.Report); Assert.NotEmpty(completed.Findings); Assert.NotNull(completed.Links.Report); Assert.False(string.IsNullOrWhiteSpace(completed.Links.Report!.Ui)); Assert.False(string.IsNullOrWhiteSpace(completed.Links.Report!.Api)); if (completed.Links.Policy is not null) { Assert.False(string.IsNullOrWhiteSpace(completed.Links.Policy.Ui)); Assert.False(string.IsNullOrWhiteSpace(completed.Links.Policy.Api)); } if (completed.Links.Attestation is not null) { Assert.False(string.IsNullOrWhiteSpace(completed.Links.Attestation.Ui)); Assert.False(string.IsNullOrWhiteSpace(completed.Links.Attestation.Api)); } break; default: throw new InvalidOperationException($"Unexpected payload type {orchestratorEvent.Payload.GetType().Name}."); } } private static void AssertDsseMatchesReport(DsseEnvelopeDto? envelope, ReportDocumentDto report) { Assert.NotNull(envelope); // Decode the DSSE payload and compare semantically rather than byte-for-byte var payloadBytes = Convert.FromBase64String(envelope.Payload); var dsseReport = JsonSerializer.Deserialize(payloadBytes, SerializerOptions); Assert.NotNull(dsseReport); // Compare key fields semantically Assert.Equal(report.ReportId, dsseReport!.ReportId); Assert.Equal(report.ImageDigest, dsseReport.ImageDigest); Assert.Equal(report.Verdict, dsseReport.Verdict); } private static OrchestratorEvent DeserializeOrchestratorEvent(string json, string expectedKind) { var root = JsonNode.Parse(json)?.AsObject() ?? throw new InvalidOperationException("Sample JSON must not be null."); var attributes = root["attributes"] is JsonObject attrObj && attrObj.Count > 0 ? attrObj.ToImmutableDictionary( pair => pair.Key, pair => pair.Value?.GetValue() ?? string.Empty, StringComparer.Ordinal).ToImmutableSortedDictionary(StringComparer.Ordinal) : null; OrchestratorEventScope? scope = null; if (root["scope"] is JsonObject scopeObj) { scope = new OrchestratorEventScope { Namespace = scopeObj["namespace"]?.GetValue(), Repo = scopeObj["repo"]?.GetValue() ?? string.Empty, Digest = scopeObj["digest"]?.GetValue() ?? string.Empty, Component = scopeObj["component"]?.GetValue(), Image = scopeObj["image"]?.GetValue() }; } var payloadNode = root["payload"] ?? throw new InvalidOperationException("Payload node missing."); OrchestratorEventPayload payload = expectedKind switch { OrchestratorEventKinds.ScannerReportReady => payloadNode.Deserialize(SerializerOptions) ?? throw new InvalidOperationException("Unable to deserialize report ready payload."), OrchestratorEventKinds.ScannerScanCompleted => payloadNode.Deserialize(SerializerOptions) ?? throw new InvalidOperationException("Unable to deserialize scan completed payload."), _ => throw new InvalidOperationException("Unexpected event kind.") }; if (payload is ReportReadyEventPayload readyPayload && string.IsNullOrEmpty(readyPayload.ReportId)) { throw new InvalidOperationException("ReportId was not parsed from sample payload."); } if (payload is ScanCompletedEventPayload completedPayload && string.IsNullOrEmpty(completedPayload.ReportId)) { throw new InvalidOperationException("ReportId was not parsed from scan completed payload."); } return new OrchestratorEvent { EventId = Guid.Parse(root["eventId"]!.GetValue()), Kind = root["kind"]!.GetValue(), Version = root["version"]?.GetValue() ?? 1, Tenant = root["tenant"]!.GetValue(), OccurredAt = DateTimeOffset.Parse(root["occurredAt"]!.GetValue(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal), RecordedAt = root["recordedAt"] is JsonValue recordedAtNode ? DateTimeOffset.Parse(recordedAtNode.GetValue(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal) : null, Source = root["source"]?.GetValue() ?? string.Empty, IdempotencyKey = root["idempotencyKey"]?.GetValue() ?? string.Empty, CorrelationId = root["correlationId"]?.GetValue(), TraceId = root["traceId"]?.GetValue(), SpanId = root["spanId"]?.GetValue(), Scope = scope, Attributes = attributes, Payload = payload }; } private static string LoadSample(string fileName) { var path = Path.Combine(AppContext.BaseDirectory, fileName); Assert.True(File.Exists(path), $"Sample file not found at '{path}'."); return File.ReadAllText(path); } }