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

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