408 lines
16 KiB
C#
408 lines
16 KiB
C#
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>
|
|
[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<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 = 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<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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|