feat(audit): multi-value filters across ManagementService, CLI and Central UI
This commit is contained in:
@@ -58,10 +58,10 @@ public class AuditQueryCommandTests
|
||||
{
|
||||
Since = "1h",
|
||||
Until = "2026-05-20T12:00:00Z",
|
||||
Channel = "ApiOutbound",
|
||||
Kind = "ApiCallCached",
|
||||
Status = "Delivered",
|
||||
Site = "site-1",
|
||||
Channel = new[] { "ApiOutbound" },
|
||||
Kind = new[] { "ApiCallCached" },
|
||||
Status = new[] { "Delivered" },
|
||||
Site = new[] { "site-1" },
|
||||
Target = "weather-api",
|
||||
Actor = "multi-role",
|
||||
CorrelationId = "abc-123",
|
||||
@@ -96,6 +96,43 @@ public class AuditQueryCommandTests
|
||||
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()
|
||||
{
|
||||
@@ -254,6 +291,38 @@ public class AuditQueryCommandTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -77,12 +77,30 @@ public class AuditFilterBarTests : BunitContext
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
// Task 8: the filter dimension is multi-value now; ToFilter still collapses
|
||||
// the chip selection to a single-element list (Task 9 widens that).
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
|
||||
Assert.Equal("Plant-A-OPC", captured.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_WithMultipleChannelChips_PassesAllSelectedChannels()
|
||||
{
|
||||
// Task 9: ToFilter no longer collapses the chip multi-select — every
|
||||
// selected channel chip reaches the filter's Channels list.
|
||||
AuditLogQueryFilter? captured = null;
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
cut.Find("[data-test=\"chip-channel-ApiOutbound\"]").Click();
|
||||
cut.Find("[data-test=\"chip-channel-Notification\"]").Click();
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.NotNull(captured!.Channels);
|
||||
Assert.Equal(2, captured.Channels!.Count);
|
||||
Assert.Contains(AuditChannel.ApiOutbound, captured.Channels);
|
||||
Assert.Contains(AuditChannel.Notification, captured.Channels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Channel_Narrows_Kind_Options_When_Selected()
|
||||
{
|
||||
@@ -119,8 +137,12 @@ public class AuditFilterBarTests : BunitContext
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
// Single-value collapse contract (Task 8): Failed leads the non-success set.
|
||||
Assert.Equal(new[] { AuditStatus.Failed }, captured!.Statuses);
|
||||
// Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}.
|
||||
Assert.NotNull(captured!.Statuses);
|
||||
Assert.Equal(3, captured.Statuses!.Count);
|
||||
Assert.Contains(AuditStatus.Failed, captured.Statuses);
|
||||
Assert.Contains(AuditStatus.Parked, captured.Statuses);
|
||||
Assert.Contains(AuditStatus.Discarded, captured.Statuses);
|
||||
|
||||
// Now pin an explicit Status chip — Errors-only must yield (chip wins).
|
||||
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
|
||||
@@ -129,6 +151,26 @@ public class AuditFilterBarTests : BunitContext
|
||||
Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses()
|
||||
{
|
||||
// Task 9: multiple explicit Status chips all reach the filter — and they
|
||||
// win over the Errors-only default.
|
||||
AuditLogQueryFilter? captured = null;
|
||||
var cut = Render<AuditFilterBar>(p => p
|
||||
.Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));
|
||||
|
||||
cut.Find("[data-test=\"chip-status-Delivered\"]").Click();
|
||||
cut.Find("[data-test=\"chip-status-Failed\"]").Click();
|
||||
cut.Find("[data-test=\"filter-apply\"]").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.NotNull(captured!.Statuses);
|
||||
Assert.Equal(2, captured.Statuses!.Count);
|
||||
Assert.Contains(AuditStatus.Delivered, captured.Statuses);
|
||||
Assert.Contains(AuditStatus.Failed, captured.Statuses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
|
||||
{
|
||||
|
||||
@@ -74,4 +74,22 @@ public class AuditLogPageExportUrlTests
|
||||
Assert.Single(query);
|
||||
Assert.Equal("Notification", query["channel"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExportUrl_MultiValueDimensions_EmitRepeatedParams()
|
||||
{
|
||||
// Task 9: each multi-value dimension emits one repeated query-string key
|
||||
// per selected value so the export endpoint's ParseFilter sees them all.
|
||||
var filter = new AuditLogQueryFilter(
|
||||
Channels: new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound },
|
||||
Statuses: new[] { AuditStatus.Failed, AuditStatus.Parked },
|
||||
SourceSiteIds: new[] { "plant-a", "plant-b" });
|
||||
|
||||
var url = AuditLogPage.BuildExportUrl(filter);
|
||||
|
||||
var query = QueryHelpers.ParseQuery(new Uri("http://x" + url).Query);
|
||||
Assert.Equal(new[] { "ApiOutbound", "DbOutbound" }, query["channel"].ToArray());
|
||||
Assert.Equal(new[] { "Failed", "Parked" }, query["status"].ToArray());
|
||||
Assert.Equal(new[] { "plant-a", "plant-b" }, query["site"].ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +367,89 @@ public class AuditEndpointsTests
|
||||
Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_RepeatedParams_ParseIntoMultiValueLists()
|
||||
{
|
||||
// Repeated query params (channel=A&channel=B …) must widen to multi-value
|
||||
// filter lists — one element per supplied value.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["channel"] = new[] { "ApiOutbound", "DbOutbound" },
|
||||
["kind"] = new[] { "ApiCall", "DbWrite" },
|
||||
["status"] = new[] { "Failed", "Parked" },
|
||||
["sourceSiteId"] = new[] { "plant-a", "plant-b" },
|
||||
});
|
||||
|
||||
var filter = AuditEndpoints.ParseFilter(query);
|
||||
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, filter.Channels);
|
||||
Assert.Equal(new[] { AuditKind.ApiCall, AuditKind.DbWrite }, filter.Kinds);
|
||||
Assert.Equal(new[] { AuditStatus.Failed, AuditStatus.Parked }, filter.Statuses);
|
||||
Assert.Equal(new[] { "plant-a", "plant-b" }, filter.SourceSiteIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_SingleParam_ParsesIntoOneElementList()
|
||||
{
|
||||
// The single-valued contract still holds — one param yields a
|
||||
// one-element list, not a scalar.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["channel"] = "ApiOutbound",
|
||||
["status"] = "Delivered",
|
||||
});
|
||||
|
||||
var filter = AuditEndpoints.ParseFilter(query);
|
||||
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound }, filter.Channels);
|
||||
Assert.Equal(new[] { AuditStatus.Delivered }, filter.Statuses);
|
||||
Assert.Null(filter.Kinds);
|
||||
Assert.Null(filter.SourceSiteIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFilter_UnparseableValuesInRepeatedSet_AreDroppedSilently()
|
||||
{
|
||||
// Lax-parse contract: an unrecognised enum name is dropped, the rest of
|
||||
// the repeated set survives — no 400, no whole-set drop.
|
||||
var query = new Microsoft.AspNetCore.Http.QueryCollection(
|
||||
new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
|
||||
{
|
||||
["channel"] = new[] { "ApiOutbound", "Bogus", "Notification" },
|
||||
["status"] = new[] { "Nonsense" },
|
||||
});
|
||||
|
||||
var filter = AuditEndpoints.ParseFilter(query);
|
||||
|
||||
Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, filter.Channels);
|
||||
// Every value unparseable → the dimension stays unconstrained (null).
|
||||
Assert.Null(filter.Statuses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
|
||||
{
|
||||
// End-to-end: a repeated channel= query param must surface at the
|
||||
// repository as a two-element Channels list.
|
||||
var (client, repo, host) = await BuildHostAsync(roles: new[] { "Audit" });
|
||||
using (host)
|
||||
{
|
||||
var response = await client.SendAsync(Get(
|
||||
"/api/audit/query?channel=ApiOutbound&channel=DbOutbound"));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
await repo.Received().QueryAsync(
|
||||
Arg.Is<AuditLogQueryFilter>(f =>
|
||||
f.Channels != null && f.Channels.Count == 2 &&
|
||||
f.Channels.Contains(AuditChannel.ApiOutbound) &&
|
||||
f.Channels.Contains(AuditChannel.DbOutbound)),
|
||||
Arg.Any<AuditLogPaging>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParsePaging_HalfSuppliedCursor_IsDropped()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user