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:
@@ -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 <c>scadabridge audit</c> command group. Provides read access to
|
||||
/// the centralized append-only Audit Log via the REST endpoints
|
||||
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>,
|
||||
/// <c>GET /api/audit/tree</c>), plus a v1 no-op <c>verify-chain</c> placeholder
|
||||
/// for the deferred hash-chain tamper-evidence feature.
|
||||
/// <c>GET /api/audit/tree</c>), plus <c>verify-chain</c>, which sends the
|
||||
/// <c>VerifyAuditChain</c> management command (hash-chain tamper evidence, ScadaBridge#41).
|
||||
/// </summary>
|
||||
public static class AuditCommands
|
||||
{
|
||||
@@ -288,11 +289,14 @@ public static class AuditCommands
|
||||
|
||||
private static Command BuildVerifyChain(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var monthOption = new Option<string>("--month") { Description = "Month to verify (YYYY-MM)", Required = true };
|
||||
var monthOption = new Option<string>("--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;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for the <c>audit verify-chain</c> subcommand. v1 is a no-op: hash-chain
|
||||
/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still
|
||||
/// validates its <c>--month</c> argument so the surface is stable for v1.x.
|
||||
/// Helpers for the <c>audit verify-chain</c> subcommand (ScadaBridge#41). The command sends
|
||||
/// the <c>VerifyAuditChain</c> management command for one UTC calendar month and reports
|
||||
/// whether that month's AuditLog hash chain is intact, or the first break (chain partition,
|
||||
/// <c>ChainSeq</c>, <c>EventId</c>, kind).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exit codes: <c>0</c> intact (or nothing chained yet), <see cref="BrokenExitCode"/> when
|
||||
/// the chain is broken, <c>1</c> on a request error, <c>2</c> on an authorization failure.
|
||||
/// </remarks>
|
||||
public static class AuditVerifyChainHelpers
|
||||
{
|
||||
/// <summary>Process exit code when verification found a break (tamper evidence).</summary>
|
||||
public const int BrokenExitCode = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(6);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="month"/> is a well-formed <c>YYYY-MM</c> value
|
||||
/// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
|
||||
@@ -19,4 +34,53 @@ public static class AuditVerifyChainHelpers
|
||||
=> !string.IsNullOrWhiteSpace(month)
|
||||
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a <c>VerifyAuditChain</c> result and maps it to the process exit code.
|
||||
/// <c>json</c> prints the server body verbatim; <c>table</c> prints a short report.
|
||||
/// </summary>
|
||||
/// <param name="json">The success body (an <c>AuditChainVerificationResult</c>, camelCase).</param>
|
||||
/// <param name="format">Resolved output format.</param>
|
||||
/// <param name="output">Writer for the report.</param>
|
||||
/// <returns><c>0</c> when intact / not chained, <see cref="BrokenExitCode"/> when broken.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -23,3 +23,11 @@ public record QueryAuditLogCommand(
|
||||
/// <param name="Entries">The page of configuration-change audit entries.</param>
|
||||
/// <param name="TotalCount">Total number of rows matching the filters (unpaged).</param>
|
||||
public record AuditLogQueryResult(IReadOnlyList<AuditLogEntry> Entries, int TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="Month">The month to verify, <c>YYYY-MM</c>.</param>
|
||||
public record VerifyAuditChainCommand(string Month);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies one month of the AuditLog hash chain (ScadaBridge#41). The month must be
|
||||
/// <c>YYYY-MM</c>; a site-scoped, non-Administrator caller is refused because the chain
|
||||
/// covers every site's rows and a break report names rows by EventId.
|
||||
/// </summary>
|
||||
private static async Task<object?> 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<IAuditLogHashChain>();
|
||||
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
|
||||
// ========================================================================
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user