Implemented audit logging

This commit is contained in:
2026-10-22 12:31:37 +02:00
parent 92891438f6
commit 4dd0899209
12 changed files with 3477 additions and 90 deletions
+312
View File
@@ -0,0 +1,312 @@
using System.Reflection;
using System.Security.Claims;
using Castle.DynamicProxy;
using FluentResults;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using PostFundManagement.Application.Interceptors;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Attributes;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Infrastructure.Database;
using Entities = PostFundManagement.Domain.Entities;
using Models = PostFundManagement.Domain.Models;
namespace PostFundManagement.Tests;
public class AuditLogTests
{
[Fact]
public void AuditLog_WhenErrorNull_IsErrorIsFalse()
{
var model = new Models.AuditLog
{
Id = 1,
TenantId = 1,
Action = "Test",
Error = null
};
Assert.False(model.IsError);
}
[Fact]
public void AuditLog_WhenErrorPopulated_IsErrorIsTrue()
{
var model = new Models.AuditLog
{
Id = 2,
TenantId = 1,
Action = "Test",
Error = "[ValidationError] Something went wrong"
};
Assert.True(model.IsError);
Assert.Equal("[ValidationError] Something went wrong", model.Error);
}
[Fact]
public void AuditLog_Map_CorrectlyMapsErrorField()
{
var entity = new Entities.AuditLog
{
Id = 10,
TenantId = 3,
Action = "RegisterAward",
Changes = ["amount: 50000"],
EntityId = "AW-100",
EntityName = "Award",
PerformedBy = Guid.NewGuid(),
Timestamp = DateTime.UtcNow,
Error = "[Exception] InvalidOperationException: Award already exists"
};
var mapped = entity.Map();
Assert.Equal(entity.Id, mapped.Id);
Assert.Equal(entity.TenantId, mapped.TenantId);
Assert.Equal(entity.Action, mapped.Action);
Assert.Equal(entity.Changes, mapped.Changes);
Assert.Equal(entity.EntityId, mapped.EntityId);
Assert.Equal(entity.EntityName, mapped.EntityName);
Assert.Equal(entity.PerformedBy, mapped.PerformedBy);
Assert.Equal(entity.Timestamp, mapped.Timestamp);
Assert.Equal(entity.Error, mapped.Error);
Assert.True(mapped.IsError);
}
public interface ITestService
{
[Audit]
Task<Result> DoSuccessfulActionAsync(string param);
[Audit]
Task<Result> DoFailedActionAsync(string param);
[Audit]
Task<Result> ThrowExceptionAsync(string param);
}
public class TestService : ITestService
{
public Task<Result> DoSuccessfulActionAsync(string param) =>
Task.FromResult(Result.Ok());
public Task<Result> DoFailedActionAsync(string param) =>
Task.FromResult(Result.Fail(new Error("Database connection timed out")));
public Task<Result> ThrowExceptionAsync(string param) =>
throw new InvalidOperationException("Fatal system error occurred");
}
private sealed class DummyTenantContextService : ITenantContextService
{
public string? TenantName => "TestTenant";
public long TenantId { get; }
public bool ByPassTenantQueryFilter { get; set; }
public bool IsAdmin { get; }
public bool HasTenantFilter => !IsAdmin;
public DummyTenantContextService(long tenantId, bool isAdmin)
{
TenantId = tenantId;
IsAdmin = isAdmin;
ByPassTenantQueryFilter = isAdmin;
}
}
private sealed class TestDbContextFactory(DbContextOptions<ApplicationDbContext> options, ITenantContextService tenantService) : IDbContextFactory<ApplicationDbContext>
{
public ApplicationDbContext CreateDbContext() => new(options, tenantService);
public Task<ApplicationDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(new ApplicationDbContext(options, tenantService));
}
[Fact]
public async Task AuditInterceptor_WhenActionFails_LogsErrorDetails()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false);
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
var httpContext = new DefaultHttpContext();
var userId = Guid.NewGuid();
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim("tenant_id", "5")
};
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var proxyGenerator = new ProxyGenerator();
var interceptor = new AuditInterceptor(auditService, httpContextAccessor);
var target = new TestService();
var proxy = proxyGenerator.CreateInterfaceProxyWithTarget<ITestService>(target, interceptor);
// Act
var result = await proxy.DoFailedActionAsync("test_payload");
// Assert
Assert.True(result.IsFailed);
await using var verifyContext = new ApplicationDbContext(options, tenantService);
var logs = await verifyContext.AuditLogs.ToListAsync();
Assert.Single(logs);
var log = logs[0];
Assert.Equal("DoFailedAction", log.Action);
Assert.Equal(5, log.TenantId);
Assert.Equal(userId, log.PerformedBy);
Assert.NotNull(log.Error);
Assert.Contains("Database connection timed out", log.Error);
}
[Fact]
public async Task AuditInterceptor_WhenActionThrowsException_LogsExceptionAndRethrows()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false);
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
var httpContext = new DefaultHttpContext();
var userId = Guid.NewGuid();
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim("tenant_id", "5")
};
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var proxyGenerator = new ProxyGenerator();
var interceptor = new AuditInterceptor(auditService, httpContextAccessor);
var target = new TestService();
var proxy = proxyGenerator.CreateInterfaceProxyWithTarget<ITestService>(target, interceptor);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() => proxy.ThrowExceptionAsync("test_payload"));
await using var verifyContext = new ApplicationDbContext(options, tenantService);
var logs = await verifyContext.AuditLogs.ToListAsync();
Assert.Single(logs);
var log = logs[0];
Assert.Equal("ThrowException", log.Action);
Assert.Equal(5, log.TenantId);
Assert.Equal(userId, log.PerformedBy);
Assert.NotNull(log.Error);
Assert.Contains("InvalidOperationException", log.Error);
Assert.Contains("Fatal system error occurred", log.Error);
}
[Fact]
public async Task AuditInterceptor_WhenActionSucceeds_LogsWithoutError()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false);
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
var httpContext = new DefaultHttpContext();
var userId = Guid.NewGuid();
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim("tenant_id", "5")
};
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var proxyGenerator = new ProxyGenerator();
var interceptor = new AuditInterceptor(auditService, httpContextAccessor);
var target = new TestService();
var proxy = proxyGenerator.CreateInterfaceProxyWithTarget<ITestService>(target, interceptor);
// Act
var result = await proxy.DoSuccessfulActionAsync("test_payload");
// Assert
Assert.True(result.IsSuccess);
await using var verifyContext = new ApplicationDbContext(options, tenantService);
var logs = await verifyContext.AuditLogs.ToListAsync();
Assert.Single(logs);
var log = logs[0];
Assert.Equal("DoSuccessfulAction", log.Action);
Assert.Equal(5, log.TenantId);
Assert.Equal(userId, log.PerformedBy);
Assert.Null(log.Error);
}
[Fact]
public async Task AuditService_GetAuditLogsAsync_NonAdmin_FiltersByTenant()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 2, isAdmin: false);
await using (var dbContext = new ApplicationDbContext(options, tenantService))
{
dbContext.AuditLogs.AddRange(
new Entities.AuditLog { Id = 1, TenantId = 1, Action = "A1", EntityId = "1", EntityName = "E1", PerformedBy = Guid.NewGuid() },
new Entities.AuditLog { Id = 2, TenantId = 2, Action = "A2", EntityId = "2", EntityName = "E2", PerformedBy = Guid.NewGuid() },
new Entities.AuditLog { Id = 3, TenantId = 3, Action = "A3", EntityId = "3", EntityName = "E3", PerformedBy = Guid.NewGuid() }
);
await dbContext.SaveChangesAsync();
}
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
// Act
var result = await auditService.GetAuditLogsAsync(Pagination.Create(1, 10));
// Assert
Assert.True(result.IsSuccess);
Assert.Single(result.Value);
Assert.Equal(2, result.Value[0].TenantId);
}
[Fact]
public async Task AuditService_GetAuditLogsAsync_Tenant1Admin_SeesAllTenantsLogs()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
var tenantService = new DummyTenantContextService(tenantId: 1, isAdmin: true);
await using (var dbContext = new ApplicationDbContext(options, tenantService))
{
dbContext.AuditLogs.AddRange(
new Entities.AuditLog { Id = 1, TenantId = 1, Action = "A1", EntityId = "1", EntityName = "E1", PerformedBy = Guid.NewGuid() },
new Entities.AuditLog { Id = 2, TenantId = 2, Action = "A2", EntityId = "2", EntityName = "E2", PerformedBy = Guid.NewGuid() },
new Entities.AuditLog { Id = 3, TenantId = 3, Action = "A3", EntityId = "3", EntityName = "E3", PerformedBy = Guid.NewGuid() }
);
await dbContext.SaveChangesAsync();
}
var dbContextFactory = new TestDbContextFactory(options, tenantService);
var auditService = new AuditService(dbContextFactory, tenantService);
// Act
var result = await auditService.GetAuditLogsAsync(Pagination.Create(1, 10));
// Assert
Assert.True(result.IsSuccess);
Assert.Equal(3, result.Value.Length);
}
}