Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditChainHeadComparerTests.cs
T
Joseph Doherty c4d7ec9b96 feat(audit): off-box chain-head export, verify-heads, maintenance login (ScadaBridge#107)
Item 1 (owner decision: no signing). AuditLog:HashChain:HeadExport (default
off): both centrals periodically read every month's chain head (LastSeq,
LastHash, purge anchor, chained rows, tombstones; one snapshot under the
chain lock SHARED) and append the changed heads to a sink the DB login cannot
write - File (append-only JSONL per node per day, local or UNC) or Syslog
(RFC 5424 over UDP / TCP octet counting). `audit verify-heads --path` reads
those records back (file or syslog-collector lines) and the new
VerifyAuditChainHeads command checks each against the database: Matches /
Extended / Purged, or Mismatch / Regressed / MissingHead / MissingLink /
CountMismatch (exit 3). AuditChainHeadStoreTests forges a chain with the
production hasher: verify-chain says Intact, verify-heads says Mismatch.

Item 3. AuditLog:MaintenanceConnectionString (${secret:}-capable, unset =
today's single login) runs the chain job, chain verify / head export, the
partition switch-out + archive and the per-channel purge on
AuditMaintenanceContext (keyed audit-maintenance repository/archive, purge
actor prefers them). AuditMaintenanceLoginTests runs the hardened setup on
SQL Server: writer (datareader/datawriter/audit_writer) ingests and is
refused chaining and deleting; the chainer+purger login does the full cycle.
Two-login grants documented in Component-AuditLog.md.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 23:16:05 -05:00

82 lines
4.3 KiB
C#

using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
/// <summary>
/// Pure verdict table of <see cref="AuditChainHeadComparer"/> (ScadaBridge#107 item 1) for the
/// branches the SQL Server tests do not reach: a link carried by a tombstone, the partition
/// anchor itself, and malformed input.
/// </summary>
public sealed class AuditChainHeadComparerTests
{
private static readonly byte[] H1 = Enumerable.Repeat((byte)1, 32).ToArray();
private static readonly byte[] H2 = Enumerable.Repeat((byte)2, 32).ToArray();
private static AuditChainHeadRecord Exported(long seq, byte[] hash) => new(
1, "2026-05", seq, AuditChainHeadComparer.ToHex(hash), null, null, seq, 0, DateTime.UtcNow, DateTime.UtcNow, "central-a");
private static AuditChainHeadSnapshot Head(long seq, byte[] hash, long? anchor = null, byte[]? anchorHash = null) =>
new(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), seq, hash, anchor, anchorHash, seq, 0, DateTime.UtcNow);
private static Func<long, Task<AuditChainHeadComparer.StoredLink>> Link(byte[]? row, byte[]? tombstone)
=> _ => Task.FromResult(new AuditChainHeadComparer.StoredLink(row, tombstone));
private static Func<long, Task<AuditChainHeadComparer.StoredLink>> NoLookup
=> _ => throw new InvalidOperationException("no link lookup expected");
/// <summary>The exported link was later purged per channel: the tombstone carries the hash.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GrownChain_LinkOnATombstone_IsExtended_OrMismatch()
{
Assert.Equal(AuditChainHeadStatus.Extended,
(await AuditChainHeadComparer.CompareAsync(Exported(3, H1), Head(9, H2), Link(null, H1))).Status);
Assert.Equal(AuditChainHeadStatus.Mismatch,
(await AuditChainHeadComparer.CompareAsync(Exported(3, H1), Head(9, H2), Link(null, H2))).Status);
}
/// <summary>The exported head is the partition-purge anchor: compared with the anchor's hash, no lookup.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task GrownChain_ExportedHeadIsTheAnchor_ComparesTheAnchorHash()
{
Assert.Equal(AuditChainHeadStatus.Extended,
(await AuditChainHeadComparer.CompareAsync(Exported(4, H1), Head(9, H2, 4, H1), NoLookup)).Status);
Assert.Equal(AuditChainHeadStatus.Mismatch,
(await AuditChainHeadComparer.CompareAsync(Exported(4, H1), Head(9, H2, 4, H2), NoLookup)).Status);
Assert.Equal(AuditChainHeadStatus.Purged,
(await AuditChainHeadComparer.CompareAsync(Exported(2, H1), Head(9, H2, 4, H2), NoLookup)).Status);
}
/// <summary>A record whose hash is not 32 bytes of hex can never match.</summary>
/// <param name="hash">The exported hash text.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("abc")]
[InlineData("zz0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")]
public async Task MalformedExportedHash_IsMismatch(string hash)
{
var record = Exported(3, H1) with { LastHash = hash };
Assert.Equal(AuditChainHeadStatus.Mismatch,
(await AuditChainHeadComparer.CompareAsync(record, Head(3, H1), NoLookup)).Status);
}
/// <summary>Only the tamper verdicts count as failures.</summary>
/// <param name="status">The verdict.</param>
/// <param name="failure">Whether it is a failure.</param>
[Theory]
[InlineData(AuditChainHeadStatus.Matches, false)]
[InlineData(AuditChainHeadStatus.Extended, false)]
[InlineData(AuditChainHeadStatus.Purged, false)]
[InlineData(AuditChainHeadStatus.Mismatch, true)]
[InlineData(AuditChainHeadStatus.Regressed, true)]
[InlineData(AuditChainHeadStatus.MissingHead, true)]
[InlineData(AuditChainHeadStatus.MissingLink, true)]
[InlineData(AuditChainHeadStatus.CountMismatch, true)]
public void IsFailure_IsTrueOnlyForTamperVerdicts(AuditChainHeadStatus status, bool failure)
{
Assert.Equal(failure, new AuditChainHeadCheck("2026-05", 1, DateTime.UtcNow, null, status, 1, "").IsFailure);
}
}