using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance; /// /// Storage-free tests for the AuditLog hash-chain hasher + verifier (ScadaBridge#41): /// tamper detection for an edited, a deleted and a reordered row, tombstone and /// partition-anchor bridging, and a pinned known-answer vector for the v1 canonical form. /// public class AuditChainVerifierTests { private static readonly DateTime Month = new(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); /// An untouched chain verifies intact and covers every row. [Fact] public void IntactChain_Verifies() { var (rows, head) = BuildChain(5); var result = Verify(rows, head); Assert.Equal(AuditChainStatus.Intact, result.Status); Assert.Equal(5, result.RowsVerified); Assert.Null(result.FirstBreak); } /// Editing any stored column of a chained row is a HashMismatch at that row. [Fact] public void EditedRow_IsHashMismatch_AtThatRow() { var (rows, head) = BuildChain(5); rows[2] = rows[2] with { Target = "tampered" }; var result = Verify(rows, head); Assert.Equal(AuditChainStatus.Broken, result.Status); Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind); Assert.Equal(3, result.FirstBreak.ChainSeq); Assert.Equal(rows[2].EventId, result.FirstBreak.EventId); Assert.Equal("2026-05", result.FirstBreak.Partition); Assert.Equal(2, result.RowsVerified); } /// A row deleted from the middle (no tombstone) is a MissingRow at its sequence number. [Fact] public void DeletedMiddleRow_IsMissingRow() { var (rows, head) = BuildChain(5); var deleted = rows[1]; rows.RemoveAt(1); var result = Verify(rows, head); Assert.Equal(AuditChainStatus.Broken, result.Status); Assert.Equal(AuditChainBreakKind.MissingRow, result.FirstBreak!.Kind); Assert.Equal(deleted.ChainSeq, result.FirstBreak.ChainSeq); Assert.Equal(rows[1].EventId, result.FirstBreak.EventId); // the next surviving row } /// Deleting the newest rows leaves a self-consistent prefix; the head catches it. [Fact] public void DeletedTailRows_IsHeadMismatch() { var (rows, head) = BuildChain(5); rows.RemoveRange(3, 2); var result = Verify(rows, head); Assert.Equal(AuditChainStatus.Broken, result.Status); Assert.Equal(AuditChainBreakKind.HeadMismatch, result.FirstBreak!.Kind); Assert.Equal(4, result.FirstBreak.ChainSeq); Assert.Null(result.FirstBreak.EventId); } /// Swapping two rows' sequence numbers is a HashMismatch at the first moved row. [Fact] public void ReorderedRows_IsHashMismatch() { var (rows, head) = BuildChain(5); var a = rows[1]; var b = rows[3]; rows[1] = b with { ChainSeq = a.ChainSeq }; rows[3] = a with { ChainSeq = b.ChainSeq }; var result = Verify(rows, head); Assert.Equal(AuditChainStatus.Broken, result.Status); Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind); Assert.Equal(2, result.FirstBreak.ChainSeq); Assert.Equal(b.EventId, result.FirstBreak.EventId); } /// A duplicated sequence number is reported, not silently skipped. [Fact] public void DuplicateSequence_IsReported() { var (rows, head) = BuildChain(3); rows.Insert(2, rows[1] with { EventId = Guid.NewGuid() }); var result = Verify(rows, head); Assert.Equal(AuditChainBreakKind.DuplicateSequence, result.FirstBreak!.Kind); } /// A sanctioned per-channel purge leaves a tombstone that bridges the gap. [Fact] public void TombstonedRow_BridgesTheGap() { var (rows, head) = BuildChain(5); var purged = rows[2]; rows.RemoveAt(2); var tombstones = new Dictionary { [purged.ChainSeq!.Value] = purged.RowHash! }; var result = Verify(rows, head, tombstones); Assert.Equal(AuditChainStatus.Intact, result.Status); Assert.Equal(4, result.RowsVerified); Assert.Equal(1, result.TombstonesApplied); } /// A tombstone with the wrong hash does not hide a deletion. [Fact] public void ForgedTombstone_IsDetectedAtTheNextRow() { var (rows, head) = BuildChain(5); var purged = rows[2]; rows.RemoveAt(2); var tombstones = new Dictionary { [purged.ChainSeq!.Value] = new byte[32] }; var result = Verify(rows, head, tombstones); Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind); Assert.Equal(4, result.FirstBreak.ChainSeq); } /// After a partition purge, verification resumes at the checkpoint anchor. [Fact] public void PartitionPurgeAnchor_VerifiesTheRest() { var (rows, head) = BuildChain(6); var anchor = rows[3]; var survivors = rows.Skip(4).ToList(); var anchored = head with { PurgedThroughSeq = anchor.ChainSeq, PurgedThroughHash = anchor.RowHash }; var result = Verify(survivors, anchored); Assert.Equal(AuditChainStatus.Intact, result.Status); Assert.Equal(2, result.RowsVerified); Assert.Equal(4, result.PurgedThroughSeq); } /// A month with neither rows nor head reports NotChained. [Fact] public void EmptyMonth_IsNotChained() { var result = Verify([], null, unchained: 7); Assert.Equal(AuditChainStatus.NotChained, result.Status); Assert.Equal(7, result.UnchainedRows); } /// /// Pins the v1 canonical form: a change to the serialisation silently invalidates every /// chain already in a database, so it must fail a test first. The expected value was /// computed by an independent implementation of the documented v1 encoding (Python /// hashlib/struct), not by this code. /// [Fact] public void KnownAnswerVector_V1() { var row = new AuditChainRow( Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"), new DateTime(2026, 5, 20, 10, 30, 0, DateTimeKind.Utc).AddTicks(1234567), "alice", "ApiOutbound.ApiCall", "Success", "ApiOutbound", "Erp", "central-a", Guid.Parse("7c9e6679-7425-40de-944b-e07fc1f90ae7"), "{\"kind\":\"ApiCall\",\"status\":\"Delivered\"}"); var hash = AuditChainHasher.ComputeRowHash(AuditChainHasher.GenesisHash, Month, 1, row); Assert.Equal("a3137c9961f35f931c7d84de6bced66ce1fe8bbb1dc500f9740b97efd9983ad4", Convert.ToHexStringLower(hash)); } /// NULL and the empty string hash differently (presence byte), as do adjacent-field shifts. [Fact] public void Canonical_DistinguishesNullEmptyAndFieldBoundaries() { var baseRow = NewRow(1); var nullTarget = baseRow with { Target = null }; var emptyTarget = baseRow with { Target = "" }; var shifted = baseRow with { Actor = "ab", Target = "c" }; var shifted2 = baseRow with { Actor = "a", Target = "bc" }; Assert.NotEqual(Hash(nullTarget), Hash(emptyTarget)); Assert.NotEqual(Hash(shifted), Hash(shifted2)); } private static string Hash(AuditChainRow row) => Convert.ToHexString(AuditChainHasher.ComputeRowHash(AuditChainHasher.GenesisHash, Month, 1, row)); private static AuditChainVerificationResult Verify( IEnumerable rows, AuditChainHeadState? head, IReadOnlyDictionary? tombstones = null, long unchained = 0) { var verifier = new AuditChainVerifier(Month, head, tombstones ?? new Dictionary()); foreach (var row in rows) { if (!verifier.Accept(row)) { break; } } return verifier.Complete(unchained); } // ── chain start / cutoff (ScadaBridge#139) ─────────────────────────────── private static readonly DateTime CutoffAt = Month.AddHours(12); // rows are dated Month + i days /// A cutoff-started month links its first row to the anchor and verifies intact. [Fact] public void CutoffStartedChain_Verifies_FromTheAnchor() { var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt); var (rows, head) = BuildChain(4, anchor); var result = VerifyCutoff(rows, head, anchor); Assert.Equal(AuditChainStatus.Intact, result.Status); Assert.Equal(4, result.RowsVerified); Assert.Equal(0, result.TombstonesApplied); // the anchor is not a purge tombstone Assert.Equal(CutoffAt, result.ChainFromUtc); Assert.Equal(7, result.PreCutoffRows); } /// Without its anchor, a cutoff-started month fails at its first link. [Fact] public void CutoffStartedChain_WithoutItsAnchor_FailsAtTheFirstLink() { var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt); var (rows, head) = BuildChain(4, anchor); var result = Verify(rows, head); Assert.Equal(AuditChainBreakKind.HashMismatch, result.FirstBreak!.Kind); Assert.Equal(1, result.FirstBreak.ChainSeq); } /// A different cutoff is a different anchor: the anchor hash binds the instant. [Fact] public void CutoffAnchorHash_BindsTheInstant() { Assert.False(AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt).AsSpan() .SequenceEqual(AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt.AddTicks(1)))); Assert.Equal(AuditChainHasher.HashLength, AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt).Length); } /// /// The eligibility attack: a chained row re-dated before the cutoff is a /// ChainedBeforeCutoff break even when its hash were recomputed to match. /// [Fact] public void ChainedRowDatedBeforeTheCutoff_IsChainedBeforeCutoff() { var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt); var (rows, _) = BuildChain(4, anchor); // Re-date row 3 before the cutoff and re-hash the chain from it (an attacker who // rewrites the links): only the cutoff rule can still see it. rows[2] = rows[2] with { OccurredAtUtc = CutoffAt.AddMinutes(-1) }; var previous = rows[1].RowHash!; for (var i = 2; i < rows.Count; i++) { var hash = AuditChainHasher.ComputeRowHash(previous, Month, i + 1, rows[i] with { ChainSeq = null, RowHash = null }); rows[i] = rows[i] with { RowHash = hash }; previous = hash; } var result = VerifyCutoff(rows, new AuditChainHeadState(4, previous, null, null), anchor); Assert.Equal(AuditChainStatus.Broken, result.Status); Assert.Equal(AuditChainBreakKind.ChainedBeforeCutoff, result.FirstBreak!.Kind); Assert.Equal(3, result.FirstBreak.ChainSeq); Assert.Equal(rows[2].EventId, result.FirstBreak.EventId); } /// An anchor the caller found invalid fails the month even when every link verifies. [Fact] public void InvalidAnchor_IsCutoffAnchorInvalid() { var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt); var (rows, head) = BuildChain(4, anchor); var tombstones = new Dictionary { [AuditChainHasher.CutoffAnchorSeq] = anchor }; var verifier = new AuditChainVerifier(Month, head, tombstones, CutoffAt, "anchor altered"); foreach (var row in rows) { verifier.Accept(row); } var result = verifier.Complete(0); Assert.Equal(AuditChainStatus.Broken, result.Status); Assert.Equal(AuditChainBreakKind.CutoffAnchorInvalid, result.FirstBreak!.Kind); Assert.Equal(AuditChainHasher.CutoffAnchorSeq, result.FirstBreak.ChainSeq); } /// /// After a partition purge the checkpoint anchor, not the cutoff anchor, is where /// verification resumes; the cutoff anchor in the same tombstone set is ignored. /// [Fact] public void PurgeCheckpoint_TakesPrecedenceOverTheCutoffAnchor() { var anchor = AuditChainHasher.ComputeCutoffAnchorHash(CutoffAt); var (rows, _) = BuildChain(5, anchor); var head = new AuditChainHeadState(5, rows[4].RowHash!, 3, rows[2].RowHash!); var result = VerifyCutoff(rows.Skip(3), head, anchor); Assert.Equal(AuditChainStatus.Intact, result.Status); Assert.Equal(2, result.RowsVerified); } private static AuditChainVerificationResult VerifyCutoff(IEnumerable rows, AuditChainHeadState head, byte[] anchor) { var tombstones = new Dictionary { [AuditChainHasher.CutoffAnchorSeq] = anchor }; var verifier = new AuditChainVerifier(Month, head, tombstones, CutoffAt); foreach (var row in rows) { if (!verifier.Accept(row)) { break; } } return verifier.Complete(0, preCutoffRows: 7); } private static (List Rows, AuditChainHeadState Head) BuildChain(int count) => BuildChain(count, AuditChainHasher.GenesisHash.ToArray()); private static (List Rows, AuditChainHeadState Head) BuildChain(int count, byte[] genesis) { var rows = new List(); var previous = genesis; for (var seq = 1; seq <= count; seq++) { var row = NewRow(seq); var hash = AuditChainHasher.ComputeRowHash(previous, Month, seq, row); rows.Add(row with { ChainSeq = seq, RowHash = hash }); previous = hash; } return (rows, new AuditChainHeadState(count, previous, null, null)); } private static AuditChainRow NewRow(int i) => new( Guid.NewGuid(), Month.AddDays(i).AddTicks(i), $"actor-{i}", "ApiOutbound.ApiCall", "Success", "ApiOutbound", $"target-{i}", "central-a", null, $"{{\"kind\":\"ApiCall\",\"n\":{i}}}"); }