feat(cli): working audit verify-chain via the VerifyAuditChain management command (ScadaBridge#41)

verify-chain was a no-op stub that printed "not enabled" and exited 0 without
contacting the server. It now sends VerifyAuditChain(Month) over
POST /management and maps the verdict to the exit code: 0 intact or not
chained, 3 broken (first break: partition, ChainSeq, EventId, kind), 1 error,
2 unauthorized. Table and JSON renderings.

ManagementActor: new HandleVerifyAuditChain arm, gated on the OperationalAudit
roles (Administrator, Viewer) and refusing a site-scoped caller because a
month's chain spans every site; INVALID_ARGUMENT for a bad month, CHAIN_BUSY
when the chain lock is held; long-running Ask timeout. Result enums serialise
as names.

Parity: RequiredRoleMatrixTests freezes the gate; ManagementSurfaceParityTests
now probes `audit verify-chain` (the one audit leaf that is a management
contract) instead of skipping the whole audit group.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 16:28:54 -05:00
parent fd4be80913
commit 548bb9a725
9 changed files with 333 additions and 27 deletions
@@ -1,29 +1,95 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
/// <summary>
/// Tests for the <c>scadabridge audit verify-chain</c> subcommand.
/// v1 is a no-op stub: a valid <c>--month</c> prints the documented "not enabled"
/// message and exits 0; a malformed month or a missing <c>--month</c> exits non-zero.
/// Tests for the <c>scadabridge audit verify-chain</c> subcommand (ScadaBridge#41). The
/// command sends the <c>VerifyAuditChain</c> management command for the month and maps
/// the verification status onto the exit code: 0 intact / not chained, 3 broken.
/// </summary>
[Collection("Console")]
public class AuditVerifyChainCommandTests
{
/// <summary>Verifies that verifying the chain for a valid month exits with zero and prints the documented message.</summary>
[Fact]
public void VerifyChain_ValidMonth_ExitsZeroWithDocumentedMessage()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, output, _) = AuditCommandTestHarness.Invoke(
root, "audit", "verify-chain", "--month", "2026-05");
private const string IntactBody =
"{\"partition\":\"2026-05\",\"status\":\"Intact\",\"rowsVerified\":42,\"tombstonesApplied\":1,\"headSeq\":43,\"unchainedRows\":0}";
Assert.Equal(0, exit);
Assert.Contains("Hash-chain tamper-evidence is not enabled", output);
Assert.Contains("Component-AuditLog.md", output);
private const string BrokenBody =
"{\"partition\":\"2026-05\",\"status\":\"Broken\",\"rowsVerified\":6,\"tombstonesApplied\":0,\"headSeq\":43,\"unchainedRows\":2," +
"\"firstBreak\":{\"partition\":\"2026-05\",\"chainSeq\":7,\"eventId\":\"11111111-2222-3333-4444-555555555555\",\"kind\":\"HashMismatch\",\"detail\":\"edited\"}}";
/// <summary>
/// CLI round trip: the request carries <c>VerifyAuditChain</c> + the month, and the
/// server's verdict drives the exit code and the report.
/// </summary>
/// <param name="body">The verification result the fake management endpoint serves.</param>
/// <param name="format">Output format.</param>
/// <param name="expectedExit">Expected exit code.</param>
/// <param name="expectedText">Text the report must contain.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(IntactBody, "table", 0, "Status : Intact")]
[InlineData(IntactBody, "json", 0, "\"status\":\"Intact\"")]
[InlineData(BrokenBody, "table", 3, "eventId : 11111111-2222-3333-4444-555555555555")]
[InlineData(BrokenBody, "json", 3, "\"chainSeq\":7")]
public async Task VerifyChain_SendsVerifyAuditChain_AndMapsVerdictToExitCode(
string body, string format, int expectedExit, string expectedText)
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
string? requestBody = null;
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
await using var stream = client.GetStream();
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var length = 0;
while (await reader.ReadLineAsync() is { Length: > 0 } line)
if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase))
length = int.Parse(line[15..].Trim());
var buffer = new char[length];
var read = 0;
while (read < length)
{
var count = await reader.ReadAsync(buffer.AsMemory(read));
if (count == 0) throw new EndOfStreamException();
read += count;
}
requestBody = new string(buffer);
var bytes = Encoding.UTF8.GetBytes(body);
var headers = Encoding.ASCII.GetBytes(
$"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {bytes.Length}\r\nConnection: close\r\n\r\n");
await stream.WriteAsync(headers);
await stream.WriteAsync(bytes);
});
var root = AuditCommandTestHarness.BuildRoot();
var (exit, output, _) = AuditCommandTestHarness.Invoke(
root, "audit", "verify-chain", "--month", "2026-05",
"--url", $"http://127.0.0.1:{port}", "--username", "u", "--password", "p", "--format", format);
await server.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(expectedExit, exit);
Assert.Contains(expectedText, output);
using var payload = JsonDocument.Parse(requestBody!);
Assert.Equal("VerifyAuditChain", payload.RootElement.GetProperty("command").GetString());
var month = payload.RootElement.GetProperty("payload").EnumerateObject()
.First(p => string.Equals(p.Name, "month", StringComparison.OrdinalIgnoreCase)).Value.GetString();
Assert.Equal("2026-05", month);
}
finally
{
listener.Stop();
}
}
/// <summary>Verifies that a malformed month value makes the command exit with a non-zero code.</summary>
/// <summary>Verifies that a malformed month value exits non-zero without contacting the server.</summary>
[Fact]
public void VerifyChain_MalformedMonth_ExitsNonZero()
{
@@ -93,7 +93,10 @@ public class ManagementSurfaceParityTests
foreach (var (leaf, path) in Leaves(root, []))
{
// Separate REST and persistent SignalR surfaces, not management contracts.
if (path.Length == 0 || path[0] == "audit" || path[0] == omitGroup
// `audit verify-chain` is the one audit leaf that IS a management contract
// (VerifyAuditChain, ScadaBridge#41), so it stays in the probe.
if (path.Length == 0 || (path[0] == "audit" && string.Join(' ', path) != "audit verify-chain")
|| path[0] == omitGroup
|| string.Join(' ', path) == "debug stream") continue;
currentPath = string.Join(' ', path);
var args = new List<string>(path);
@@ -155,6 +158,7 @@ public class ManagementSurfaceParityTests
"--from-address" => "sender@example.test",
"--thumbprint" => new string('A', 40),
"--role" => "Designer",
"--month" => "2026-05",
"--code" or "--script" => "return null;",
_ => "fixture"
};
@@ -167,6 +167,11 @@ public class RequiredRoleMatrixTests
["FlowDebugRunScript"] = [Roles.Deployer],
["FlowDebugReplay"] = FlowDebuggerReplayers,
// ---- Audit reads (OperationalAudit) -----------------------------------------
// AuditLog hash-chain verification (ScadaBridge#41) carries audit query's
// OperationalAudit permission (alog.md §15.1); written out by hand to keep the freeze.
["VerifyAuditChain"] = [Roles.Administrator, Roles.Viewer],
// ---- Two-person secured write ----------------------------------------------
["SubmitSecuredWrite"] = [Roles.Operator],
["ApproveSecuredWrite"] = [Roles.Verifier],
@@ -0,0 +1,104 @@
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>VerifyAuditChain</c> management command (ScadaBridge#41): role gate, month
/// parsing, site-scope refusal, and the verification result reaching the wire with its
/// enums as names.
/// </summary>
public class VerifyAuditChainCommandTests : TestKit
{
private readonly IAuditLogHashChain _chain = Substitute.For<IAuditLogHashChain>();
private IActorRef CreateActor()
{
var services = new ServiceCollection().AddTestConfigurationTransaction();
services.AddScoped(_ => _chain);
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 verifier's result for the requested month, enums as names.</summary>
[Fact]
public void Viewer_GetsVerificationResult_ForTheRequestedMonth()
{
var brk = new AuditChainBreak("2026-05", 7, Guid.Parse("11111111-2222-3333-4444-555555555555"),
AuditChainBreakKind.HashMismatch, "edited");
_chain.VerifyMonthAsync(Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns(new AuditChainVerificationResult("2026-05", AuditChainStatus.Broken, 6, 0, null, 43, 2, brk));
CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-05"), [], "Viewer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
using var json = JsonDocument.Parse(response.JsonData);
Assert.Equal("Broken", json.RootElement.GetProperty("status").GetString());
Assert.Equal("HashMismatch", json.RootElement.GetProperty("firstBreak").GetProperty("kind").GetString());
Assert.Equal(7, json.RootElement.GetProperty("firstBreak").GetProperty("chainSeq").GetInt64());
_chain.Received(1).VerifyMonthAsync(
new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), Arg.Any<CancellationToken>());
}
/// <summary>A Designer/Deployer without an OperationalAudit role is refused.</summary>
[Fact]
public void NonAuditRole_IsUnauthorized()
{
CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-05"), [], "Designer", "Deployer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
_chain.DidNotReceiveWithAnyArgs().VerifyMonthAsync(default, default);
}
/// <summary>A site-scoped Viewer is refused: the chain spans every site.</summary>
[Fact]
public void SiteScopedViewer_IsRefused()
{
CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-05"), ["3"], "Viewer"));
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
Assert.Contains("spans every site", response.Message);
_chain.DidNotReceiveWithAnyArgs().VerifyMonthAsync(default, default);
}
/// <summary>A malformed month is a caller error, not an internal fault.</summary>
[Fact]
public void MalformedMonth_IsInvalidArgument()
{
CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-13"), [], "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("INVALID_ARGUMENT", response.ErrorCode);
}
/// <summary>A verification that cannot get the chain lock surfaces as CHAIN_BUSY.</summary>
[Fact]
public void BusyChain_IsChainBusy()
{
_chain.VerifyMonthAsync(Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
.Returns<Task<AuditChainVerificationResult>>(_ => throw new TimeoutException("busy"));
CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-05"), [], "Administrator"));
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal("CHAIN_BUSY", response.ErrorCode);
}
/// <summary>Verification runs under the long-running Ask timeout, not the 30 s default.</summary>
[Fact]
public void VerifyAuditChain_UsesLongRunningAskTimeout()
{
Assert.Equal(TimeSpan.FromMinutes(5),
ManagementEndpoints.ResolveAskTimeout(new ManagementServiceOptions(), typeof(VerifyAuditChainCommand)));
}
}