235 lines
11 KiB
C#
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);
|
|
}
|
|
}
|