using System.Collections.Specialized; using System.Net; using System.Text; using System.Web; using ScadaLink.CLI; using ScadaLink.CLI.Commands; namespace ScadaLink.CLI.Tests.Commands; /// /// Tests for the scadalink audit query subcommand (Audit Log #23 M8-T2): /// time-spec resolution, query-string construction, formatter selection, error /// handling, and keyset-cursor paging via --all. /// [Collection("Console")] public class AuditQueryCommandTests { // ---- Time-spec parsing ------------------------------------------------- [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); } [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); } [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); } [Fact] public void ResolveTimeSpec_Garbage_Throws() { var now = DateTimeOffset.UtcNow; Assert.Throws(() => AuditQueryHelpers.ResolveTimeSpec("not-a-time", now)); } // ---- Query string construction ---------------------------------------- [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"]); } [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"]); } [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")); } [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")); } [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"]); } [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"]); } [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"]); } [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; public List RequestUris { get; } = new(); 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"), }); } } [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); } [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); } [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); } [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); } 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\"}"), }); } // ---- CLI parsing ------------------------------------------------------- [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); } [Fact] public void Query_FormatTable_IsAccepted() { var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "query", "--format", "table" }); Assert.Empty(parse.Errors); } [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); } [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) ---------------------------------- [Fact] public void Query_ChannelWithRealEnumName_IsAccepted() { var root = AuditCommandTestHarness.BuildRoot(); var parse = root.Parse(new[] { "audit", "query", "--channel", "ApiOutbound" }); Assert.Empty(parse.Errors); } [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); } [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); } [Fact] public void Query_MultiValueChannel_WithOneInvalidName_FailsFast() { // AcceptOnlyFromAmong validates EACH value of the multi-value option. var root = AuditCommandTestHarness.BuildRoot(); var (exit, _, err) = AuditCommandTestHarness.Invoke( root, "audit", "query", "--channel", "ApiOutbound", "OutboundApi"); Assert.NotEqual(0, exit); Assert.NotEqual("", err); } [Fact] public void Query_ChannelWithInvalidName_FailsFast_NonZeroExit() { // "OutboundApi" is the old (non-existent) name; the real enum is "ApiOutbound". var root = AuditCommandTestHarness.BuildRoot(); var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--channel", "OutboundApi"); Assert.NotEqual(0, exit); Assert.NotEqual("", err); Assert.Contains("OutboundApi", err); } [Fact] public void Query_KindWithInvalidName_FailsFast_NonZeroExit() { var root = AuditCommandTestHarness.BuildRoot(); var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--kind", "CachedCall"); Assert.NotEqual(0, exit); Assert.NotEqual("", err); } [Fact] public void Query_StatusWithInvalidName_FailsFast_NonZeroExit() { var root = AuditCommandTestHarness.BuildRoot(); var (exit, _, err) = AuditCommandTestHarness.Invoke(root, "audit", "query", "--status", "Bogus"); Assert.NotEqual(0, exit); Assert.NotEqual("", err); } }