Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/VerifyAuditChainHeadsCommandTests.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

111 lines
5.1 KiB
C#

using System.Text.Json;
using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// The <c>VerifyAuditChainHeads</c> management command (ScadaBridge#107 item 1): role gate,
/// site-scope refusal, input bounds, and the verdicts reaching the wire with enums as names.
/// </summary>
public class VerifyAuditChainHeadsCommandTests : TestKit
{
private readonly IAuditChainHeadStore _store = Substitute.For<IAuditChainHeadStore>();
private static readonly AuditChainHeadRecord Record = new(
1, "2026-05", 12, new string('a', 64), null, null, 12, 0,
new DateTime(2026, 5, 3, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 5, 3, 1, 0, 0, DateTimeKind.Utc), "central-a");
private IActorRef CreateActor()
{
var services = new ServiceCollection().AddTestConfigurationTransaction();
services.AddScoped(_ => _store);
return Sys.ActorOf(Props.Create(() => new ManagementActor(
services.BuildServiceProvider(), NullLogger<ManagementActor>.Instance)));
}
private static ManagementEnvelope Envelope(object command, string[] sites, params string[] roles) =>
new(new AuthenticatedUser("u", "U", roles, sites), command, Guid.NewGuid().ToString("N"));
/// <summary>A Viewer gets the store's verdicts; the records reach the store unchanged.</summary>
[Fact]
public void Viewer_GetsTheVerdicts_ForTheSuppliedRecords()
{
var check = new AuditChainHeadCheck("2026-05", 12, Record.ExportedAtUtc, "central-a",
AuditChainHeadStatus.Mismatch, 12, "rewritten");
_store.VerifyExportedHeadsAsync(Arg.Any<IReadOnlyList<AuditChainHeadRecord>>(), Arg.Any<CancellationToken>())
.Returns(new AuditChainHeadVerificationResult(false, 1, [check]));
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), [], "Viewer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
using var json = JsonDocument.Parse(response.JsonData);
Assert.False(json.RootElement.GetProperty("verified").GetBoolean());
Assert.Equal("Mismatch", json.RootElement.GetProperty("checks")[0].GetProperty("status").GetString());
_store.Received(1).VerifyExportedHeadsAsync(
Arg.Is<IReadOnlyList<AuditChainHeadRecord>>(r => r.Count == 1 && r[0] == Record), Arg.Any<CancellationToken>());
}
/// <summary>A Designer/Deployer without an OperationalAudit role is refused.</summary>
[Fact]
public void NonAuditRole_IsUnauthorized()
{
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), [], "Designer", "Deployer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_store.DidNotReceiveWithAnyArgs().VerifyExportedHeadsAsync(default!, default);
}
/// <summary>A site-scoped Viewer is refused: the chain spans every site.</summary>
[Fact]
public void SiteScopedViewer_IsRefused()
{
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), ["3"], "Viewer"));
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("spans every site", response.Message);
_store.DidNotReceiveWithAnyArgs().VerifyExportedHeadsAsync(default!, default);
}
/// <summary>No records, or more than the cap, is a caller error.</summary>
/// <param name="count">Records supplied.</param>
[Theory]
[InlineData(0)]
[InlineData(ManagementActor.MaxChainHeadRecords + 1)]
public void EmptyOrOversizedInput_IsInvalidArgument(int count)
{
CreateActor().Tell(Envelope(
new VerifyAuditChainHeadsCommand(Enumerable.Repeat(Record, count).ToList()), [], "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("INVALID_ARGUMENT", response.ErrorCode);
_store.DidNotReceiveWithAnyArgs().VerifyExportedHeadsAsync(default!, default);
}
/// <summary>A chain lock that cannot be had surfaces as CHAIN_BUSY.</summary>
[Fact]
public void BusyChain_IsChainBusy()
{
_store.VerifyExportedHeadsAsync(Arg.Any<IReadOnlyList<AuditChainHeadRecord>>(), Arg.Any<CancellationToken>())
.Returns<Task<AuditChainHeadVerificationResult>>(_ => throw new TimeoutException("busy"));
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), [], "Administrator"));
Assert.Equal("CHAIN_BUSY", ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)).ErrorCode);
}
/// <summary>Runs under the long-running Ask timeout.</summary>
[Fact]
public void VerifyAuditChainHeads_UsesLongRunningAskTimeout()
{
Assert.Equal(TimeSpan.FromMinutes(5),
ManagementEndpoints.ResolveAskTimeout(new ManagementServiceOptions(), typeof(VerifyAuditChainHeadsCommand)));
}
}