using System.Security.Cryptography; using System.Text; using System.Text.Json.Nodes; using FluentAssertions; using StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Hashing; using StellaOps.Findings.Ledger.Infrastructure.Policy; using StellaOps.Findings.Ledger.Services; using Xunit; using StellaOps.TestKit; namespace StellaOps.Findings.Ledger.Tests; public sealed class LedgerProjectionReducerTests { [Trait("Category", TestCategories.Unit)] [Fact] public void Reduce_WhenFindingCreated_InitialisesProjection() { var payload = new JsonObject { ["status"] = "triaged", ["severity"] = 6.5, ["labels"] = new JsonObject { ["kev"] = true, ["runtime"] = "exposed" }, ["explainRef"] = "explain://tenant-a/finding/123" }; var record = CreateRecord(LedgerEventConstants.EventFindingCreated, payload); var evaluation = new PolicyEvaluationResult( "triaged", 6.5m, null, null, null, null, 1, (JsonObject)payload["labels"]!.DeepClone(), payload["explainRef"]!.GetValue(), new JsonArray(payload["explainRef"]!.GetValue())); var result = LedgerProjectionReducer.Reduce(record, current: null, evaluation); result.Projection.Status.Should().Be("triaged"); result.Projection.Severity.Should().Be(6.5m); result.Projection.Labels["kev"]!.GetValue().Should().BeTrue(); result.Projection.Labels["runtime"]!.GetValue().Should().Be("exposed"); result.Projection.ExplainRef.Should().Be("explain://tenant-a/finding/123"); result.Projection.PolicyRationale.Should().ContainSingle() .Which!.GetValue().Should().Be("explain://tenant-a/finding/123"); result.Projection.CycleHash.Should().NotBeNullOrWhiteSpace(); ProjectionHashing.ComputeCycleHash(result.Projection).Should().Be(result.Projection.CycleHash); result.History.Status.Should().Be("triaged"); result.History.Severity.Should().Be(6.5m); result.Action.Should().BeNull(); } [Trait("Category", TestCategories.Unit)] [Fact] public void Reduce_StatusChange_ProducesHistoryAndAction() { var existing = new FindingProjection( "tenant-a", "finding-1", "policy-v1", "affected", 5.0m, null, null, null, null, 1, new JsonObject(), Guid.NewGuid(), null, new JsonArray(), DateTimeOffset.UtcNow, string.Empty); var existingHash = ProjectionHashing.ComputeCycleHash(existing); existing = existing with { CycleHash = existingHash }; var payload = new JsonObject { ["status"] = "accepted_risk", ["justification"] = "Approved by CISO" }; var record = CreateRecord(LedgerEventConstants.EventFindingStatusChanged, payload); var evaluation = new PolicyEvaluationResult( "accepted_risk", existing.Severity, null, null, null, null, existing.RiskEventSequence, (JsonObject)existing.Labels.DeepClone(), null, new JsonArray()); var result = LedgerProjectionReducer.Reduce(record, existing, evaluation); result.Projection.Status.Should().Be("accepted_risk"); result.History.Status.Should().Be("accepted_risk"); result.History.Comment.Should().Be("Approved by CISO"); result.Action.Should().NotBeNull(); result.Action!.ActionType.Should().Be("status_change"); result.Action.Payload["justification"]!.GetValue().Should().Be("Approved by CISO"); } [Trait("Category", TestCategories.Unit)] [Fact] public void Reduce_LabelUpdates_RemoveKeys() { var labels = new JsonObject { ["kev"] = true, ["runtime"] = "exposed" }; var existing = new FindingProjection( "tenant-a", "finding-1", "policy-v1", "triaged", 7.1m, null, null, null, null, 1, labels, Guid.NewGuid(), null, new JsonArray(), DateTimeOffset.UtcNow, string.Empty); existing = existing with { CycleHash = ProjectionHashing.ComputeCycleHash(existing) }; var payload = new JsonObject { ["labels"] = new JsonObject { ["runtime"] = "contained", ["priority"] = "p1" }, ["labelsRemove"] = new JsonArray("kev") }; var record = CreateRecord(LedgerEventConstants.EventFindingTagUpdated, payload); var evaluation = new PolicyEvaluationResult( "triaged", existing.Severity, null, null, null, null, existing.RiskEventSequence, (JsonObject)payload["labels"]!.DeepClone(), null, new JsonArray()); var result = LedgerProjectionReducer.Reduce(record, existing, evaluation); result.Projection.Labels.ContainsKey("kev").Should().BeFalse(); result.Projection.Labels["runtime"]!.GetValue().Should().Be("contained"); result.Projection.Labels["priority"]!.GetValue().Should().Be("p1"); } private static LedgerEventRecord CreateRecord(string eventType, JsonObject payload) { var envelope = new JsonObject { ["event"] = new JsonObject { ["id"] = Guid.NewGuid().ToString(), ["type"] = eventType, ["tenant"] = "tenant-a", ["chainId"] = Guid.NewGuid().ToString(), ["sequence"] = 1, ["policyVersion"] = "policy-v1", ["artifactId"] = "artifact-1", ["finding"] = new JsonObject { ["id"] = "finding-1", ["artifactId"] = "artifact-1", ["vulnId"] = "CVE-2025-0001" }, ["actor"] = new JsonObject { ["id"] = "user:alice", ["type"] = "operator" }, ["occurredAt"] = "2025-11-03T12:00:00.000Z", ["recordedAt"] = "2025-11-03T12:00:05.000Z", ["payload"] = payload.DeepClone() } }; var canonical = LedgerCanonicalJsonSerializer.Canonicalize(envelope); var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(canonical); return new LedgerEventRecord( "tenant-a", Guid.Parse(canonical["event"]!["chainId"]!.GetValue()), 1, Guid.Parse(canonical["event"]!["id"]!.GetValue()), eventType, canonical["event"]!["policyVersion"]!.GetValue(), canonical["event"]!["finding"]!["id"]!.GetValue(), canonical["event"]!["artifactId"]!.GetValue(), null, canonical["event"]!["actor"]!["id"]!.GetValue(), canonical["event"]!["actor"]!["type"]!.GetValue(), DateTimeOffset.Parse(canonical["event"]!["occurredAt"]!.GetValue()), DateTimeOffset.Parse(canonical["event"]!["recordedAt"]!.GetValue()), canonical, ComputeSha256Hex(canonicalJson), LedgerEventConstants.EmptyHash, ComputeSha256Hex("placeholder-1"), canonicalJson); } private static string ComputeSha256Hex(string input) { var bytes = Encoding.UTF8.GetBytes(input); var hashBytes = SHA256.HashData(bytes); return Convert.ToHexString(hashBytes).ToLowerInvariant(); } }