Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditArchiveCommandTests.cs
T
Joseph Doherty d1fc336e18 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
2026-11-09 17:12:12 -05:00

137 lines
6.6 KiB
C#

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();
}
}
}