Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/Commands/AuditArchiveCommandTests.cs
T
Joseph Doherty b95ee5a658 feat(audit): archive retention and superseded stale-archive cleanup (ScadaBridge#109)
Owner decisions recorded: the archive destination stays a directory/UNC
(no object-storage client; share + permissions named at enablement),
parquet-tools-only reading (no re-import), unsigned like the chain (#107).

AuditLog:Archive:RetentionDays (default 0 = never delete; else must exceed
AuditLog:RetentionDays, counted from the archived month's end) and
DeleteSupersededStaleArchives (default true) run after every purge tick.
A retried purge's stale Archived archive is deleted only when a later,
verifying, Purged archive of the same partition provably holds every row:
same prior anchor and the stale chain-end hash at its chain-end sequence
(row or tombstone), every unchained row present by EventId; otherwise it is
reported KeptStale. A sole copy left at Archived is never touched. Deletion
removes only the archive name's own three files in its folder, manifest
first, never a manifest-supplied path. `audit archive cleanup [--dry-run]`
= CleanupAuditArchives (Administrator).

AuditLogArchiveCleanupTests (MSSQL) cover retried purge, kept stale copy,
sole copy, retention and the path guard; each safety rule mutation-checked.
Follow-ups filed: #113, #114, #115.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 23:33:04 -05:00

165 lines
8.1 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 + cleanup (ScadaBridge#109).</summary>
[Fact]
public void AuditGroup_HasArchiveListVerifyAndCleanup()
{
var root = AuditCommandTestHarness.BuildRoot();
var archive = root.Subcommands.Single(c => c.Name == "audit").Subcommands.Single(c => c.Name == "archive");
Assert.Equal(["cleanup", "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>cleanup</c> (ScadaBridge#109) sends CleanupAuditArchives with the dry-run flag and
/// reports each action; it always exits 0.
/// </summary>
/// <param name="dryRun">Whether <c>--dry-run</c> is passed.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Cleanup_SendsCleanupAuditArchives_WithTheDryRunFlag(bool dryRun)
{
var body = "{\"dryRun\":" + (dryRun ? "true" : "false") + ",\"retentionDays\":0,\"items\":[{\"name\":\"" + Name
+ "\",\"partition\":\"2025-10\",\"action\":\"DeletedSuperseded\",\"detail\":\"superseded by x\"}]}";
string[] args = dryRun
? ["audit", "archive", "cleanup", "--dry-run", "--format", "table"]
: ["audit", "archive", "cleanup", "--format", "table"];
var (exit, output, request) = await RunAsync(body, args);
Assert.Equal(0, exit);
Assert.Contains("DeletedSuperseded", output);
Assert.Contains("never delete", output);
Assert.Equal(dryRun, output.Contains("Dry run", StringComparison.Ordinal));
using var payload = JsonDocument.Parse(request);
Assert.Equal("CleanupAuditArchives", payload.RootElement.GetProperty("command").GetString());
Assert.Equal(dryRun, payload.RootElement.GetProperty("payload").EnumerateObject()
.First(p => string.Equals(p.Name, "dryRun", StringComparison.OrdinalIgnoreCase)).Value.GetBoolean());
}
/// <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();
}
}
}