Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions ZWebAPI.UnitTests/Services/AuditServiceDefaultTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -254,6 +256,202 @@ public async Task ListEntityOperationsHistoryAsync_Fail_ThrowsForAnUnknownServic
await act.Should().ThrowAsync<EntityNotFoundException<ServicesHistoryFake>>();
}

/// <summary>
/// Test the ListEntityServicesHistoryAsync should match the service name with LIKE.
/// </summary>
[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<ServicesHistoryListModel> result = await harness.Service
.ListEntityServicesHistoryAsync<AuditableEntityFake>(3, parameters);

// Assert
result.Select(x => x.ID).Should().Equal(2L);
}

/// <summary>
/// Test the ListEntityServicesHistoryAsync should match the author exactly.
/// </summary>
[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<ServicesHistoryListModel> result = await harness.Service
.ListEntityServicesHistoryAsync<AuditableEntityFake>(3, parameters);

// Assert
result.Select(x => x.ChangedByName).Should().Equal("Author");
}

/// <summary>
/// Test the ListEntityServicesHistoryAsync should treat both ends of the date range as inclusive.
/// </summary>
[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<ServicesHistoryListModel> result = await harness.Service
.ListEntityServicesHistoryAsync<AuditableEntityFake>(3, parameters);

// Assert
result.Select(x => x.ID).Should().BeEquivalentTo(new[] { 1L, 2L });
}

/// <summary>
/// Test the ListEntityServicesHistoryAsync should narrow a date range to one end.
/// </summary>
[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<ServicesHistoryListModel> result = await harness.Service
.ListEntityServicesHistoryAsync<AuditableEntityFake>(3, parameters);

// Assert
result.Select(x => x.ID).Should().Equal(2L);
}

/// <summary>
/// Test the ListEntityServicesHistoryAsync should combine the filters rather than pick one.
/// </summary>
[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<ServicesHistoryListModel> result = await harness.Service
.ListEntityServicesHistoryAsync<AuditableEntityFake>(3, parameters);

// Assert
result.Should().BeEmpty();
}

/// <summary>
/// Test the ListEntityOperationsHistoryAsync should list what a service touched beyond the
/// audited record unless asked otherwise.
/// </summary>
[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<OperationsHistoryListModel> result = await harness.Service
.ListEntityOperationsHistoryAsync<AuditableEntityFake>(3, 1, new ListParametersModel());

// Assert
result.Select(x => x.ID).Should().BeEquivalentTo(new[] { 10L, 20L });
}

/// <summary>
/// Test the ListEntityOperationsHistoryAsync should narrow the operations to the audited
/// record when asked.
/// </summary>
[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<OperationsHistoryListModel> result = await harness.Service
.ListEntityOperationsHistoryAsync<AuditableEntityFake>(3, 1, parameters);

// Assert
result.Select(x => x.ID).Should().Equal(10L);
}

/// <summary>
/// Test the ListEntityOperationsHistoryAsync should leave the operations alone when the
/// filter is present but off.
/// </summary>
[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<OperationsHistoryListModel> result = await harness.Service
.ListEntityOperationsHistoryAsync<AuditableEntityFake>(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" };
Expand Down
49 changes: 49 additions & 0 deletions ZWebAPI/Models/Audit/AuditFilters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace ZWebAPI.Models.Audit
{
/// <summary>
/// Names of the filters <see cref="ZWebAPI.Services.Interfaces.IAuditService{TUsers, TUsersKey}"/>
/// understands in an <see cref="ZWebAPI.Interfaces.IListParameters"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class AuditFilters
{
/// <summary>
/// Identifier of the user who ran the service. Matched exactly.
/// </summary>
public const string ChangedByID = nameof(ChangedByID);

/// <summary>
/// Earliest instant to include, compared against the service history's ChangedOn and
/// inclusive of it. UTC, because that is how the instant is stored.
/// </summary>
public const string ChangedOnFrom = nameof(ChangedOnFrom);

/// <summary>
/// Latest instant to include, compared against the service history's ChangedOn and
/// inclusive of it. UTC, because that is how the instant is stored.
/// </summary>
public const string ChangedOnTo = nameof(ChangedOnTo);

/// <summary>
/// Name of the service that ran, in its <c>IService\Method</c> form. Matched with LIKE.
/// </summary>
public const string Name = nameof(Name);

/// <summary>
/// Restricts the operations listed for a service history to the record being audited.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public const string OnlyCurrentEntity = nameof(OnlyCurrentEntity);
}
}
17 changes: 16 additions & 1 deletion ZWebAPI/Services/AuditServiceDefault.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -105,6 +107,10 @@ public async Task<IQueryable<ServicesHistoryListModel>> ListEntityServicesHistor
where TEntity : AuditableEntity<TUsers, TUsersKey>
{
return (await servicesHistoryRepository.ListServicesAsync<TEntity>(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<ServicesHistoryListModel>(mapper.ConfigurationProvider);
}
Expand All @@ -127,11 +133,20 @@ public async Task<IQueryable<OperationsHistoryListModel>> ListEntityOperationsHi

// The returned operations must contain the entity identifier and the table name.
EntityEntry<TEntity> 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<OperationsHistoryListModel>().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<bool>(AuditFilters.OnlyCurrentEntity) == true)
{
operations = operations.Where(x => EF.Functions.Like(x.TableName ?? string.Empty, tableName) && x.EntityID == entityID);
}

return operations
.GetRange(parameters)
.ProjectTo<OperationsHistoryListModel>(mapper.ConfigurationProvider);
Expand Down
9 changes: 7 additions & 2 deletions ZWebAPI/Services/Interfaces/IAuditService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Task AddOperationHistoryAsync<TEntity>(EntityEntry<TEntity> entityEntry)
/// <typeparam name="TEntity">The type of the entity.</typeparam>
/// <param name="entityID">The entity identifier.</param>
/// <param name="serviceHistoryID">The service history identifier.</param>
/// <param name="parameters">The parameters.</param>
/// <param name="parameters">The parameters. Recognises <see cref="Models.Audit.AuditFilters.OnlyCurrentEntity"/>.</param>
/// <returns>Query with all operations history from the service history identifier</returns>
Task<IQueryable<OperationsHistoryListModel>> ListEntityOperationsHistoryAsync<TEntity>(long entityID, long serviceHistoryID, IListParameters parameters)
where TEntity : AuditableEntity<TUsers, TUsersKey>;
Expand All @@ -44,7 +44,12 @@ Task<IQueryable<OperationsHistoryListModel>> ListEntityOperationsHistoryAsync<TE
/// </summary>
/// <typeparam name="TEntity">The type of the entity.</typeparam>
/// <param name="entityID">The entity identifier.</param>
/// <param name="parameters">The parameters.</param>
/// <param name="parameters">
/// The parameters. Recognises <see cref="Models.Audit.AuditFilters.Name"/>,
/// <see cref="Models.Audit.AuditFilters.ChangedByID"/>,
/// <see cref="Models.Audit.AuditFilters.ChangedOnFrom"/> and
/// <see cref="Models.Audit.AuditFilters.ChangedOnTo"/>.
/// </param>
/// <returns>Query with all services history from the entity identifier.</returns>
Task<IQueryable<ServicesHistoryListModel>> ListEntityServicesHistoryAsync<TEntity>(long entityID, IListParameters parameters)
where TEntity : AuditableEntity<TUsers, TUsersKey>;
Expand Down
Loading