feat(management): parquet audit export and audit archive list/verify (ScadaBridge#42)

GET /api/audit/export?format=parquet returned 501. It now streams the same 24
columns as csv/jsonl (AuditExportRow, CsvHeader order) as one Parquet file,
one 10,000-row row group at a time. Each row group is encoded into a small
non-seekable staging buffer and copied to the response asynchronously, because
Kestrel rejects the synchronous writes the Parquet writer issues.

New management commands, with CLI parity:
- ListAuditArchives -> `audit archive list` (AuditArchiveListResult: enabled
  flag + every manifest).
- VerifyAuditArchive(Name) -> `audit archive verify --name` (files, chain,
  purge anchor); exit 0 verified, 3 not verified, 1 error, 2 unauthorized.
Both carry verify-chain's gate: OperationalAudit roles (Administrator, Viewer),
site-scoped non-Administrator refused because an archive spans every site.
VerifyAuditArchive uses the long-running Ask timeout. Error codes
ARCHIVE_NOT_CONFIGURED, INVALID_ARGUMENT, NOT_FOUND.

RequiredRoleMatrixTests freezes both gates; ManagementSurfaceParityTests now
probes `audit archive list|verify` alongside `audit verify-chain`.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 17:00:58 -05:00
parent 7be4f19861
commit d1fc336e18
17 changed files with 982 additions and 42 deletions
@@ -69,7 +69,7 @@ public class CommandTreeTests
Assert.Contains("bundle", groupNames);
}
/// <summary>Verifies that the audit command group exposes the query, export and verify-chain subcommands.</summary>
/// <summary>Verifies that the audit command group exposes the query, export, verify-chain and archive subcommands.</summary>
[Fact]
public void AuditCommandGroup_HasQueryExportAndVerifyChain()
{
@@ -80,6 +80,7 @@ public class CommandTreeTests
Assert.Contains("query", subNames);
Assert.Contains("export", subNames);
Assert.Contains("verify-chain", subNames);
Assert.Contains("archive", subNames);
}
/// <summary>Verifies that the bundle command group exposes the export, preview and import subcommands.</summary>
@@ -0,0 +1,136 @@
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 <c>scadabridge audit archive list</c> / <c>verify</c> (ScadaBridge#42): each
/// sends its management command (<c>ListAuditArchives</c>, <c>VerifyAuditArchive</c>), and
/// <c>verify</c> maps the verdict onto the exit code (0 verified, 3 not verified).
/// </summary>
[Collection("Console")]
public class AuditArchiveCommandTests
{
private const string Name = "auditlog-2025-10-20261109T120000123Z";
private const string ListBody =
"{\"enabled\":true,\"archives\":[{\"name\":\"" + Name + "\",\"partition\":\"2025-10\",\"status\":\"Purged\"," +
"\"rowCount\":11,\"chainedRowCount\":10,\"dataBytes\":4096}]}";
private const string VerifiedBody =
"{\"name\":\"" + Name + "\",\"partition\":\"2025-10\",\"manifestStatus\":\"Purged\",\"verified\":true,\"filesIntact\":true," +
"\"rowsRead\":11,\"chains\":[{\"chain\":{\"partition\":\"2025-10\",\"status\":\"Intact\",\"rowsVerified\":10," +
"\"tombstonesApplied\":2,\"unchainedRows\":1},\"anchor\":\"Matches\",\"anchorDetail\":\"ends at the anchor\"}]}";
private const string MismatchBody =
"{\"name\":\"" + Name + "\",\"partition\":\"2025-10\",\"manifestStatus\":\"Purged\",\"verified\":false,\"filesIntact\":true," +
"\"rowsRead\":11,\"chains\":[{\"chain\":{\"partition\":\"2025-10\",\"status\":\"Intact\",\"rowsVerified\":10," +
"\"tombstonesApplied\":0,\"unchainedRows\":0},\"anchor\":\"Mismatch\",\"anchorDetail\":\"hash differs\"}]}";
/// <summary>The audit group carries the archive group with list + verify.</summary>
[Fact]
public void AuditGroup_HasArchiveListAndVerify()
{
var root = AuditCommandTestHarness.BuildRoot();
var archive = root.Subcommands.Single(c => c.Name == "audit").Subcommands.Single(c => c.Name == "archive");
Assert.Equal(["list", "verify"], archive.Subcommands.Select(c => c.Name).Order());
}
/// <summary><c>list</c> sends ListAuditArchives and prints the archives.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task List_SendsListAuditArchives_AndPrintsTheArchives()
{
var (exit, output, request) = await RunAsync(ListBody, "audit", "archive", "list", "--format", "table");
Assert.Equal(0, exit);
Assert.Contains(Name, output);
Assert.Contains("Archiving: enabled", output);
using var payload = JsonDocument.Parse(request);
Assert.Equal("ListAuditArchives", payload.RootElement.GetProperty("command").GetString());
}
/// <summary><c>verify</c> sends VerifyAuditArchive with the name and maps the verdict to the exit code.</summary>
/// <param name="body">The verification result the fake 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(VerifiedBody, "table", 0, "anchor Matches")]
[InlineData(VerifiedBody, "json", 0, "\"verified\":true")]
[InlineData(MismatchBody, "table", 3, "Verified : NO")]
[InlineData(MismatchBody, "json", 3, "\"anchor\":\"Mismatch\"")]
public async Task Verify_SendsVerifyAuditArchive_AndMapsVerdictToExitCode(
string body, string format, int expectedExit, string expectedText)
{
var (exit, output, request) = await RunAsync(body, "audit", "archive", "verify", "--name", Name, "--format", format);
Assert.Equal(expectedExit, exit);
Assert.Contains(expectedText, output);
using var payload = JsonDocument.Parse(request);
Assert.Equal("VerifyAuditArchive", payload.RootElement.GetProperty("command").GetString());
var name = payload.RootElement.GetProperty("payload").EnumerateObject()
.First(p => string.Equals(p.Name, "name", StringComparison.OrdinalIgnoreCase)).Value.GetString();
Assert.Equal(Name, name);
}
/// <summary><c>verify</c> without <c>--name</c> is a usage error.</summary>
[Fact]
public void Verify_MissingName_ProducesRequiredFlagError()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "archive", "verify");
Assert.NotEqual(0, exit);
Assert.Contains("--name", err);
}
private static async Task<(int Exit, string Output, string Request)> RunAsync(string body, params string[] args)
{
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, [.. args, "--url", $"http://127.0.0.1:{port}", "--username", "u", "--password", "p"]);
await server.WaitAsync(TimeSpan.FromSeconds(5));
return (exit, output, requestBody!);
}
finally
{
listener.Stop();
}
}
}
@@ -27,14 +27,14 @@ public class AuditCommandsScaffoldTests
Assert.False(string.IsNullOrWhiteSpace(audit.Description));
}
/// <summary>Verifies that the audit group exposes the query, export, tree, verify-chain and backfill-source-node subcommands.</summary>
/// <summary>Verifies that the audit group exposes the query, export, tree, verify-chain, backfill-source-node and archive subcommands.</summary>
[Fact]
public void Audit_HasFiveSubcommands_QueryExportTreeVerifyChainBackfillSourceNode()
public void Audit_HasSixSubcommands_QueryExportTreeVerifyChainBackfillSourceNodeArchive()
{
var audit = BuildAudit();
var names = audit.Subcommands.Select(c => c.Name).OrderBy(n => n).ToArray();
Assert.Equal(
new[] { "backfill-source-node", "export", "query", "tree", "verify-chain" },
new[] { "archive", "backfill-source-node", "export", "query", "tree", "verify-chain" },
names);
}
@@ -62,8 +62,11 @@ public class AuditCommandsScaffoldTests
[Fact]
public void Audit_EveryLeafCommand_HasAnAction()
{
// `archive` is a group (list / verify, ScadaBridge#42): check its leaves, not it.
var audit = BuildAudit();
Assert.All(audit.Subcommands, sub =>
var leaves = audit.Subcommands.SelectMany(c => c.Subcommands.Count == 0 ? [c] : c.Subcommands).ToList();
Assert.Equal(7, leaves.Count);
Assert.All(leaves, sub =>
Assert.True(sub.Action != null, $"Leaf command '{sub.Name}' has no action."));
}
}
@@ -44,6 +44,9 @@ public class ManagementSurfaceParityTests
Assert.Contains("ListSharedScripts", sent.Keys);
}
private static readonly HashSet<string> AuditManagementLeaves =
["audit verify-chain", "audit archive list", "audit archive verify"];
private static async Task<ConcurrentDictionary<string, string>> ProbeProductionTree(string? omitGroup = null)
{
var root = CliApplication.BuildRoot();
@@ -93,9 +96,10 @@ public class ManagementSurfaceParityTests
foreach (var (leaf, path) in Leaves(root, []))
{
// Separate REST and persistent SignalR surfaces, not management contracts.
// `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")
// `audit verify-chain` (VerifyAuditChain, ScadaBridge#41) and `audit archive
// list|verify` (ListAuditArchives / VerifyAuditArchive, ScadaBridge#42) are the
// audit leaves that ARE management contracts, so they stay in the probe.
if (path.Length == 0 || (path[0] == "audit" && !AuditManagementLeaves.Contains(string.Join(' ', path)))
|| path[0] == omitGroup
|| string.Join(' ', path) == "debug stream") continue;
currentPath = string.Join(' ', path);