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 DoSuccessfulActionAsync(string param); [Audit] Task DoFailedActionAsync(string param); [Audit] Task ThrowExceptionAsync(string param); } public class TestService : ITestService { public Task DoSuccessfulActionAsync(string param) => Task.FromResult(Result.Ok()); public Task DoFailedActionAsync(string param) => Task.FromResult(Result.Fail(new Error("Database connection timed out"))); public Task 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 options, ITenantContextService tenantService) : IDbContextFactory { public ApplicationDbContext CreateDbContext() => new(options, tenantService); public Task CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(new ApplicationDbContext(options, tenantService)); } [Fact] public async Task AuditInterceptor_WhenActionFails_LogsErrorDetails() { var options = new DbContextOptionsBuilder() .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(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() .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(target, interceptor); // Act & Assert await Assert.ThrowsAsync(() => 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() .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(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() .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() .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); } }