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; /// /// Tests for scadabridge audit archive list / verify (ScadaBridge#42): each /// sends its management command (ListAuditArchives, VerifyAuditArchive), and /// verify maps the verdict onto the exit code (0 verified, 3 not verified). /// [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\"}]}"; /// The audit group carries the archive group with list + verify. [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()); } /// list sends ListAuditArchives and prints the archives. /// A task that represents the asynchronous operation. [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()); } /// verify sends VerifyAuditArchive with the name and maps the verdict to the exit code. /// The verification result the fake endpoint serves. /// Output format. /// Expected exit code. /// Text the report must contain. /// A task that represents the asynchronous operation. [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); } /// verify without --name is a usage error. [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(); } } }