using System.Globalization; 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; using StellaOps.Findings.Ledger.Infrastructure.InMemory; using StellaOps.Findings.Ledger.Infrastructure.Merkle; using StellaOps.Findings.Ledger.Services; using Xunit; namespace StellaOps.Findings.Ledger.Tests; public sealed class LedgerEventWriteServiceTests { private readonly InMemoryLedgerEventRepository _repository = new(); private readonly NullMerkleAnchorScheduler _scheduler = new(); private readonly LedgerEventWriteService _service; public LedgerEventWriteServiceTests() { _service = new LedgerEventWriteService(_repository, _scheduler, NullLogger.Instance); } [Fact] public async Task AppendAsync_ComputesExpectedHashes() { var draft = CreateDraft(); var result = await _service.AppendAsync(draft, CancellationToken.None); result.Status.Should().Be(LedgerWriteStatus.Success); result.Record.Should().NotBeNull(); var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(draft.CanonicalEnvelope); var expectedHashes = LedgerHashing.ComputeHashes(canonicalEnvelope, draft.SequenceNumber); result.Record!.EventHash.Should().Be(expectedHashes.EventHash); result.Record.MerkleLeafHash.Should().Be(expectedHashes.MerkleLeafHash); result.Record.PreviousHash.Should().Be(LedgerEventConstants.EmptyHash); } [Fact] public async Task AppendAsync_ReturnsConflict_WhenSequenceOutOfOrder() { var initial = CreateDraft(); await _service.AppendAsync(initial, CancellationToken.None); var second = CreateDraft(sequenceNumber: 44, eventId: Guid.NewGuid()); Assert.NotEqual(initial.EventId, second.EventId); var result = await _service.AppendAsync(second, CancellationToken.None); result.Status.Should().Be(LedgerWriteStatus.Conflict); result.Errors.Should().NotBeEmpty(); } [Fact] public async Task AppendAsync_ReturnsIdempotent_WhenExistingRecordMatches() { var draft = CreateDraft(); var existingRecord = CreateRecordFromDraft(draft, LedgerEventConstants.EmptyHash); var repository = new StubLedgerEventRepository(existingRecord); var scheduler = new CapturingMerkleScheduler(); var service = new LedgerEventWriteService(repository, scheduler, NullLogger.Instance); var result = await service.AppendAsync(draft, CancellationToken.None); result.Status.Should().Be(LedgerWriteStatus.Idempotent); scheduler.Enqueued.Should().BeFalse(); repository.AppendWasCalled.Should().BeFalse(); } private static LedgerEventDraft CreateDraft(long sequenceNumber = 1, Guid? eventId = null) { var eventGuid = eventId ?? Guid.Parse("3ac1f4ef-3c26-4b0d-91d4-6a6d3a5bde10"); var payload = new JsonObject { ["previousStatus"] = "affected", ["status"] = "triaged", ["justification"] = "Ticket SEC-1234 created", ["ticket"] = new JsonObject { ["id"] = "SEC-1234", ["url"] = "https://tracker.example/sec-1234" } }; var occurredAt = DateTimeOffset.Parse("2025-11-03T15:12:05.123Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); var recordedAt = DateTimeOffset.Parse("2025-11-03T15:12:06.001Z", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); var eventObject = new JsonObject { ["id"] = eventGuid.ToString(), ["type"] = "finding.status_changed", ["tenant"] = "tenant-a", ["chainId"] = "5fa2b970-9da2-4ef4-9a63-463c5d98d3cc", ["sequence"] = sequenceNumber, ["policyVersion"] = "sha256:5f38c7887d4a4bb887ce89c393c7a2e23e6e708fda310f9f3ff2a2a0b4dffbdf", ["finding"] = new JsonObject { ["id"] = "artifact:sha256:3f1e2d9c7b1a0f6534d1b6f998d7a5c3ef9e0ab92f4c1d2e3f5a6b7c8d9e0f1a|pkg:cpe:/o:vendor:product", ["artifactId"] = "sha256:3f1e2d9c7b1a0f6534d1b6f998d7a5c3ef9e0ab92f4c1d2e3f5a6b7c8d9e0f1a", ["vulnId"] = "CVE-2025-1234" }, ["artifactId"] = "sha256:3f1e2d9c7b1a0f6534d1b6f998d7a5c3ef9e0ab92f4c1d2e3f5a6b7c8d9e0f1a", ["actor"] = new JsonObject { ["id"] = "user:alice@tenant", ["type"] = "operator" }, ["occurredAt"] = occurredAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"), ["recordedAt"] = recordedAt.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'"), ["payload"] = payload }; eventObject["sourceRunId"] = "8f89a703-94cd-4e9d-8a75-2f407c4bee7f"; var envelope = new JsonObject { ["event"] = eventObject }; var draft = new LedgerEventDraft( TenantId: "tenant-a", ChainId: Guid.Parse("5fa2b970-9da2-4ef4-9a63-463c5d98d3cc"), SequenceNumber: sequenceNumber, EventId: Guid.Parse("3ac1f4ef-3c26-4b0d-91d4-6a6d3a5bde10"), EventType: "finding.status_changed", PolicyVersion: "sha256:5f38c7887d4a4bb887ce89c393c7a2e23e6e708fda310f9f3ff2a2a0b4dffbdf", FindingId: "artifact:sha256:3f1e2d9c7b1a0f6534d1b6f998d7a5c3ef9e0ab92f4c1d2e3f5a6b7c8d9e0f1a|pkg:cpe:/o:vendor:product", ArtifactId: "sha256:3f1e2d9c7b1a0f6534d1b6f998d7a5c3ef9e0ab92f4c1d2e3f5a6b7c8d9e0f1a", SourceRunId: Guid.Parse("8f89a703-94cd-4e9d-8a75-2f407c4bee7f"), ActorId: "user:alice@tenant", ActorType: "operator", OccurredAt: occurredAt, RecordedAt: recordedAt, Payload: payload, CanonicalEnvelope: envelope, ProvidedPreviousHash: null); return draft with { EventId = eventGuid }; } private static LedgerEventRecord CreateRecordFromDraft(LedgerEventDraft draft, string previousHash) { var canonicalEnvelope = LedgerCanonicalJsonSerializer.Canonicalize(draft.CanonicalEnvelope); var hashResult = LedgerHashing.ComputeHashes(canonicalEnvelope, draft.SequenceNumber); var eventBody = (JsonObject)canonicalEnvelope.DeepClone(); return new LedgerEventRecord( draft.TenantId, draft.ChainId, draft.SequenceNumber, draft.EventId, draft.EventType, draft.PolicyVersion, draft.FindingId, draft.ArtifactId, draft.SourceRunId, draft.ActorId, draft.ActorType, draft.OccurredAt, draft.RecordedAt, eventBody, hashResult.EventHash, previousHash, hashResult.MerkleLeafHash, hashResult.CanonicalJson); } private sealed class StubLedgerEventRepository : ILedgerEventRepository { private readonly LedgerEventRecord? _existing; public StubLedgerEventRepository(LedgerEventRecord? existing) { _existing = existing; } public bool AppendWasCalled { get; private set; } public Task AppendAsync(LedgerEventRecord record, CancellationToken cancellationToken) { AppendWasCalled = true; return Task.CompletedTask; } public Task GetByEventIdAsync(string tenantId, Guid eventId, CancellationToken cancellationToken) => Task.FromResult(_existing); public Task GetChainHeadAsync(string tenantId, Guid chainId, CancellationToken cancellationToken) => Task.FromResult(null); } private sealed class CapturingMerkleScheduler : IMerkleAnchorScheduler { public bool Enqueued { get; private set; } public Task EnqueueAsync(LedgerEventRecord record, CancellationToken cancellationToken) { Enqueued = true; return Task.CompletedTask; } } }