refactor(audit): consolidate query-param parsers; widen CLI export to multi-value
This commit is contained in:
@@ -128,10 +128,36 @@ public static class AuditCommands
|
|||||||
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
|
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
|
||||||
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
|
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
|
||||||
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
|
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
|
||||||
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel" };
|
// --channel/--kind/--status/--site are multi-valued — same shape as the
|
||||||
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind" };
|
// `query` subcommand: repeated tokens (--channel A --channel B) and, with
|
||||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
|
// AllowMultipleArgumentsPerToken, a single token carrying several values
|
||||||
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
|
// (--channel A B). AcceptOnlyFromAmong validates EACH supplied value.
|
||||||
|
var channelOption = new Option<string[]>("--channel")
|
||||||
|
{
|
||||||
|
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
|
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
||||||
|
var kindOption = new Option<string[]>("--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<string[]>("--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<string[]>("--site")
|
||||||
|
{
|
||||||
|
Description = "Filter by source site ID; repeatable",
|
||||||
|
AllowMultipleArgumentsPerToken = true,
|
||||||
|
};
|
||||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
|
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
|
||||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||||
|
|
||||||
@@ -162,10 +188,10 @@ public static class AuditCommands
|
|||||||
Until = result.GetValue(untilOption)!,
|
Until = result.GetValue(untilOption)!,
|
||||||
Format = result.GetValue(formatExportOption)!,
|
Format = result.GetValue(formatExportOption)!,
|
||||||
Output = result.GetValue(outputOption)!,
|
Output = result.GetValue(outputOption)!,
|
||||||
Channel = result.GetValue(channelOption),
|
Channel = result.GetValue(channelOption) ?? Array.Empty<string>(),
|
||||||
Kind = result.GetValue(kindOption),
|
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
|
||||||
Status = result.GetValue(statusOption),
|
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
|
||||||
Site = result.GetValue(siteOption),
|
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
|
||||||
Target = result.GetValue(targetOption),
|
Target = result.GetValue(targetOption),
|
||||||
Actor = result.GetValue(actorOption),
|
Actor = result.GetValue(actorOption),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ namespace ScadaLink.CLI.Commands;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
|
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
|
||||||
/// Bundle B <c>GET /api/audit/export</c> parameters.
|
/// Bundle B <c>GET /api/audit/export</c> parameters.
|
||||||
|
/// <see cref="Channel"/>/<see cref="Kind"/>/<see cref="Status"/>/<see cref="Site"/>
|
||||||
|
/// are multi-valued — each supplied value becomes a repeated query-string param so
|
||||||
|
/// the server's multi-value <c>IN (…)</c> filter sees the full set, exactly like
|
||||||
|
/// the <c>audit query</c> subcommand.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AuditExportArgs
|
public sealed class AuditExportArgs
|
||||||
{
|
{
|
||||||
@@ -13,10 +17,10 @@ public sealed class AuditExportArgs
|
|||||||
public string Until { get; set; } = string.Empty;
|
public string Until { get; set; } = string.Empty;
|
||||||
public string Format { get; set; } = string.Empty;
|
public string Format { get; set; } = string.Empty;
|
||||||
public string Output { get; set; } = string.Empty;
|
public string Output { get; set; } = string.Empty;
|
||||||
public string? Channel { get; set; }
|
public string[] Channel { get; set; } = Array.Empty<string>();
|
||||||
public string? Kind { get; set; }
|
public string[] Kind { get; set; } = Array.Empty<string>();
|
||||||
public string? Status { get; set; }
|
public string[] Status { get; set; } = Array.Empty<string>();
|
||||||
public string? Site { get; set; }
|
public string[] Site { get; set; } = Array.Empty<string>();
|
||||||
public string? Target { get; set; }
|
public string? Target { get; set; }
|
||||||
public string? Actor { get; set; }
|
public string? Actor { get; set; }
|
||||||
}
|
}
|
||||||
@@ -31,7 +35,11 @@ public static class AuditExportHelpers
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
|
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
|
||||||
/// time window + format, plus optional filters. Time-specs are resolved via
|
/// time window + format, plus optional filters. Time-specs are resolved via
|
||||||
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>.
|
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>. The multi-valued
|
||||||
|
/// <c>--channel</c>/<c>--kind</c>/<c>--status</c>/<c>--site</c> filters each emit ONE
|
||||||
|
/// repeated query-string key per value (e.g. <c>channel=A&channel=B</c>) so the
|
||||||
|
/// server's multi-value <c>IN (…)</c> filter receives the full set — mirroring
|
||||||
|
/// <see cref="AuditQueryHelpers.BuildQueryString"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
|
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
@@ -43,13 +51,21 @@ public static class AuditExportHelpers
|
|||||||
parts.Add($"{key}={Uri.EscapeDataString(value)}");
|
parts.Add($"{key}={Uri.EscapeDataString(value)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AddEach(string key, IReadOnlyList<string> values)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
Add(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
|
Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture));
|
||||||
Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
|
Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture));
|
||||||
Add("format", args.Format);
|
Add("format", args.Format);
|
||||||
Add("channel", args.Channel);
|
AddEach("channel", args.Channel);
|
||||||
Add("kind", args.Kind);
|
AddEach("kind", args.Kind);
|
||||||
Add("status", args.Status);
|
AddEach("status", args.Status);
|
||||||
Add("sourceSiteId", args.Site);
|
AddEach("sourceSiteId", args.Site);
|
||||||
Add("target", args.Target);
|
Add("target", args.Target);
|
||||||
Add("actor", args.Actor);
|
Add("actor", args.Actor);
|
||||||
|
|
||||||
|
|||||||
@@ -82,12 +82,18 @@ public static class AuditExportEndpoints
|
|||||||
/// <c>AuditLogPage.ApplyQueryStringFilters</c>) — an unparseable value within
|
/// <c>AuditLogPage.ApplyQueryStringFilters</c>) — an unparseable value within
|
||||||
/// a repeated set is dropped, not the whole set.
|
/// a repeated set is dropped, not the whole set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This endpoint reads the source-site filter from the <c>site</c> query key,
|
||||||
|
/// whereas the ManagementService export endpoint reads it as
|
||||||
|
/// <c>sourceSiteId</c>. The divergence is deliberate — each endpoint matches
|
||||||
|
/// its own CLI / UI URL builder — so do NOT "fix" the two to one key name.
|
||||||
|
/// </remarks>
|
||||||
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
internal static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
||||||
{
|
{
|
||||||
var channels = ParseEnumList<AuditChannel>(query, "channel");
|
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
|
||||||
var kinds = ParseEnumList<AuditKind>(query, "kind");
|
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
|
||||||
var statuses = ParseEnumList<AuditStatus>(query, "status");
|
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
|
||||||
var sites = ParseStringList(query, "site");
|
var sites = AuditQueryParamParsers.ParseStringList(query["site"]);
|
||||||
|
|
||||||
string? target = TrimToNullable(query, "target");
|
string? target = TrimToNullable(query, "target");
|
||||||
string? actor = TrimToNullable(query, "actor");
|
string? actor = TrimToNullable(query, "actor");
|
||||||
@@ -114,53 +120,6 @@ public static class AuditExportEndpoints
|
|||||||
ToUtc: toUtc);
|
ToUtc: toUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads EVERY value of a (possibly repeated) query param and parses each as
|
|
||||||
/// <typeparamref name="TEnum"/>, dropping unparseable values silently. Returns
|
|
||||||
/// <c>null</c> when the param is absent or no value parsed.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IQueryCollection query, string key)
|
|
||||||
where TEnum : struct, Enum
|
|
||||||
{
|
|
||||||
if (!query.TryGetValue(key, out var values))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed = new List<TEnum>();
|
|
||||||
foreach (var raw in values)
|
|
||||||
{
|
|
||||||
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
|
||||||
{
|
|
||||||
parsed.Add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed.Count > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads EVERY value of a (possibly repeated) query param, trims each, and
|
|
||||||
/// drops blank entries. Returns <c>null</c> when the param is absent or every
|
|
||||||
/// value was blank.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<string>? ParseStringList(IQueryCollection query, string key)
|
|
||||||
{
|
|
||||||
if (!query.TryGetValue(key, out var values))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed = new List<string>();
|
|
||||||
foreach (var raw in values)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(raw))
|
|
||||||
{
|
|
||||||
parsed.Add(raw.Trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed.Count > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional <c>maxRows=</c> query-string override. Falls back to
|
/// Optional <c>maxRows=</c> query-string override. Falls back to
|
||||||
/// <see cref="DefaultMaxRows"/> on a missing / non-positive / unparseable
|
/// <see cref="DefaultMaxRows"/> on a missing / non-positive / unparseable
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ namespace ScadaLink.CentralUI.Components.Pages.Audit;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
|
/// Bundle D (M7-T10..T12) adds query-string drill-in parsing so other pages can
|
||||||
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
|
/// deep-link to a pre-filtered Audit Log: <c>?correlationId=</c>, <c>?target=</c>,
|
||||||
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, and the UI-only
|
/// <c>?actor=</c>, <c>?site=</c>, <c>?channel=</c>, <c>?kind=</c>, and the UI-only
|
||||||
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
/// <c>?instance=</c> are read on initialization. Bundle E (M7-T13) extends
|
||||||
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
/// this with <c>?status=</c> so the Health-dashboard Audit error-rate tile can
|
||||||
/// drill in to <c>?status=Failed</c>. When any param is present we allocate a
|
/// drill in to <c>?status=Failed</c>. 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
|
// site/channel/kind/status accept repeated params for symmetry with the
|
||||||
// multi-value export URL — a single ?site=/?channel=/?status= drill-in
|
// multi-value export URL — a single ?site=/?channel=/?kind=/?status=
|
||||||
// still works (one-element list). Unknown enum names are silently dropped.
|
// drill-in still works (one-element list). Unknown enum names are silently
|
||||||
IReadOnlyList<string>? sites = ParseStringList(query, "site");
|
// dropped. The lax-parse contract is shared with the two export endpoints
|
||||||
|
// via AuditQueryParamParsers so all three surfaces stay in lockstep.
|
||||||
|
IReadOnlyList<string>? sites = AuditQueryParamParsers.ParseStringList(Raw(query, "site"));
|
||||||
|
|
||||||
IReadOnlyList<AuditChannel>? channels = ParseEnumList<AuditChannel>(query, "channel");
|
IReadOnlyList<AuditChannel>? channels =
|
||||||
|
AuditQueryParamParsers.ParseEnumList<AuditChannel>(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<AuditKind>? kinds =
|
||||||
|
AuditQueryParamParsers.ParseEnumList<AuditKind>(Raw(query, "kind"));
|
||||||
|
|
||||||
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in
|
// Bundle E (M7-T13): the Health-dashboard Audit error-rate tile drills in
|
||||||
// with ?status=Failed (and operators may craft URLs with Parked/Discarded).
|
// with ?status=Failed (and operators may craft URLs with Parked/Discarded).
|
||||||
// Unknown values are silently dropped — the page still renders without
|
// Unknown values are silently dropped — the page still renders without
|
||||||
// the constraint.
|
// the constraint.
|
||||||
IReadOnlyList<AuditStatus>? statuses = ParseEnumList<AuditStatus>(query, "status");
|
IReadOnlyList<AuditStatus>? statuses =
|
||||||
|
AuditQueryParamParsers.ParseEnumList<AuditStatus>(Raw(query, "status"));
|
||||||
|
|
||||||
// Instance is UI-only — the filter contract has no matching column, so we
|
// Instance is UI-only — the filter contract has no matching column, so we
|
||||||
// pass it as a separate seam to the filter bar.
|
// 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
|
// 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
|
// because the filter contract has no instance column — the user still needs
|
||||||
// to refine + Apply for those.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentFilter = new AuditLogQueryFilter(
|
_currentFilter = new AuditLogQueryFilter(
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
|
Kinds: kinds,
|
||||||
Statuses: statuses,
|
Statuses: statuses,
|
||||||
SourceSiteIds: sites,
|
SourceSiteIds: sites,
|
||||||
Target: target,
|
Target: target,
|
||||||
@@ -123,52 +134,15 @@ public partial class AuditLogPage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads EVERY value of a (possibly repeated) query param and parses each as
|
/// Extracts the raw repeated values for one query-string key, returning
|
||||||
/// <typeparamref name="TEnum"/>, dropping unparseable values silently. Returns
|
/// <c>null</c> when the key is absent so the shared
|
||||||
/// <c>null</c> when the param is absent or no value parsed.
|
/// <see cref="AuditQueryParamParsers"/> sees the same absent-vs-present
|
||||||
|
/// distinction the ASP.NET <c>IQueryCollection</c> callers do.
|
||||||
|
/// <c>StringValues</c> is itself an <c>IEnumerable<string?></c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(
|
private static IEnumerable<string?>? Raw(
|
||||||
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
|
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
|
||||||
where TEnum : struct, Enum
|
query.TryGetValue(key, out var values) ? (IEnumerable<string?>)values : null;
|
||||||
{
|
|
||||||
if (!query.TryGetValue(key, out var values))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed = new List<TEnum>();
|
|
||||||
foreach (var raw in values)
|
|
||||||
{
|
|
||||||
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
|
||||||
{
|
|
||||||
parsed.Add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed.Count > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads EVERY value of a (possibly repeated) query param, trims each, and
|
|
||||||
/// drops blank entries. Returns <c>null</c> when absent or all-blank.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<string>? ParseStringList(
|
|
||||||
Dictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
|
|
||||||
{
|
|
||||||
if (!query.TryGetValue(key, out var values))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed = new List<string>();
|
|
||||||
foreach (var raw in values)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(raw))
|
|
||||||
{
|
|
||||||
parsed.Add(raw.Trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed.Count > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void HandleFilterChanged(AuditLogQueryFilter filter)
|
private void HandleFilterChanged(AuditLogQueryFilter filter)
|
||||||
{
|
{
|
||||||
@@ -213,7 +187,9 @@ public partial class AuditLogPage
|
|||||||
return basePath;
|
return basePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
var parts = new List<KeyValuePair<string, string?>>(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<KeyValuePair<string, string?>>();
|
||||||
// Task 9: the filter dimensions are multi-value end-to-end. Emit ONE
|
// 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
|
// repeated query-string key per selected value (channel=A&channel=B); the
|
||||||
// export endpoint's ParseFilter reads the full repeated set.
|
// export endpoint's ParseFilter reads the full repeated set.
|
||||||
|
|||||||
79
src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs
Normal file
79
src/ScadaLink.Commons/Types/Audit/AuditQueryParamParsers.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared lax parsers for the multi-value Audit Log query parameters
|
||||||
|
/// (<c>channel</c>/<c>kind</c>/<c>status</c>/<c>site</c>). The Audit Log filter
|
||||||
|
/// wire-contract is consumed by three surfaces that MUST stay in lockstep:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>the ManagementService <c>/api/audit/query</c> + <c>/api/audit/export</c>
|
||||||
|
/// endpoints,</item>
|
||||||
|
/// <item>the CentralUI <c>/api/centralui/audit/export</c> endpoint, and</item>
|
||||||
|
/// <item>the CentralUI <c>AuditLogPage</c> query-string drill-in parser.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Each caller extracts the raw repeated values for a single parameter from its
|
||||||
|
/// own request type (ASP.NET <c>IQueryCollection</c>, a
|
||||||
|
/// <c>Dictionary<string, StringValues></c> from <c>QueryHelpers.ParseQuery</c>,
|
||||||
|
/// etc.) and passes them here as a plain <see cref="IEnumerable{T}"/> of strings —
|
||||||
|
/// so this helper carries NO ASP.NET / <c>Microsoft.Extensions.Primitives</c>
|
||||||
|
/// dependency and can live in <c>ScadaLink.Commons</c>.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Lax-parse contract.</b> 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 <c>null</c> so
|
||||||
|
/// the corresponding filter dimension stays unconstrained.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class AuditQueryParamParsers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parses each raw value as <typeparamref name="TEnum"/> (case-insensitive),
|
||||||
|
/// dropping unparseable values silently. Returns <c>null</c> when
|
||||||
|
/// <paramref name="rawValues"/> is <c>null</c>, empty, or yields no parseable
|
||||||
|
/// value — so the filter dimension stays unconstrained.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IEnumerable<string?>? rawValues)
|
||||||
|
where TEnum : struct, Enum
|
||||||
|
{
|
||||||
|
if (rawValues is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = new List<TEnum>();
|
||||||
|
foreach (var raw in rawValues)
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
||||||
|
{
|
||||||
|
parsed.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed.Count > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trims each raw value and drops blank entries. Returns <c>null</c> when
|
||||||
|
/// <paramref name="rawValues"/> is <c>null</c>, empty, or every value was
|
||||||
|
/// blank.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string>? ParseStringList(IEnumerable<string?>? rawValues)
|
||||||
|
{
|
||||||
|
if (rawValues is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = new List<string>();
|
||||||
|
foreach (var raw in rawValues)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
parsed.Add(raw.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed.Count > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -375,12 +375,18 @@ public static class AuditEndpoints
|
|||||||
/// (no 400) — the same lax contract the CentralUI export endpoint uses; an
|
/// (no 400) — the same lax contract the CentralUI export endpoint uses; an
|
||||||
/// unparseable value within a repeated set is dropped, not the whole set.
|
/// unparseable value within a repeated set is dropped, not the whole set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This endpoint reads the source-site filter from the <c>sourceSiteId</c>
|
||||||
|
/// query key, whereas the CentralUI export endpoint reads it as <c>site</c>.
|
||||||
|
/// The divergence is deliberate — each endpoint matches its own CLI / UI URL
|
||||||
|
/// builder — so do NOT "fix" the two to a single key name.
|
||||||
|
/// </remarks>
|
||||||
public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
|
||||||
{
|
{
|
||||||
var channels = ParseEnumList<AuditChannel>(query, "channel");
|
var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
|
||||||
var kinds = ParseEnumList<AuditKind>(query, "kind");
|
var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
|
||||||
var statuses = ParseEnumList<AuditStatus>(query, "status");
|
var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
|
||||||
var sourceSiteIds = ParseStringList(query, "sourceSiteId");
|
var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]);
|
||||||
|
|
||||||
Guid? correlationId = null;
|
Guid? correlationId = null;
|
||||||
if (query.TryGetValue("correlationId", out var corrValues)
|
if (query.TryGetValue("correlationId", out var corrValues)
|
||||||
@@ -401,54 +407,6 @@ public static class AuditEndpoints
|
|||||||
ToUtc: ParseUtcDate(query, "toUtc"));
|
ToUtc: ParseUtcDate(query, "toUtc"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads EVERY value of a (possibly repeated) query param and parses each as
|
|
||||||
/// <typeparamref name="TEnum"/>, dropping unparseable values silently. Returns
|
|
||||||
/// <c>null</c> when the param is absent or no value parsed — so the filter
|
|
||||||
/// dimension stays unconstrained.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<TEnum>? ParseEnumList<TEnum>(IQueryCollection query, string key)
|
|
||||||
where TEnum : struct, Enum
|
|
||||||
{
|
|
||||||
if (!query.TryGetValue(key, out var values))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed = new List<TEnum>();
|
|
||||||
foreach (var raw in values)
|
|
||||||
{
|
|
||||||
if (Enum.TryParse<TEnum>(raw, ignoreCase: true, out var value))
|
|
||||||
{
|
|
||||||
parsed.Add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed.Count > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reads EVERY value of a (possibly repeated) query param, trims each, and
|
|
||||||
/// drops blank entries. Returns <c>null</c> when the param is absent or every
|
|
||||||
/// value was blank.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<string>? ParseStringList(IQueryCollection query, string key)
|
|
||||||
{
|
|
||||||
if (!query.TryGetValue(key, out var values))
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsed = new List<string>();
|
|
||||||
foreach (var raw in values)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(raw))
|
|
||||||
{
|
|
||||||
parsed.Add(raw.Trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed.Count > 0 ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the keyset-paging query parameters into an
|
/// Parses the keyset-paging query parameters into an
|
||||||
/// <see cref="AuditLogPaging"/>. <c>pageSize</c> is clamped to
|
/// <see cref="AuditLogPaging"/>. <c>pageSize</c> is clamped to
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ public class AuditExportCommandTests
|
|||||||
Until = "2026-05-20T12:00:00Z",
|
Until = "2026-05-20T12:00:00Z",
|
||||||
Format = "jsonl",
|
Format = "jsonl",
|
||||||
Output = "/tmp/x",
|
Output = "/tmp/x",
|
||||||
Channel = "Notification",
|
Channel = new[] { "Notification" },
|
||||||
Site = "site-9",
|
Site = new[] { "site-9" },
|
||||||
};
|
};
|
||||||
var qs = AuditExportHelpers.BuildQueryString(args, now);
|
var qs = AuditExportHelpers.BuildQueryString(args, now);
|
||||||
var parsed = HttpUtility.ParseQueryString(qs.TrimStart('?'));
|
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"]);
|
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 -----------------------------------------
|
// ---- Streaming export to file -----------------------------------------
|
||||||
|
|
||||||
private sealed class BodyHandler : HttpMessageHandler
|
private sealed class BodyHandler : HttpMessageHandler
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ public class AuditQueryCommandTests
|
|||||||
Assert.Equal("ApiCallCached", parsed["kind"]);
|
Assert.Equal("ApiCallCached", parsed["kind"]);
|
||||||
Assert.Equal("Delivered", parsed["status"]);
|
Assert.Equal("Delivered", parsed["status"]);
|
||||||
Assert.Equal("site-1", parsed["sourceSiteId"]);
|
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.Null(parsed["instance"]);
|
||||||
Assert.Equal("weather-api", parsed["target"]);
|
Assert.Equal("weather-api", parsed["target"]);
|
||||||
Assert.Equal("multi-role", parsed["actor"]);
|
Assert.Equal("multi-role", parsed["actor"]);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Tests.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M8): tests for the shared lax multi-value query-param parsers
|
||||||
|
/// used by the ManagementService + CentralUI audit endpoints and the
|
||||||
|
/// <c>AuditLogPage</c> drill-in parser. The contract under test: parse each
|
||||||
|
/// repeated value independently, silently drop unparseable/blank elements, and
|
||||||
|
/// collapse an empty result to <c>null</c>.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditQueryParamParsersTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_NullInput_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_EmptyInput_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditChannel>(Array.Empty<string?>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_AllValuesValid_ParsesEverything()
|
||||||
|
{
|
||||||
|
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
|
||||||
|
new[] { "ApiOutbound", "DbOutbound" });
|
||||||
|
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_IsCaseInsensitive()
|
||||||
|
{
|
||||||
|
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(new[] { "apioutbound" });
|
||||||
|
Assert.Equal(new[] { AuditChannel.ApiOutbound }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_DropsUnparseableElement_KeepsTheRest()
|
||||||
|
{
|
||||||
|
var result = AuditQueryParamParsers.ParseEnumList<AuditChannel>(
|
||||||
|
new[] { "ApiOutbound", "NotAChannel", "Notification" });
|
||||||
|
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseEnumList_AllValuesUnparseable_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(AuditQueryParamParsers.ParseEnumList<AuditStatus>(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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user