using System.Text.Json.Nodes; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Findings.Ledger.Domain; using StellaOps.Findings.Ledger.Hashing; using StellaOps.Findings.Ledger.Infrastructure.Policy; using StellaOps.Findings.Ledger.Services; using Xunit; namespace StellaOps.Findings.Ledger.Tests; public sealed class InlinePolicyEvaluationServiceTests { private readonly InlinePolicyEvaluationService _service = new(NullLogger.Instance); [Fact] public async Task EvaluateAsync_UsesPayloadValues_WhenPresent() { var payload = new JsonObject { ["status"] = "triaged", ["severity"] = 5.2, ["labels"] = new JsonObject { ["kev"] = true, ["runtime"] = "exposed" }, ["labelsRemove"] = new JsonArray("deprecated"), ["explainRef"] = "explain://tenant/findings/1", ["rationaleRefs"] = new JsonArray("explain://tenant/findings/1", "policy://tenant/pol/version/rationale") }; var existingProjection = new FindingProjection( "tenant", "finding", "policy-sha", "affected", 7.1m, new JsonObject { ["deprecated"] = "true" }, Guid.NewGuid(), null, new JsonArray("explain://existing"), DateTimeOffset.UtcNow, string.Empty); var record = CreateRecord(payload); var result = await _service.EvaluateAsync(record, existingProjection, default); result.Status.Should().Be("triaged"); result.Severity.Should().Be(5.2m); result.Labels["kev"]!.GetValue().Should().BeTrue(); result.Labels.ContainsKey("deprecated").Should().BeFalse(); result.Labels["runtime"]!.GetValue().Should().Be("exposed"); result.ExplainRef.Should().Be("explain://tenant/findings/1"); result.Rationale.Should().HaveCount(2); result.Rationale[0]!.GetValue().Should().Be("explain://tenant/findings/1"); result.Rationale[1]!.GetValue().Should().Be("policy://tenant/pol/version/rationale"); } [Fact] public async Task EvaluateAsync_FallsBack_WhenEventMissing() { var existingRationale = new JsonArray("explain://existing/rationale"); var existingProjection = new FindingProjection( "tenant", "finding", "policy-sha", "accepted_risk", 3.4m, new JsonObject { ["runtime"] = "contained" }, Guid.NewGuid(), "explain://existing", existingRationale, DateTimeOffset.UtcNow, string.Empty); var record = new LedgerEventRecord( "tenant", Guid.NewGuid(), 1, Guid.NewGuid(), "finding.status_changed", "policy-sha", "finding", "artifact", null, "user:alice", "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, new JsonObject(), "hash", "prev", "leaf", "{}" ); var result = await _service.EvaluateAsync(record, existingProjection, default); result.Status.Should().Be("accepted_risk"); result.Severity.Should().Be(3.4m); result.Labels["runtime"]!.GetValue().Should().Be("contained"); result.ExplainRef.Should().Be("explain://existing"); result.Rationale.Should().HaveCount(1); result.Rationale[0]!.GetValue().Should().Be("explain://existing/rationale"); } private static LedgerEventRecord CreateRecord(JsonObject payload) { var eventObject = new JsonObject { ["id"] = Guid.NewGuid().ToString(), ["type"] = "finding.status_changed", ["tenant"] = "tenant", ["chainId"] = Guid.NewGuid().ToString(), ["sequence"] = 10, ["policyVersion"] = "policy-sha", ["artifactId"] = "artifact", ["finding"] = new JsonObject { ["id"] = "finding", ["artifactId"] = "artifact", ["vulnId"] = "CVE-0000-0001" }, ["actor"] = new JsonObject { ["id"] = "user:alice", ["type"] = "operator" }, ["occurredAt"] = "2025-11-04T12:00:00.000Z", ["recordedAt"] = "2025-11-04T12:00:01.000Z", ["payload"] = payload.DeepClone() }; var envelope = new JsonObject { ["event"] = eventObject }; var canonical = LedgerCanonicalJsonSerializer.Canonicalize(envelope); var canonicalJson = LedgerCanonicalJsonSerializer.Serialize(canonical); return new LedgerEventRecord( "tenant", Guid.Parse(eventObject["chainId"]!.GetValue()), 10, Guid.Parse(eventObject["id"]!.GetValue()), eventObject["type"]!.GetValue(), eventObject["policyVersion"]!.GetValue(), eventObject["finding"]!["id"]!.GetValue(), eventObject["artifactId"]!.GetValue(), null, eventObject["actor"]!["id"]!.GetValue(), eventObject["actor"]!["type"]!.GetValue(), DateTimeOffset.Parse(eventObject["occurredAt"]!.GetValue()), DateTimeOffset.Parse(eventObject["recordedAt"]!.GetValue()), canonical, "hash", "prev", "leaf", canonicalJson); } }