From 548bb9a725642f63d412509e66c08ad144ed9cc4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 9 Nov 2026 16:28:54 -0500 Subject: [PATCH] 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 --- .../Commands/AuditCommands.cs | 24 ++-- .../Commands/AuditVerifyChainHelpers.cs | 70 +++++++++++- .../Messages/Management/AuditLogCommands.cs | 8 ++ .../ManagementActor.cs | 46 ++++++++ .../ManagementEndpoints.cs | 3 + .../Commands/AuditVerifyChainCommandTests.cs | 94 +++++++++++++--- .../ManagementSurfaceParityTests.cs | 6 +- .../RequiredRoleMatrixTests.cs | 5 + .../VerifyAuditChainCommandTests.cs | 104 ++++++++++++++++++ 9 files changed, 333 insertions(+), 27 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/VerifyAuditChainCommandTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs index 561f3527..8ec96f77 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.CommandLine.Parsing; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; @@ -7,8 +8,8 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// The scadabridge audit command group. Provides read access to /// the centralized append-only Audit Log via the REST endpoints /// (GET /api/audit/query, GET /api/audit/export, -/// GET /api/audit/tree), plus a v1 no-op verify-chain placeholder -/// for the deferred hash-chain tamper-evidence feature. +/// GET /api/audit/tree), plus verify-chain, which sends the +/// VerifyAuditChain management command (hash-chain tamper evidence, ScadaBridge#41). /// public static class AuditCommands { @@ -288,11 +289,14 @@ public static class AuditCommands private static Command BuildVerifyChain(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { - var monthOption = new Option("--month") { Description = "Month to verify (YYYY-MM)", Required = true }; + var monthOption = new Option("--month") { Description = "UTC calendar month to verify (YYYY-MM)", Required = true }; - var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" }; + var cmd = new Command("verify-chain") + { + Description = "Verify the audit log hash chain for a month; exit 3 and report the first break if it was tampered with", + }; cmd.Add(monthOption); - cmd.SetAction((ParseResult result) => + cmd.SetAction(async (ParseResult result) => { var month = result.GetValue(monthOption)!; if (!AuditVerifyChainHelpers.IsValidMonth(month)) @@ -302,10 +306,12 @@ public static class AuditCommands return 1; } - Console.WriteLine( - "Hash-chain tamper-evidence is not enabled in this release. " - + "See Component-AuditLog.md (Security & Tamper-Evidence) for the v1.x roadmap."); - return 0; + var format = AuditCommandHelpers.ResolveFormat(result, formatOption); + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new VerifyAuditChainCommand(month), + timeout: AuditVerifyChainHelpers.RequestTimeout, + onSuccess: json => AuditVerifyChainHelpers.Render(json, format, Console.Out)); }); return cmd; } diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditVerifyChainHelpers.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditVerifyChainHelpers.cs index cc6dcb38..3e3e142f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditVerifyChainHelpers.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditVerifyChainHelpers.cs @@ -1,14 +1,29 @@ using System.Globalization; +using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.CLI.Commands; /// -/// Helpers for the audit verify-chain subcommand. v1 is a no-op: hash-chain -/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still -/// validates its --month argument so the surface is stable for v1.x. +/// Helpers for the audit verify-chain subcommand (ScadaBridge#41). The command sends +/// the VerifyAuditChain management command for one UTC calendar month and reports +/// whether that month's AuditLog hash chain is intact, or the first break (chain partition, +/// ChainSeq, EventId, kind). /// +/// +/// Exit codes: 0 intact (or nothing chained yet), when +/// the chain is broken, 1 on a request error, 2 on an authorization failure. +/// public static class AuditVerifyChainHelpers { + /// Process exit code when verification found a break (tamper evidence). + public const int BrokenExitCode = 3; + + /// + /// Client deadline. The server bounds the call with the long-running management Ask + /// (5 min); the client waits a little longer so the server's answer always arrives. + /// + public static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(6); + /// /// Returns true if is a well-formed YYYY-MM value /// with a real month (01-12). A malformed month (e.g. 2026-13) is rejected. @@ -19,4 +34,53 @@ public static class AuditVerifyChainHelpers => !string.IsNullOrWhiteSpace(month) && DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _); + + /// + /// Renders a VerifyAuditChain result and maps it to the process exit code. + /// json prints the server body verbatim; table prints a short report. + /// + /// The success body (an AuditChainVerificationResult, camelCase). + /// Resolved output format. + /// Writer for the report. + /// 0 when intact / not chained, when broken. + public static int Render(string json, string format, TextWriter output) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var status = root.TryGetProperty("status", out var s) ? s.GetString() ?? "" : ""; + var broken = string.Equals(status, "Broken", StringComparison.OrdinalIgnoreCase); + + if (!string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) + { + output.WriteLine(json); + output.Flush(); + return broken ? BrokenExitCode : 0; + } + + output.WriteLine($"Partition : {Str(root, "partition")}"); + output.WriteLine($"Status : {status}"); + output.WriteLine($"Rows verified : {Num(root, "rowsVerified")}"); + output.WriteLine($"Tombstones applied: {Num(root, "tombstonesApplied")}"); + output.WriteLine($"Purged through : {Num(root, "purgedThroughSeq", "-")}"); + output.WriteLine($"Head sequence : {Num(root, "headSeq", "-")}"); + output.WriteLine($"Unchained rows : {Num(root, "unchainedRows")}"); + if (root.TryGetProperty("firstBreak", out var brk) && brk.ValueKind == JsonValueKind.Object) + { + output.WriteLine("First break:"); + output.WriteLine($" partition : {Str(brk, "partition")}"); + output.WriteLine($" chainSeq : {Num(brk, "chainSeq")}"); + output.WriteLine($" eventId : {Str(brk, "eventId", "-")}"); + output.WriteLine($" kind : {Str(brk, "kind")}"); + output.WriteLine($" detail : {Str(brk, "detail")}"); + } + + output.Flush(); + return broken ? BrokenExitCode : 0; + } + + private static string Str(JsonElement e, string name, string missing = "") + => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? missing : missing; + + private static string Num(JsonElement e, string name, string missing = "0") + => e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt64().ToString(CultureInfo.InvariantCulture) : missing; } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs index 2d00ca7c..8a024ae2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/AuditLogCommands.cs @@ -23,3 +23,11 @@ public record QueryAuditLogCommand( /// The page of configuration-change audit entries. /// Total number of rows matching the filters (unpaged). public record AuditLogQueryResult(IReadOnlyList Entries, int TotalCount); + +/// +/// Verifies the central AuditLog hash chain for one UTC calendar month (audit roadmap T1, +/// ScadaBridge#41). Read-only; gated on the OperationalAudit roles and refused for a +/// site-scoped caller, because a month's chain spans every site. +/// +/// The month to verify, YYYY-MM. +public record VerifyAuditChainCommand(string Month); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index a212173a..cafd61c4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -246,6 +246,9 @@ public class ManagementActor : ReceiveActor // three Live run commands stay DeployerOnly. private static readonly string[] FlowDebuggerReplayRoles = AuthorizationPolicies.FlowDebuggerAccessRoles; + // ScadaBridge#41: verify-chain is an audit read — the OperationalAudit role set. + private static readonly string[] OperationalAuditReaders = AuthorizationPolicies.OperationalAuditRoles; + // arch-review C6: area management is reconciled to any-of [Designer, Deployer]. // Areas are authored on both real surfaces — the Designer template/topology // tooling AND the Central UI Deployment Topology workflow (Topology.razor is @@ -384,6 +387,12 @@ public class ManagementActor : ReceiveActor // (arch-review UA1). ListSecuredWritesCommand => SecuredWriteReaders, + // AuditLog hash-chain verification (ScadaBridge#41): an audit read, so the + // OperationalAudit roles (alog.md §15.1 — verify-chain carries audit query's + // permission). A site-scoped caller is refused in the handler: a month's chain + // spans every site. + VerifyAuditChainCommand => OperationalAuditReaders, + // Read-only queries -- any authenticated user _ => null }; @@ -554,6 +563,7 @@ public class ManagementActor : ReceiveActor // Audit Log QueryAuditLogCommand cmd => await HandleQueryAuditLog(sp, cmd), + VerifyAuditChainCommand cmd => await HandleVerifyAuditChain(sp, cmd, user), // Health GetHealthSummaryCommand => HandleGetHealthSummary(sp), @@ -3210,6 +3220,42 @@ public class ManagementActor : ReceiveActor return new AuditLogQueryResult(entries, totalCount); } + /// + /// Verifies one month of the AuditLog hash chain (ScadaBridge#41). The month must be + /// YYYY-MM; a site-scoped, non-Administrator caller is refused because the chain + /// covers every site's rows and a break report names rows by EventId. + /// + private static async Task HandleVerifyAuditChain( + IServiceProvider sp, VerifyAuditChainCommand cmd, AuthenticatedUser user) + { + if (string.IsNullOrWhiteSpace(cmd.Month) + || !DateTime.TryParseExact(cmd.Month, "yyyy-MM", System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var month)) + { + throw new ManagementCommandException( + $"Invalid month '{cmd.Month}'. Expected YYYY-MM (e.g. 2026-05).", "INVALID_ARGUMENT"); + } + + if (user.PermittedSiteIds.Length > 0 + && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) + { + throw new SiteScopeViolationException( + "Access denied: the audit hash chain spans every site; verify-chain needs an " + + "OperationalAudit role that is not scoped to specific sites."); + } + + var chain = sp.GetRequiredService(); + try + { + return await chain.VerifyMonthAsync( + new DateTime(month.Year, month.Month, 1, 0, 0, 0, DateTimeKind.Utc)); + } + catch (TimeoutException ex) + { + throw new ManagementCommandException(ex.Message, "CHAIN_BUSY"); + } + } + // ======================================================================== // Health handlers // ======================================================================== diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs index bd605b77..f0e12098 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementEndpoints.cs @@ -60,6 +60,9 @@ public static class ManagementEndpoints // script entry (design 2026-09-08): a direct call lasts up to the script's timeout // (+ grace), a write kind up to its wait window. typeof(FlowDebugRunScriptCommand), + // AuditLog hash-chain verification (ScadaBridge#41) recomputes one hash per row of + // a whole month — millions of rows on a busy central. + typeof(VerifyAuditChainCommand), ]; /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs index 1aa67755..ccfde2db 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditVerifyChainCommandTests.cs @@ -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; /// -/// Tests for the scadabridge audit verify-chain subcommand. -/// v1 is a no-op stub: a valid --month prints the documented "not enabled" -/// message and exits 0; a malformed month or a missing --month exits non-zero. +/// Tests for the scadabridge audit verify-chain subcommand (ScadaBridge#41). The +/// command sends the VerifyAuditChain management command for the month and maps +/// the verification status onto the exit code: 0 intact / not chained, 3 broken. /// [Collection("Console")] public class AuditVerifyChainCommandTests { - /// Verifies that verifying the chain for a valid month exits with zero and prints the documented message. - [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\"}}"; + + /// + /// CLI round trip: the request carries VerifyAuditChain + the month, and the + /// server's verdict drives the exit code and the report. + /// + /// The verification result the fake management endpoint serves. + /// Output format. + /// Expected exit code. + /// Text the report must contain. + /// A task that represents the asynchronous operation. + [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(); + } } - /// Verifies that a malformed month value makes the command exit with a non-zero code. + /// Verifies that a malformed month value exits non-zero without contacting the server. [Fact] public void VerifyChain_MalformedMonth_ExitsNonZero() { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs index ab4756a0..d65ee09e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementSurfaceParityTests.cs @@ -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(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" }; diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs index 3c01ed27..1f44a349 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs @@ -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], diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/VerifyAuditChainCommandTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/VerifyAuditChainCommandTests.cs new file mode 100644 index 00000000..f559c2cb --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/VerifyAuditChainCommandTests.cs @@ -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; + +/// +/// The VerifyAuditChain management command (ScadaBridge#41): role gate, month +/// parsing, site-scope refusal, and the verification result reaching the wire with its +/// enums as names. +/// +public class VerifyAuditChainCommandTests : TestKit +{ + private readonly IAuditLogHashChain _chain = Substitute.For(); + + private IActorRef CreateActor() + { + var services = new ServiceCollection().AddTestConfigurationTransaction(); + services.AddScoped(_ => _chain); + return Sys.ActorOf(Props.Create(() => new ManagementActor( + services.BuildServiceProvider(), NullLogger.Instance))); + } + + private static ManagementEnvelope Envelope(object command, string[] sites, params string[] roles) => + new(new AuthenticatedUser("u", "U", roles, sites), command, Guid.NewGuid().ToString("N")); + + /// A Viewer gets the verifier's result for the requested month, enums as names. + [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(), Arg.Any()) + .Returns(new AuditChainVerificationResult("2026-05", AuditChainStatus.Broken, 6, 0, null, 43, 2, brk)); + + CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-05"), [], "Viewer")); + + var response = ExpectMsg(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()); + } + + /// A Designer/Deployer without an OperationalAudit role is refused. + [Fact] + public void NonAuditRole_IsUnauthorized() + { + CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-05"), [], "Designer", "Deployer")); + + ExpectMsg(TimeSpan.FromSeconds(5)); + _chain.DidNotReceiveWithAnyArgs().VerifyMonthAsync(default, default); + } + + /// A site-scoped Viewer is refused: the chain spans every site. + [Fact] + public void SiteScopedViewer_IsRefused() + { + CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-05"), ["3"], "Viewer")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("spans every site", response.Message); + _chain.DidNotReceiveWithAnyArgs().VerifyMonthAsync(default, default); + } + + /// A malformed month is a caller error, not an internal fault. + [Fact] + public void MalformedMonth_IsInvalidArgument() + { + CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-13"), [], "Administrator")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("INVALID_ARGUMENT", response.ErrorCode); + } + + /// A verification that cannot get the chain lock surfaces as CHAIN_BUSY. + [Fact] + public void BusyChain_IsChainBusy() + { + _chain.VerifyMonthAsync(Arg.Any(), Arg.Any()) + .Returns>(_ => throw new TimeoutException("busy")); + + CreateActor().Tell(Envelope(new VerifyAuditChainCommand("2026-05"), [], "Administrator")); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("CHAIN_BUSY", response.ErrorCode); + } + + /// Verification runs under the long-running Ask timeout, not the 30 s default. + [Fact] + public void VerifyAuditChain_UsesLongRunningAskTimeout() + { + Assert.Equal(TimeSpan.FromMinutes(5), + ManagementEndpoints.ResolveAskTimeout(new ManagementServiceOptions(), typeof(VerifyAuditChainCommand))); + } +}