feat(cli): scadalink audit query subcommand (#23 M8)
This commit is contained in:
245
tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs
Normal file
245
tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the <c>scadalink audit query</c> subcommand (Audit Log #23 M8-T2):
|
||||
/// time-spec resolution, query-string construction, formatter selection, error
|
||||
/// handling, and keyset-cursor paging via <c>--all</c>.
|
||||
/// </summary>
|
||||
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<FormatException>(() => 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<string> _bodies;
|
||||
public List<string> RequestUris { get; } = new();
|
||||
|
||||
public RecordingHandler(params string[] bodies)
|
||||
{
|
||||
_bodies = new Queue<string>(bodies);
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> 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<HttpResponseMessage> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user