From 0da30d8b3c108b55f4a664be0bc5bf4f4c1a00c6 Mon Sep 17 00:00:00 2001 From: Ricardo Zambon Date: Sat, 26 Sep 2026 12:31:27 -0300 Subject: [PATCH] feat: filter the audit history by action, author and date The audit view lists every service that ever touched a record, newest first, and offers no way to narrow it. On a record with a long history that is a wall of rows to read through. The filtering itself was already here -- TryFilter, FilterTypes and the JsonElement conversion behind GetFilterValue -- but nothing applied it to the two audit lists. Now the services list honours Name (LIKE), ChangedByID (equals) and an inclusive ChangedOnFrom/ChangedOnTo range, and the operations list honours OnlyCurrentEntity. The names are constants on ZWebAPI.Models.Audit.AuditFilters rather than literals, because a filter crosses to the client as a dictionary key and nothing checks that both sides still spell it the same way. Lookups are case insensitive, so a camelCase form control reaches a PascalCase property. OnlyCurrentEntity is off by default, and deliberately: a service writes to more than one table, and listing all of its operations is what shows the related rows a change also touched. The filter is there for when that context is noise, not the other way round -- which is also why it does not touch the services list, already restricted to the services that touched the record. The dates are compared as given, and ChangedOn is stored UTC, so a caller converts before sending. Both ends are inclusive: a range picked as two dates is read by a person as covering both of them. 8 tests, on top of the 11 the audit service already had: each filter alone, the two combined narrowing further rather than replacing one another, a range whose bounds are the rows' own instants (an exclusive comparison would return nothing), and the operations list keeping its related rows by default, dropping them when asked, and keeping them when the filter is present but off. 274 pass on net8.0, net9.0 and net10.0. Co-Authored-By: Claude Opus 5 --- .../Services/AuditServiceDefaultTests.cs | 198 ++++++++++++++++++ ZWebAPI/Models/Audit/AuditFilters.cs | 49 +++++ ZWebAPI/Services/AuditServiceDefault.cs | 17 +- ZWebAPI/Services/Interfaces/IAuditService.cs | 9 +- 4 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 ZWebAPI/Models/Audit/AuditFilters.cs diff --git a/ZWebAPI.UnitTests/Services/AuditServiceDefaultTests.cs b/ZWebAPI.UnitTests/Services/AuditServiceDefaultTests.cs index 3296378..464b39e 100644 --- a/ZWebAPI.UnitTests/Services/AuditServiceDefaultTests.cs +++ b/ZWebAPI.UnitTests/Services/AuditServiceDefaultTests.cs @@ -1,9 +1,11 @@ using AutoMapper; +using System.Text.Json; using ZDatabase.Exceptions; using ZDatabase.Repositories.Audit.Interfaces; using ZSecurity.Services; using ZWebAPI.Interfaces; using ZWebAPI.Models; +using ZWebAPI.Models.Audit; using ZWebAPI.Models.Audit.OperationHistory; using ZWebAPI.Models.Audit.ServiceHistory; using ZWebAPI.Services; @@ -254,6 +256,202 @@ public async Task ListEntityOperationsHistoryAsync_Fail_ThrowsForAnUnknownServic await act.Should().ThrowAsync>(); } + /// + /// Test the ListEntityServicesHistoryAsync should match the service name with LIKE. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_FiltersTheServiceNameWithLike() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + IListParameters parameters = Filters((AuditFilters.Name, "\"Second\"")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Select(x => x.ID).Should().Equal(2L); + } + + /// + /// Test the ListEntityServicesHistoryAsync should match the author exactly. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_FiltersTheAuthor() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + IListParameters parameters = Filters((AuditFilters.ChangedByID, "1")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Select(x => x.ChangedByName).Should().Equal("Author"); + } + + /// + /// Test the ListEntityServicesHistoryAsync should treat both ends of the date range as inclusive. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_FiltersAnInclusiveDateRange() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + // The bounds are the two rows' own instants, so an exclusive comparison would return nothing. + IListParameters parameters = Filters( + (AuditFilters.ChangedOnFrom, "\"2024-01-01T00:00:00\""), + (AuditFilters.ChangedOnTo, "\"2024-02-01T00:00:00\"")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Select(x => x.ID).Should().BeEquivalentTo(new[] { 1L, 2L }); + } + + /// + /// Test the ListEntityServicesHistoryAsync should narrow a date range to one end. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_FiltersFromADateAlone() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + IListParameters parameters = Filters((AuditFilters.ChangedOnFrom, "\"2024-01-15T00:00:00\"")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Select(x => x.ID).Should().Equal(2L); + } + + /// + /// Test the ListEntityServicesHistoryAsync should combine the filters rather than pick one. + /// + [Fact] + public async Task ListEntityServicesHistoryAsync_Pass_CombinesEveryFilter() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + + // Each filter alone matches row 2; the author does not, so together they match nothing. + IListParameters parameters = Filters( + (AuditFilters.Name, "\"Second\""), + (AuditFilters.ChangedByID, "1")); + + // Act + IQueryable result = await harness.Service + .ListEntityServicesHistoryAsync(3, parameters); + + // Assert + result.Should().BeEmpty(); + } + + /// + /// Test the ListEntityOperationsHistoryAsync should list what a service touched beyond the + /// audited record unless asked otherwise. + /// + [Fact] + public async Task ListEntityOperationsHistoryAsync_Pass_KeepsTheRelatedRowsByDefault() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + SeedOperationsHistory(context, nameof(AuditableEntityFake), 3); + SeedOperationsHistory(context, "RelatedTable", 77, id: 20); + + // Act + IQueryable result = await harness.Service + .ListEntityOperationsHistoryAsync(3, 1, new ListParametersModel()); + + // Assert + result.Select(x => x.ID).Should().BeEquivalentTo(new[] { 10L, 20L }); + } + + /// + /// Test the ListEntityOperationsHistoryAsync should narrow the operations to the audited + /// record when asked. + /// + [Fact] + public async Task ListEntityOperationsHistoryAsync_Pass_NarrowsToTheAuditedRecordWhenAsked() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + SeedOperationsHistory(context, nameof(AuditableEntityFake), 3); + SeedOperationsHistory(context, "RelatedTable", 77, id: 20); + + IListParameters parameters = Filters((AuditFilters.OnlyCurrentEntity, "true")); + + // Act + IQueryable result = await harness.Service + .ListEntityOperationsHistoryAsync(3, 1, parameters); + + // Assert + result.Select(x => x.ID).Should().Equal(10L); + } + + /// + /// Test the ListEntityOperationsHistoryAsync should leave the operations alone when the + /// filter is present but off. + /// + [Fact] + public async Task ListEntityOperationsHistoryAsync_Pass_KeepsTheRelatedRowsWhenTheFilterIsOff() + { + // Arrange + using DbContextFake context = DbContextFakeFactory.Create(); + AuditServiceHarness harness = new(context); + SeedServicesHistory(context); + SeedOperationsHistory(context, nameof(AuditableEntityFake), 3); + SeedOperationsHistory(context, "RelatedTable", 77, id: 20); + + IListParameters parameters = Filters((AuditFilters.OnlyCurrentEntity, "false")); + + // Act + IQueryable result = await harness.Service + .ListEntityOperationsHistoryAsync(3, 1, parameters); + + // Assert + result.Select(x => x.ID).Should().BeEquivalentTo(new[] { 10L, 20L }); + } + + private static ListParametersModel Filters(params (string Key, string Json)[] filters) + { + ListParametersModel parameters = new(); + + foreach ((string key, string json) in filters) + { + // A filter reaches the server as a JsonElement, and the conversion behind + // GetFilterValue reads it as one. Building the dictionary any other way tests a + // path no request takes. + parameters.Filters![key] = JsonDocument.Parse(json).RootElement; + } + + return parameters; + } + private static void SeedServicesHistory(DbContextFake context) { UsersFake author = new() { ID = 1, Name = "Author" }; diff --git a/ZWebAPI/Models/Audit/AuditFilters.cs b/ZWebAPI/Models/Audit/AuditFilters.cs new file mode 100644 index 0000000..4a7489e --- /dev/null +++ b/ZWebAPI/Models/Audit/AuditFilters.cs @@ -0,0 +1,49 @@ +namespace ZWebAPI.Models.Audit +{ + /// + /// Names of the filters + /// understands in an . + /// + /// + /// A filter arrives as a dictionary key, so the client and the server agree on a string and + /// nothing checks that they still do. These constants are that string, named once, so a + /// consumer can reference them instead of spelling them again. + /// Lookups are case insensitive, which is what lets a camelCase form control reach a + /// PascalCase property. + /// + public static class AuditFilters + { + /// + /// Identifier of the user who ran the service. Matched exactly. + /// + public const string ChangedByID = nameof(ChangedByID); + + /// + /// Earliest instant to include, compared against the service history's ChangedOn and + /// inclusive of it. UTC, because that is how the instant is stored. + /// + public const string ChangedOnFrom = nameof(ChangedOnFrom); + + /// + /// Latest instant to include, compared against the service history's ChangedOn and + /// inclusive of it. UTC, because that is how the instant is stored. + /// + public const string ChangedOnTo = nameof(ChangedOnTo); + + /// + /// Name of the service that ran, in its IService\Method form. Matched with LIKE. + /// + public const string Name = nameof(Name); + + /// + /// Restricts the operations listed for a service history to the record being audited. + /// + /// + /// A service usually writes to more than one table, and by default every operation it + /// performed is listed -- which is what shows the related rows a change also touched. + /// Set this to narrow the list to the audited record alone. It has no effect on the + /// services list, which is already restricted to the services that touched the record. + /// + public const string OnlyCurrentEntity = nameof(OnlyCurrentEntity); + } +} diff --git a/ZWebAPI/Services/AuditServiceDefault.cs b/ZWebAPI/Services/AuditServiceDefault.cs index 16fa69d..fc57114 100644 --- a/ZWebAPI/Services/AuditServiceDefault.cs +++ b/ZWebAPI/Services/AuditServiceDefault.cs @@ -12,8 +12,10 @@ using ZSecurity.Attributes; using ZSecurity.Helpers; using ZSecurity.Services; +using ZWebAPI.Enums; using ZWebAPI.ExtensionMethods; using ZWebAPI.Interfaces; +using ZWebAPI.Models.Audit; using ZWebAPI.Models.Audit.OperationHistory; using ZWebAPI.Models.Audit.ServiceHistory; using ZWebAPI.Services.Interfaces; @@ -105,6 +107,10 @@ public async Task> ListEntityServicesHistor where TEntity : AuditableEntity { return (await servicesHistoryRepository.ListServicesAsync(entityID)) + .TryFilter(parameters, x => x.Name, AuditFilters.Name, FilterTypes.Like) + .TryFilter(parameters, x => x.ChangedByID, AuditFilters.ChangedByID, FilterTypes.Equals) + .TryFilter(parameters, x => x.ChangedOn, AuditFilters.ChangedOnFrom, FilterTypes.GreatherThanOrEqual) + .TryFilter(parameters, x => x.ChangedOn, AuditFilters.ChangedOnTo, FilterTypes.LessThanOrEqual) .GetRange(parameters) .ProjectTo(mapper.ConfigurationProvider); } @@ -127,11 +133,20 @@ public async Task> ListEntityOperationsHi // The returned operations must contain the entity identifier and the table name. EntityEntry entry = dbContext.Entry(entity); - if (!await operations.AnyAsync(x => EF.Functions.Like(x.TableName ?? string.Empty, entry.Metadata.GetTableName() ?? string.Empty) && x.EntityID == entityID)) + string tableName = entry.Metadata.GetTableName() ?? string.Empty; + + if (!await operations.AnyAsync(x => EF.Functions.Like(x.TableName ?? string.Empty, tableName) && x.EntityID == entityID)) { return Enumerable.Empty().AsQueryable(); } + // A service writes to more than one table, and listing all of it is what shows the rows + // a change also touched. Narrowed only when asked. + if (parameters.GetFilterValue(AuditFilters.OnlyCurrentEntity) == true) + { + operations = operations.Where(x => EF.Functions.Like(x.TableName ?? string.Empty, tableName) && x.EntityID == entityID); + } + return operations .GetRange(parameters) .ProjectTo(mapper.ConfigurationProvider); diff --git a/ZWebAPI/Services/Interfaces/IAuditService.cs b/ZWebAPI/Services/Interfaces/IAuditService.cs index 8854f52..edacd01 100644 --- a/ZWebAPI/Services/Interfaces/IAuditService.cs +++ b/ZWebAPI/Services/Interfaces/IAuditService.cs @@ -34,7 +34,7 @@ Task AddOperationHistoryAsync(EntityEntry entityEntry) /// The type of the entity. /// The entity identifier. /// The service history identifier. - /// The parameters. + /// The parameters. Recognises . /// Query with all operations history from the service history identifier Task> ListEntityOperationsHistoryAsync(long entityID, long serviceHistoryID, IListParameters parameters) where TEntity : AuditableEntity; @@ -44,7 +44,12 @@ Task> ListEntityOperationsHistoryAsync /// The type of the entity. /// The entity identifier. - /// The parameters. + /// + /// The parameters. Recognises , + /// , + /// and + /// . + /// /// Query with all services history from the entity identifier. Task> ListEntityServicesHistoryAsync(long entityID, IListParameters parameters) where TEntity : AuditableEntity;