using System.Collections.Specialized;
using System.Net;
using System.Text;
using System.Web;
using ZB.MOM.WW.ScadaBridge.CLI;
using ZB.MOM.WW.ScadaBridge.CLI.Commands;
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests.Commands;
///
/// Tests for the scadabridge audit query subcommand:
/// time-spec resolution, query-string construction, formatter selection, error
/// handling, and keyset-cursor paging via --all.
///
[Collection("Console")]
public class AuditQueryCommandTests
{
// ---- Time-spec parsing -------------------------------------------------
/// Verifies that a relative hours time spec resolves to the current time minus that offset.
[Fact]
public void ResolveTimeSpec_RelativeHours_ResolvesToNowMinusOffset()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var resolved = AuditQueryHelpers.ResolveTimeSpec("1h", now);
Assert.Equal(DateTimeOffset.Parse("2026-05-20T11:00:00Z"), resolved);
}
/// Verifies that a relative days time spec resolves to the current time minus that offset.
[Fact]
public void ResolveTimeSpec_RelativeDays_ResolvesToNowMinusOffset()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var resolved = AuditQueryHelpers.ResolveTimeSpec("7d", now);
Assert.Equal(DateTimeOffset.Parse("2026-05-13T12:00:00Z"), resolved);
}
/// Verifies that an absolute ISO 8601 time spec is parsed verbatim.
[Fact]
public void ResolveTimeSpec_AbsoluteIso8601_ParsedVerbatim()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var resolved = AuditQueryHelpers.ResolveTimeSpec("2026-01-02T03:04:05Z", now);
Assert.Equal(DateTimeOffset.Parse("2026-01-02T03:04:05Z"), resolved);
}
/// Verifies that an unparseable time spec throws.
[Fact]
public void ResolveTimeSpec_Garbage_Throws()
{
var now = DateTimeOffset.UtcNow;
Assert.Throws(() => AuditQueryHelpers.ResolveTimeSpec("not-a-time", now));
}
// ---- Query string construction ----------------------------------------
/// Verifies that a fully populated flag set produces the expected query-string parameters.
[Fact]
public void BuildQueryString_FullFlagSet_ProducesExpectedParameters()
{
var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z");
var args = new AuditQueryArgs
{
Since = "1h",
Until = "2026-05-20T12:00:00Z",
Channel = new[] { "ApiOutbound" },
Kind = new[] { "ApiCallCached" },
Status = new[] { "Delivered" },
Site = new[] { "site-1" },
Target = "weather-api",
Actor = "multi-role",
CorrelationId = "abc-123",
ExecutionId = "def-456",
ParentExecutionId = "ghi-789",
ErrorsOnly = false,
PageSize = 250,
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, afterOccurredAtUtc: null, afterEventId: null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("ApiOutbound", parsed["channel"]);
Assert.Equal("ApiCallCached", parsed["kind"]);
Assert.Equal("Delivered", parsed["status"]);
Assert.Equal("site-1", parsed["sourceSiteId"]);
// The CLI audit query has no --instance flag, so no instance param is emitted.
Assert.Null(parsed["instance"]);
Assert.Equal("weather-api", parsed["target"]);
Assert.Equal("multi-role", parsed["actor"]);
Assert.Equal("abc-123", parsed["correlationId"]);
Assert.Equal("def-456", parsed["executionId"]);
Assert.Equal("ghi-789", parsed["parentExecutionId"]);
Assert.Equal("250", parsed["pageSize"]);
Assert.Equal("2026-05-20T11:00:00.0000000+00:00", parsed["fromUtc"]);
Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]);
}
/// Verifies that the errors-only flag maps to the failed status filter.
[Fact]
public void BuildQueryString_ErrorsOnly_MapsToFailedStatus()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ErrorsOnly = true };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("Failed", parsed["status"]);
}
/// Verifies that a multi-value channel filter emits one query key per value.
[Fact]
public void BuildQueryString_MultiValueChannel_EmitsOneKeyPerValue()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs
{
Channel = new[] { "ApiOutbound", "DbOutbound" },
Status = new[] { "Failed", "Parked" },
Site = new[] { "site-1", "site-2" },
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel"));
Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status"));
Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId"));
}
/// Verifies that the errors-only flag overrides any explicit status values.
[Fact]
public void BuildQueryString_ErrorsOnly_OverridesExplicitStatusValues()
{
// --errors-only stays a single-status override: it pins status=Failed and
// supersedes any explicit (multi-value) --status selection.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs
{
ErrorsOnly = true,
Status = new[] { "Delivered", "Parked" },
};
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal(new[] { "Failed" }, parsed.GetValues("status"));
}
/// Verifies that the paging cursor is appended after the filter parameters.
[Fact]
public void BuildQueryString_Cursor_AppendsAfterParameters()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs();
var after = DateTimeOffset.Parse("2026-05-20T10:00:00Z");
var qs = AuditQueryHelpers.BuildQueryString(args, now, after, "evt-99");
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("evt-99", parsed["afterEventId"]);
Assert.Equal("2026-05-20T10:00:00.0000000+00:00", parsed["afterOccurredAtUtc"]);
}
/// Verifies that unset filters are omitted from the query string.
[Fact]
public void BuildQueryString_OmitsUnsetFilters()
{
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { PageSize = 100 };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Null(parsed["channel"]);
Assert.Null(parsed["status"]);
Assert.Null(parsed["fromUtc"]);
Assert.Null(parsed["correlationId"]);
Assert.Null(parsed["executionId"]);
Assert.Null(parsed["parentExecutionId"]);
Assert.Equal("100", parsed["pageSize"]);
}
/// Verifies that an execution id filter emits the execution id parameter.
[Fact]
public void BuildQueryString_ExecutionId_EmitsExecutionIdParameter()
{
// --execution-id is a single-value Guid filter — mirrors --correlation-id.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ExecutionId = "11111111-1111-1111-1111-111111111111" };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("11111111-1111-1111-1111-111111111111", parsed["executionId"]);
}
/// Verifies that a parent execution id filter emits the parent execution id parameter.
[Fact]
public void BuildQueryString_ParentExecutionId_EmitsParentExecutionIdParameter()
{
// --parent-execution-id is a single-value Guid filter — mirrors --execution-id.
var now = DateTimeOffset.UtcNow;
var args = new AuditQueryArgs { ParentExecutionId = "22222222-2222-2222-2222-222222222222" };
var qs = AuditQueryHelpers.BuildQueryString(args, now, null, null);
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
Assert.Equal("22222222-2222-2222-2222-222222222222", parsed["parentExecutionId"]);
}
// ---- HTTP execution / paging ------------------------------------------
private sealed class RecordingHandler : HttpMessageHandler
{
private readonly Queue _bodies;
/// The request URIs received by the handler, in order.
public List RequestUris { get; } = new();
/// Creates a handler that replies with the given JSON bodies in sequence.
/// The response bodies to return, one per request.
public RecordingHandler(params string[] bodies)
{
_bodies = new Queue(bodies);
}
///
protected override Task SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestUris.Add(request.RequestUri!.PathAndQuery);
var body = _bodies.Count > 0 ? _bodies.Dequeue() : "{\"events\":[],\"nextCursor\":null}";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
});
}
}
/// Verifies that a single page of results is written as JSON lines.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RunQuery_SinglePage_WritesEventsAsJsonLines()
{
var handler = new RecordingHandler(
"{\"events\":[{\"eventId\":\"e1\"},{\"eventId\":\"e2\"}],\"nextCursor\":null}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(0, exit);
var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
Assert.Contains("e1", lines[0]);
Assert.Contains("e2", lines[1]);
Assert.Single(handler.RequestUris);
}
/// Verifies that the all flag follows the next cursor across pages.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RunQuery_WithAll_FollowsNextCursorAcrossPages()
{
var handler = new RecordingHandler(
"{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}",
"{\"events\":[{\"eventId\":\"e2\"}],\"nextCursor\":null}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs { PageSize = 100 }, fetchAll: true,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(0, exit);
Assert.Equal(2, handler.RequestUris.Count);
Assert.Contains("afterEventId=e1", handler.RequestUris[1]);
var lines = output.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(2, lines.Length);
}
/// Verifies that without the all flag only the first page is fetched even when a cursor is present.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RunQuery_WithoutAll_StopsAfterFirstPageEvenWhenCursorPresent()
{
var handler = new RecordingHandler(
"{\"events\":[{\"eventId\":\"e1\"}],\"nextCursor\":{\"afterOccurredAtUtc\":\"2026-05-20T10:00:00Z\",\"afterEventId\":\"e1\"}}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs { PageSize = 100 }, fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(0, exit);
Assert.Single(handler.RequestUris);
}
/// Verifies that a server error returns a non-zero exit code.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RunQuery_ServerError_ReturnsNonZeroExit()
{
var handler = new ErrorHandler();
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.NotEqual(0, exit);
}
/// Verifies that an HTTP 403 response returns exit code 2.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RunQuery_Http403_ReturnsExitCode2()
{
// An authorization failure on /api/audit/query (HTTP 403) must
// produce exit code 2 per the documented CLI exit-code contract — the
// legacy bare exit code 1 return masked auth failures as generic command failures.
var handler = new StatusHandler(HttpStatusCode.Forbidden, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
/// Verifies that an UNAUTHORIZED error code on a status other than 403 returns exit code 2.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RunQuery_UnauthorizedCodeOnNon403_ReturnsExitCode2()
{
// The server may signal authorization failure via the error code on a
// status other than 403 (e.g. 400 + code=UNAUTHORIZED). Honour both channels.
var handler = new StatusHandler(HttpStatusCode.BadRequest, "{\"error\":\"nope\",\"code\":\"UNAUTHORIZED\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(2, exit);
}
/// Verifies that a generic server error returns exit code 1.
/// A task that represents the asynchronous operation.
[Fact]
public async Task RunQuery_GenericServerError_ReturnsExitCode1()
{
// Authentication / internal errors (status other than 403, no auth code) must remain
// exit code 1 — exit 2 is reserved for authorization failures.
var handler = new StatusHandler(HttpStatusCode.InternalServerError, "{\"error\":\"boom\",\"code\":\"INTERNAL\"}");
var client = new ManagementHttpClient(new HttpClient(handler), "http://localhost:9001", "u", "p");
var output = new StringWriter();
var exit = await AuditQueryHelpers.RunQueryAsync(
client, new AuditQueryArgs(), fetchAll: false,
new JsonLinesAuditFormatter(), output, DateTimeOffset.UtcNow);
Assert.Equal(1, exit);
}
private sealed class ErrorHandler : HttpMessageHandler
{
///
protected override Task SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{\"error\":\"boom\",\"code\":\"INTERNAL\"}"),
});
}
private sealed class StatusHandler : HttpMessageHandler
{
private readonly HttpStatusCode _status;
private readonly string _body;
/// Creates a handler that always replies with the given status and body.
/// The HTTP status code to return.
/// The response body to return.
public StatusHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
///
protected override Task SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
// ---- CLI parsing -------------------------------------------------------
/// Verifies that an unknown flag produces a parse error and a non-zero exit code.
[Fact]
public void Query_UnknownFlag_ProducesParseErrorAndNonZeroExit()
{
var root = AuditCommandTestHarness.BuildRoot();
var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--bogus", "x");
Assert.NotEqual(0, exit);
Assert.NotEqual("", err);
}
/// Verifies that the table output format is accepted.
[Fact]
public void Query_FormatTable_IsAccepted()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--format", "table" });
Assert.Empty(parse.Errors);
}
/// Verifies that the execution id option is accepted.
[Fact]
public void Query_ExecutionIdOption_IsAccepted()
{
// --execution-id is a single-value option — mirrors --correlation-id.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--execution-id", "11111111-1111-1111-1111-111111111111",
});
Assert.Empty(parse.Errors);
}
/// Verifies that the parent execution id option is accepted.
[Fact]
public void Query_ParentExecutionIdOption_IsAccepted()
{
// --parent-execution-id is a single-value option — mirrors --execution-id.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--parent-execution-id", "22222222-2222-2222-2222-222222222222",
});
Assert.Empty(parse.Errors);
}
// ---- Enum-name validation (fast-fail) ----------------------------------
/// Verifies that a channel option using a real enum name is accepted.
[Fact]
public void Query_ChannelWithRealEnumName_IsAccepted()
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound" });
Assert.Empty(parse.Errors);
}
/// Verifies that multiple channel values in a single token are accepted.
[Fact]
public void Query_MultipleChannelValues_SingleToken_AreAccepted()
{
// AllowMultipleArgumentsPerToken: --channel A B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound", "DbOutbound" });
Assert.Empty(parse.Errors);
}
/// Verifies that multiple channel values given by repeating the flag are accepted.
[Fact]
public void Query_MultipleChannelValues_RepeatedFlag_AreAccepted()
{
// --channel A --channel B parses as two values.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[]
{
"audit", "query", "--channel", "ApiOutbound", "--channel", "Notification",
});
Assert.Empty(parse.Errors);
}
// ========================================================================
// Register row 99: filter-vocabulary validation moved OFF the CLI.
//
// These four tests used to assert that AcceptOnlyFromAmong rejected an
// unrecognised --channel/--kind/--status at the parse layer. That gate matched
// case-SENSITIVELY, so it also rejected `--status delivered`, which the server
// has always accepted — the CLI was stricter than the system it drives. The
// gate is gone; the parse layer now accepts any string and the server answers
// 400 naming the valid set (AuditEndpointsTests pins that side).
//
// What is asserted here is the CLIENT half of that contract: the value is
// carried through to the request rather than rejected locally.
// ========================================================================
/// Verifies that an unrecognised filter value is not rejected at the parse layer.
/// The filter option name.
/// The filter value to pass.
[Theory]
[InlineData("--channel", "OutboundApi")] // old (non-existent) name; the real enum is "ApiOutbound"
[InlineData("--kind", "CachedCall")]
[InlineData("--status", "Bogus")]
public void Query_UnrecognisedFilterValue_IsNotRejectedAtTheParseLayer(string option, string value)
{
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", option, value });
Assert.Empty(parse.Errors);
}
/// Verifies that a multi-value channel filter containing one unrecognised name still parses.
[Fact]
public void Query_MultiValueChannel_WithOneUnrecognisedName_StillParses()
{
// The multi-value shape is unchanged; only the vocabulary gate is gone, so
// BOTH values reach the server for it to adjudicate.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound", "OutboundApi" });
Assert.Empty(parse.Errors);
}
/// Verifies that a filter value in any letter case is accepted.
/// The filter option name.
/// The filter value to pass.
[Theory]
[InlineData("--channel", "apioutbound")]
[InlineData("--kind", "apicall")]
[InlineData("--status", "delivered")]
[InlineData("--status", "DELIVERED")]
public void Query_FilterValueInAnyCase_IsAccepted(string option, string value)
{
// The defect row 99 names: these all failed client-side before the fix.
var root = AuditCommandTestHarness.BuildRoot();
var parse = root.Parse(new[] { "audit", "query", option, value });
Assert.Empty(parse.Errors);
}
}