From f64a7aed029a6aef918a0fe29fd8b2d4cbc26419 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 05:37:06 -0400 Subject: [PATCH] refactor(audit): consolidate query-param parsers; widen CLI export to multi-value --- src/ScadaLink.CLI/Commands/AuditCommands.cs | 42 +++++++-- .../Commands/AuditExportHelpers.cs | 34 +++++-- .../Audit/AuditExportEndpoints.cs | 61 +++---------- .../Pages/Audit/AuditLogPage.razor.cs | 84 +++++++----------- .../Types/Audit/AuditQueryParamParsers.cs | 79 +++++++++++++++++ .../AuditEndpoints.cs | 62 +++---------- .../Commands/AuditExportCommandTests.cs | 88 ++++++++++++++++++- .../Commands/AuditQueryCommandTests.cs | 2 +- .../Types/AuditQueryParamParsersTests.cs | 75 ++++++++++++++++ 9 files changed, 350 insertions(+), 177 deletions(-) create mode 100644 src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs create mode 100644 tests/ScadaLink.Commons.Tests/Types/AuditQueryParamParsersTests.cs diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs index 57cd91d..7120ffc 100644 --- a/src/ScadaLink.CLI/Commands/AuditCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -128,10 +128,36 @@ public static class AuditCommands var formatExportOption = new Option("--format") { Description = "Export format", Required = true }; formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet"); var outputOption = new Option("--output") { Description = "Destination file path", Required = true }; - var channelOption = new Option("--channel") { Description = "Filter by channel" }; - var kindOption = new Option("--kind") { Description = "Filter by event kind" }; - var statusOption = new Option("--status") { Description = "Filter by status" }; - var siteOption = new Option("--site") { Description = "Filter by source site ID" }; + // --channel/--kind/--status/--site are multi-valued — same shape as the + // `query` subcommand: repeated tokens (--channel A --channel B) and, with + // AllowMultipleArgumentsPerToken, a single token carrying several values + // (--channel A B). AcceptOnlyFromAmong validates EACH supplied value. + var channelOption = new Option("--channel") + { + Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable", + AllowMultipleArgumentsPerToken = true, + }; + channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound"); + var kindOption = new Option("--kind") + { + Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable", + AllowMultipleArgumentsPerToken = true, + }; + kindOption.AcceptOnlyFromAmong( + "ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend", + "NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve"); + var statusOption = new Option("--status") + { + Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable", + AllowMultipleArgumentsPerToken = true, + }; + statusOption.AcceptOnlyFromAmong( + "Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped"); + var siteOption = new Option("--site") + { + Description = "Filter by source site ID; repeatable", + AllowMultipleArgumentsPerToken = true, + }; var targetOption = new Option("--target") { Description = "Filter by target" }; var actorOption = new Option("--actor") { Description = "Filter by actor" }; @@ -162,10 +188,10 @@ public static class AuditCommands Until = result.GetValue(untilOption)!, Format = result.GetValue(formatExportOption)!, Output = result.GetValue(outputOption)!, - Channel = result.GetValue(channelOption), - Kind = result.GetValue(kindOption), - Status = result.GetValue(statusOption), - Site = result.GetValue(siteOption), + Channel = result.GetValue(channelOption) ?? Array.Empty(), + Kind = result.GetValue(kindOption) ?? Array.Empty(), + Status = result.GetValue(statusOption) ?? Array.Empty(), + Site = result.GetValue(siteOption) ?? Array.Empty(), Target = result.GetValue(targetOption), Actor = result.GetValue(actorOption), }; diff --git a/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs index 01d702b..4a36fc5 100644 --- a/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs +++ b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs @@ -6,6 +6,10 @@ namespace ScadaLink.CLI.Commands; /// /// Filter + destination arguments for an audit export invocation. Mirrors the /// Bundle B GET /api/audit/export parameters. +/// /// +/// are multi-valued — each supplied value becomes a repeated query-string param so +/// the server's multi-value IN (…) filter sees the full set, exactly like +/// the audit query subcommand. /// public sealed class AuditExportArgs { @@ -13,10 +17,10 @@ public sealed class AuditExportArgs public string Until { get; set; } = string.Empty; public string Format { get; set; } = string.Empty; public string Output { get; set; } = string.Empty; - public string? Channel { get; set; } - public string? Kind { get; set; } - public string? Status { get; set; } - public string? Site { get; set; } + public string[] Channel { get; set; } = Array.Empty(); + public string[] Kind { get; set; } = Array.Empty(); + public string[] Status { get; set; } = Array.Empty(); + public string[] Site { get; set; } = Array.Empty(); public string? Target { get; set; } public string? Actor { get; set; } } @@ -31,7 +35,11 @@ public static class AuditExportHelpers /// /// Builds the ?... query string for GET /api/audit/export: the required /// time window + format, plus optional filters. Time-specs are resolved via - /// . + /// . The multi-valued + /// --channel/--kind/--status/--site filters each emit ONE + /// repeated query-string key per value (e.g. channel=A&channel=B) so the + /// server's multi-value IN (…) filter receives the full set — mirroring + /// . /// public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now) { @@ -43,13 +51,21 @@ public static class AuditExportHelpers parts.Add($"{key}={Uri.EscapeDataString(value)}"); } + void AddEach(string key, IReadOnlyList values) + { + foreach (var value in values) + { + Add(key, value); + } + } + Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture)); Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture)); Add("format", args.Format); - Add("channel", args.Channel); - Add("kind", args.Kind); - Add("status", args.Status); - Add("sourceSiteId", args.Site); + AddEach("channel", args.Channel); + AddEach("kind", args.Kind); + AddEach("status", args.Status); + AddEach("sourceSiteId", args.Site); Add("target", args.Target); Add("actor", args.Actor); diff --git a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs index 64b02aa..c369195 100644 --- a/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs +++ b/src/ScadaLink.CentralUI/Audit/AuditExportEndpoints.cs @@ -82,12 +82,18 @@ public static class AuditExportEndpoints /// AuditLogPage.ApplyQueryStringFilters) — an unparseable value within /// a repeated set is dropped, not the whole set. /// + /// + /// This endpoint reads the source-site filter from the site query key, + /// whereas the ManagementService export endpoint reads it as + /// sourceSiteId. The divergence is deliberate — each endpoint matches + /// its own CLI / UI URL builder — so do NOT "fix" the two to one key name. + /// internal static AuditLogQueryFilter ParseFilter(IQueryCollection query) { - var channels = ParseEnumList(query, "channel"); - var kinds = ParseEnumList(query, "kind"); - var statuses = ParseEnumList(query, "status"); - var sites = ParseStringList(query, "site"); + var channels = AuditQueryParamParsers.ParseEnumList(query["channel"]); + var kinds = AuditQueryParamParsers.ParseEnumList(query["kind"]); + var statuses = AuditQueryParamParsers.ParseEnumList(query["status"]); + var sites = AuditQueryParamParsers.ParseStringList(query["site"]); string? target = TrimToNullable(query, "target"); string? actor = TrimToNullable(query, "actor"); @@ -114,53 +120,6 @@ public static class AuditExportEndpoints ToUtc: toUtc); } - /// - /// Reads EVERY value of a (possibly repeated) query param and parses each as - /// , dropping unparseable values silently. Returns - /// null when the param is absent or no value parsed. - /// - private static IReadOnlyList? ParseEnumList(IQueryCollection query, string key) - where TEnum : struct, Enum - { - if (!query.TryGetValue(key, out var values)) - { - return null; - } - - var parsed = new List(); - foreach (var raw in values) - { - if (Enum.TryParse(raw, ignoreCase: true, out var value)) - { - parsed.Add(value); - } - } - return parsed.Count > 0 ? parsed : null; - } - - /// - /// Reads EVERY value of a (possibly repeated) query param, trims each, and - /// drops blank entries. Returns null when the param is absent or every - /// value was blank. - /// - private static IReadOnlyList? ParseStringList(IQueryCollection query, string key) - { - if (!query.TryGetValue(key, out var values)) - { - return null; - } - - var parsed = new List(); - foreach (var raw in values) - { - if (!string.IsNullOrWhiteSpace(raw)) - { - parsed.Add(raw.Trim()); - } - } - return parsed.Count > 0 ? parsed : null; - } - /// /// Optional maxRows= query-string override. Falls back to /// on a missing / non-positive / unparseable diff --git a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs index 66092e2..bd2796e 100644 --- a/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs +++ b/src/ScadaLink.CentralUI/Components/Pages/Audit/AuditLogPage.razor.cs @@ -19,7 +19,7 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit; /// /// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can /// deep-link to a pre-filtered Audit Log: ?correlationId=, ?target=, -/// ?actor=, ?site=, ?channel=, and the UI-only +/// ?actor=, ?site=, ?channel=, ?kind=, and the UI-only /// ?instance= are read on initialization. Bundle E (M7-T13) extends /// this with ?status= so the Health-dashboard Audit error-rate tile can /// drill in to ?status=Failed. When any param is present we allocate a @@ -80,18 +80,27 @@ public partial class AuditLogPage } } - // site/channel/status accept repeated params for symmetry with the - // multi-value export URL — a single ?site=/?channel=/?status= drill-in - // still works (one-element list). Unknown enum names are silently dropped. - IReadOnlyList? sites = ParseStringList(query, "site"); + // site/channel/kind/status accept repeated params for symmetry with the + // multi-value export URL — a single ?site=/?channel=/?kind=/?status= + // drill-in still works (one-element list). Unknown enum names are silently + // dropped. The lax-parse contract is shared with the two export endpoints + // via AuditQueryParamParsers so all three surfaces stay in lockstep. + IReadOnlyList? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site")); - IReadOnlyList? channels = ParseEnumList(query, "channel"); + IReadOnlyList? channels = + AuditQueryParamParsers.ParseEnumList(Raw(query, "channel")); + + // ?kind= is honored for symmetry with BuildExportUrl, which emits a kind= + // param — a kind drill-in deep link must round-trip back into the filter. + IReadOnlyList? kinds = + AuditQueryParamParsers.ParseEnumList(Raw(query, "kind")); // Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in // with ?status=Failed (and operators may craft URLs with Parked/Discarded). // Unknown values are silently dropped — the page still renders without // the constraint. - IReadOnlyList? statuses = ParseEnumList(query, "status"); + IReadOnlyList? statuses = + AuditQueryParamParsers.ParseEnumList(Raw(query, "status")); // Instance is UI-only — the filter contract has no matching column, so we // pass it as a separate seam to the filter bar. @@ -108,13 +117,15 @@ public partial class AuditLogPage // auto-loads. Pure ?instance= deep links (UI-only) do not trigger auto-load // because the filter contract has no instance column — the user still needs // to refine + Apply for those. - if (correlationId is null && target is null && actor is null && sites is null && channels is null && statuses is null) + if (correlationId is null && target is null && actor is null + && sites is null && channels is null && kinds is null && statuses is null) { return; } _currentFilter = new AuditLogQueryFilter( Channels: channels, + Kinds: kinds, Statuses: statuses, SourceSiteIds: sites, Target: target, @@ -123,52 +134,15 @@ public partial class AuditLogPage } /// - /// Reads EVERY value of a (possibly repeated) query param and parses each as - /// , dropping unparseable values silently. Returns - /// null when the param is absent or no value parsed. + /// Extracts the raw repeated values for one query-string key, returning + /// null when the key is absent so the shared + /// sees the same absent-vs-present + /// distinction the ASP.NET IQueryCollection callers do. + /// StringValues is itself an IEnumerable<string?>. /// - private static IReadOnlyList? ParseEnumList( - Dictionary query, string key) - where TEnum : struct, Enum - { - if (!query.TryGetValue(key, out var values)) - { - return null; - } - - var parsed = new List(); - foreach (var raw in values) - { - if (Enum.TryParse(raw, ignoreCase: true, out var value)) - { - parsed.Add(value); - } - } - return parsed.Count > 0 ? parsed : null; - } - - /// - /// Reads EVERY value of a (possibly repeated) query param, trims each, and - /// drops blank entries. Returns null when absent or all-blank. - /// - private static IReadOnlyList? ParseStringList( - Dictionary query, string key) - { - if (!query.TryGetValue(key, out var values)) - { - return null; - } - - var parsed = new List(); - foreach (var raw in values) - { - if (!string.IsNullOrWhiteSpace(raw)) - { - parsed.Add(raw.Trim()); - } - } - return parsed.Count > 0 ? parsed : null; - } + private static IEnumerable? Raw( + Dictionary query, string key) => + query.TryGetValue(key, out var values) ? (IEnumerable)values : null; private void HandleFilterChanged(AuditLogQueryFilter filter) { @@ -213,7 +187,9 @@ public partial class AuditLogPage return basePath; } - var parts = new List>(9); + // No capacity hint: the dimensions are multi-value, so the part count is + // unbounded by the number of filter fields. + var parts = new List>(); // Task 9: the filter dimensions are multi-value end-to-end. Emit ONE // repeated query-string key per selected value (channel=A&channel=B); the // export endpoint's ParseFilter reads the full repeated set. diff --git a/src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs b/src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs new file mode 100644 index 0000000..adf1c1a --- /dev/null +++ b/src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs @@ -0,0 +1,79 @@ +namespace ScadaLink.Commons.Types.Audit; + +/// +/// Shared lax parsers for the multi-value Audit Log query parameters +/// (channel/kind/status/site). The Audit Log filter +/// wire-contract is consumed by three surfaces that MUST stay in lockstep: +/// +/// the ManagementService /api/audit/query + /api/audit/export +/// endpoints, +/// the CentralUI /api/centralui/audit/export endpoint, and +/// the CentralUI AuditLogPage query-string drill-in parser. +/// +/// +/// +/// Each caller extracts the raw repeated values for a single parameter from its +/// own request type (ASP.NET IQueryCollection, a +/// Dictionary<string, StringValues> from QueryHelpers.ParseQuery, +/// etc.) and passes them here as a plain of strings — +/// so this helper carries NO ASP.NET / Microsoft.Extensions.Primitives +/// dependency and can live in ScadaLink.Commons. +/// +/// +/// +/// Lax-parse contract. Every value of a repeated parameter is parsed +/// independently; an unparseable or blank element is silently dropped (NO 400) +/// rather than failing the whole set. An empty result collapses to null so +/// the corresponding filter dimension stays unconstrained. +/// +/// +public static class AuditQueryParamParsers +{ + /// + /// Parses each raw value as (case-insensitive), + /// dropping unparseable values silently. Returns null when + /// is null, empty, or yields no parseable + /// value — so the filter dimension stays unconstrained. + /// + public static IReadOnlyList? ParseEnumList(IEnumerable? rawValues) + where TEnum : struct, Enum + { + if (rawValues is null) + { + return null; + } + + var parsed = new List(); + foreach (var raw in rawValues) + { + if (Enum.TryParse(raw, ignoreCase: true, out var value)) + { + parsed.Add(value); + } + } + return parsed.Count > 0 ? parsed : null; + } + + /// + /// Trims each raw value and drops blank entries. Returns null when + /// is null, empty, or every value was + /// blank. + /// + public static IReadOnlyList? ParseStringList(IEnumerable? rawValues) + { + if (rawValues is null) + { + return null; + } + + var parsed = new List(); + foreach (var raw in rawValues) + { + if (!string.IsNullOrWhiteSpace(raw)) + { + parsed.Add(raw.Trim()); + } + } + return parsed.Count > 0 ? parsed : null; + } +} diff --git a/src/ScadaLink.ManagementService/AuditEndpoints.cs b/src/ScadaLink.ManagementService/AuditEndpoints.cs index ed83f1c..49b50f3 100644 --- a/src/ScadaLink.ManagementService/AuditEndpoints.cs +++ b/src/ScadaLink.ManagementService/AuditEndpoints.cs @@ -375,12 +375,18 @@ public static class AuditEndpoints /// (no 400) — the same lax contract the CentralUI export endpoint uses; an /// unparseable value within a repeated set is dropped, not the whole set. /// + /// + /// This endpoint reads the source-site filter from the sourceSiteId + /// query key, whereas the CentralUI export endpoint reads it as site. + /// The divergence is deliberate — each endpoint matches its own CLI / UI URL + /// builder — so do NOT "fix" the two to a single key name. + /// public static AuditLogQueryFilter ParseFilter(IQueryCollection query) { - var channels = ParseEnumList(query, "channel"); - var kinds = ParseEnumList(query, "kind"); - var statuses = ParseEnumList(query, "status"); - var sourceSiteIds = ParseStringList(query, "sourceSiteId"); + var channels = AuditQueryParamParsers.ParseEnumList(query["channel"]); + var kinds = AuditQueryParamParsers.ParseEnumList(query["kind"]); + var statuses = AuditQueryParamParsers.ParseEnumList(query["status"]); + var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]); Guid? correlationId = null; if (query.TryGetValue("correlationId", out var corrValues) @@ -401,54 +407,6 @@ public static class AuditEndpoints ToUtc: ParseUtcDate(query, "toUtc")); } - /// - /// Reads EVERY value of a (possibly repeated) query param and parses each as - /// , dropping unparseable values silently. Returns - /// null when the param is absent or no value parsed — so the filter - /// dimension stays unconstrained. - /// - private static IReadOnlyList? ParseEnumList(IQueryCollection query, string key) - where TEnum : struct, Enum - { - if (!query.TryGetValue(key, out var values)) - { - return null; - } - - var parsed = new List(); - foreach (var raw in values) - { - if (Enum.TryParse(raw, ignoreCase: true, out var value)) - { - parsed.Add(value); - } - } - return parsed.Count > 0 ? parsed : null; - } - - /// - /// Reads EVERY value of a (possibly repeated) query param, trims each, and - /// drops blank entries. Returns null when the param is absent or every - /// value was blank. - /// - private static IReadOnlyList? ParseStringList(IQueryCollection query, string key) - { - if (!query.TryGetValue(key, out var values)) - { - return null; - } - - var parsed = new List(); - foreach (var raw in values) - { - if (!string.IsNullOrWhiteSpace(raw)) - { - parsed.Add(raw.Trim()); - } - } - return parsed.Count > 0 ? parsed : null; - } - /// /// Parses the keyset-paging query parameters into an /// . pageSize is clamped to diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs index cd3349a..04fc622 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditExportCommandTests.cs @@ -63,8 +63,8 @@ public class AuditExportCommandTests Until = "2026-05-20T12:00:00Z", Format = "jsonl", Output = "/tmp/x", - Channel = "Notification", - Site = "site-9", + Channel = new[] { "Notification" }, + Site = new[] { "site-9" }, }; var qs = AuditExportHelpers.BuildQueryString(args, now); var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); @@ -76,6 +76,90 @@ public class AuditExportCommandTests Assert.Equal("2026-05-20T12:00:00.0000000+00:00", parsed["toUtc"]); } + [Fact] + public void BuildQueryString_MultiValueFilters_EmitOneKeyPerValue() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var args = new AuditExportArgs + { + Since = "1h", + Until = "2026-05-20T12:00:00Z", + Format = "csv", + Output = "/tmp/x", + Channel = new[] { "ApiOutbound", "DbOutbound" }, + Kind = new[] { "ApiCall", "DbWrite" }, + Status = new[] { "Failed", "Parked" }, + Site = new[] { "site-1", "site-2" }, + }; + var qs = AuditExportHelpers.BuildQueryString(args, now); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + + Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, parsed.GetValues("channel")); + Assert.Equal(new[] { "ApiCall", "DbWrite" }, parsed.GetValues("kind")); + Assert.Equal(new[] { "Failed", "Parked" }, parsed.GetValues("status")); + Assert.Equal(new[] { "site-1", "site-2" }, parsed.GetValues("sourceSiteId")); + } + + [Fact] + public void BuildQueryString_OmitsUnsetMultiValueFilters() + { + var now = DateTimeOffset.Parse("2026-05-20T12:00:00Z"); + var args = new AuditExportArgs + { + Since = "1h", + Until = "0h", + Format = "csv", + Output = "/tmp/x", + }; + var qs = AuditExportHelpers.BuildQueryString(args, now); + var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?')); + + Assert.Null(parsed["channel"]); + Assert.Null(parsed["kind"]); + Assert.Null(parsed["status"]); + Assert.Null(parsed["sourceSiteId"]); + } + + [Fact] + public void Export_MultipleChannelValues_SingleToken_AreAccepted() + { + // AllowMultipleArgumentsPerToken: --channel A B parses as two values. + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] + { + "audit", "export", "--since", "1h", "--until", "0h", + "--format", "csv", "--output", "/tmp/out.csv", + "--channel", "ApiOutbound", "DbOutbound", + }); + Assert.Empty(parse.Errors); + } + + [Fact] + public void Export_MultipleChannelValues_RepeatedFlag_AreAccepted() + { + var root = AuditCommandTestHarness.BuildRoot(); + var parse = root.Parse(new[] + { + "audit", "export", "--since", "1h", "--until", "0h", + "--format", "csv", "--output", "/tmp/out.csv", + "--channel", "ApiOutbound", "--channel", "Notification", + }); + Assert.Empty(parse.Errors); + } + + [Fact] + public void Export_MultiValueChannel_WithOneInvalidName_FailsFast() + { + // AcceptOnlyFromAmong validates EACH value of the multi-value option. + var root = AuditCommandTestHarness.BuildRoot(); + var (exit, _, err) = AuditCommandTestHarness.Invoke( + root, "audit", "export", "--since", "1h", "--until", "0h", + "--format", "csv", "--output", "/tmp/out.csv", + "--channel", "ApiOutbound", "OutboundApi"); + Assert.NotEqual(0, exit); + Assert.NotEqual("", err); + } + // ---- Streaming export to file ----------------------------------------- private sealed class BodyHandler : HttpMessageHandler diff --git a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs index 80f0ecd..3df692b 100644 --- a/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs +++ b/tests/ScadaLink.CLI.Tests/Commands/AuditQueryCommandTests.cs @@ -76,7 +76,7 @@ public class AuditQueryCommandTests Assert.Equal("ApiCallCached", parsed["kind"]); Assert.Equal("Delivered", parsed["status"]); Assert.Equal("site-1", parsed["sourceSiteId"]); - // --instance was dropped: AuditLogQueryFilter has no instance column. + // 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"]); diff --git a/tests/ScadaLink.Commons.Tests/Types/AuditQueryParamParsersTests.cs b/tests/ScadaLink.Commons.Tests/Types/AuditQueryParamParsersTests.cs new file mode 100644 index 0000000..3f8ad0c --- /dev/null +++ b/tests/ScadaLink.Commons.Tests/Types/AuditQueryParamParsersTests.cs @@ -0,0 +1,75 @@ +using ScadaLink.Commons.Types.Audit; +using ScadaLink.Commons.Types.Enums; + +namespace ScadaLink.Commons.Tests.Types; + +/// +/// Audit Log #23 (M8): tests for the shared lax multi-value query-param parsers +/// used by the ManagementService + CentralUI audit endpoints and the +/// AuditLogPage drill-in parser. The contract under test: parse each +/// repeated value independently, silently drop unparseable/blank elements, and +/// collapse an empty result to null. +/// +public class AuditQueryParamParsersTests +{ + [Fact] + public void ParseEnumList_NullInput_ReturnsNull() + { + Assert.Null(AuditQueryParamParsers.ParseEnumList(null)); + } + + [Fact] + public void ParseEnumList_EmptyInput_ReturnsNull() + { + Assert.Null(AuditQueryParamParsers.ParseEnumList(Array.Empty())); + } + + [Fact] + public void ParseEnumList_AllValuesValid_ParsesEverything() + { + var result = AuditQueryParamParsers.ParseEnumList( + new[] { "ApiOutbound", "DbOutbound" }); + Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result); + } + + [Fact] + public void ParseEnumList_IsCaseInsensitive() + { + var result = AuditQueryParamParsers.ParseEnumList(new[] { "apioutbound" }); + Assert.Equal(new[] { AuditChannel.ApiOutbound }, result); + } + + [Fact] + public void ParseEnumList_DropsUnparseableElement_KeepsTheRest() + { + var result = AuditQueryParamParsers.ParseEnumList( + new[] { "ApiOutbound", "NotAChannel", "Notification" }); + Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, result); + } + + [Fact] + public void ParseEnumList_AllValuesUnparseable_ReturnsNull() + { + Assert.Null(AuditQueryParamParsers.ParseEnumList(new[] { "Bogus", "" })); + } + + [Fact] + public void ParseStringList_NullInput_ReturnsNull() + { + Assert.Null(AuditQueryParamParsers.ParseStringList(null)); + } + + [Fact] + public void ParseStringList_TrimsValuesAndDropsBlanks() + { + var result = AuditQueryParamParsers.ParseStringList( + new[] { " site-1 ", "", " ", "site-2", null }); + Assert.Equal(new[] { "site-1", "site-2" }, result); + } + + [Fact] + public void ParseStringList_AllBlank_ReturnsNull() + { + Assert.Null(AuditQueryParamParsers.ParseStringList(new[] { "", " ", null })); + } +}