feat(audit): multi-value filters across ManagementService, CLI and Central UI

This commit is contained in:
Joseph Doherty
2026-05-21 05:27:17 -04:00
parent 37c7a0e5ac
commit 2a76be1f94
11 changed files with 523 additions and 146 deletions

View File

@@ -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()
{

View File

@@ -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()
{

View File

@@ -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());
}
}

View File

@@ -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()
{