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