Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/PlatformEventSamplesTests.cs

235 lines
11 KiB
C#

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<ReportDocumentDto>(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>() ?? string.Empty,
StringComparer.Ordinal).ToImmutableSortedDictionary(StringComparer.Ordinal)
: null;
OrchestratorEventScope? scope = null;
if (root["scope"] is JsonObject scopeObj)
{
scope = new OrchestratorEventScope
{
Namespace = scopeObj["namespace"]?.GetValue<string>(),
Repo = scopeObj["repo"]?.GetValue<string>() ?? string.Empty,
Digest = scopeObj["digest"]?.GetValue<string>() ?? string.Empty,
Component = scopeObj["component"]?.GetValue<string>(),
Image = scopeObj["image"]?.GetValue<string>()
};
}
var payloadNode = root["payload"] ?? throw new InvalidOperationException("Payload node missing.");
OrchestratorEventPayload payload = expectedKind switch
{
OrchestratorEventKinds.ScannerReportReady => payloadNode.Deserialize<ReportReadyEventPayload>(SerializerOptions)
?? throw new InvalidOperationException("Unable to deserialize report ready payload."),
OrchestratorEventKinds.ScannerScanCompleted => payloadNode.Deserialize<ScanCompletedEventPayload>(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<string>()),
Kind = root["kind"]!.GetValue<string>(),
Version = root["version"]?.GetValue<int>() ?? 1,
Tenant = root["tenant"]!.GetValue<string>(),
OccurredAt = DateTimeOffset.Parse(root["occurredAt"]!.GetValue<string>(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
RecordedAt = root["recordedAt"] is JsonValue recordedAtNode
? DateTimeOffset.Parse(recordedAtNode.GetValue<string>(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal)
: null,
Source = root["source"]?.GetValue<string>() ?? string.Empty,
IdempotencyKey = root["idempotencyKey"]?.GetValue<string>() ?? string.Empty,
CorrelationId = root["correlationId"]?.GetValue<string>(),
TraceId = root["traceId"]?.GetValue<string>(),
SpanId = root["spanId"]?.GetValue<string>(),
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);
}
}