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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user