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 = "OutboundApi", Kind = "CachedCall", Status = "Delivered", Site = "site-1", Instance = "pump-7", Target = "weather-api", Actor = "multi-role", CorrelationId = "abc-123", ErrorsOnly = false, PageSize = 250, }; var qs = AuditQueryHelpers.BuildQueryString(args, now, afterOccurredAtUtc: null, afterEventId: null); var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); Assert.Equal("OutboundApi", parsed["channel"]); Assert.Equal("CachedCall", parsed["kind"]); Assert.Equal("Delivered", parsed["status"]); Assert.Equal("site-1", parsed["sourceSiteId"]); Assert.Equal("pump-7", parsed["instance"]); Assert.Equal("weather-api", parsed["target"]); Assert.Equal("multi-role", parsed["actor"]); Assert.Equal("abc-123", parsed["correlationId"]); 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_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.Equal("100", parsed["pageSize"]); } // ---- 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); } }