Implemented audit logging
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user