diff --git a/API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs b/API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs new file mode 100644 index 0000000..4ed3f1e --- /dev/null +++ b/API/ROLAC.API.Tests/Data/AuditInterceptorTests.cs @@ -0,0 +1,62 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.Entities; +using Xunit; + +namespace ROLAC.API.Tests.Data; + +public class AuditInterceptorTests +{ + private static AppDbContext BuildDb(AuditSaveChangesInterceptor interceptor) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options; + return new AppDbContext(options); + } + + private static AuditSaveChangesInterceptor BuildInterceptor(string userId = "user-1") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AuditSaveChangesInterceptor(mock.Object); + } + + [Fact] + public async Task Added_SetsCreatedAtAndCreatedBy() + { + var interceptor = BuildInterceptor("user-42"); + using var db = BuildDb(interceptor); + + var member = new Member { FirstName_en = "A", LastName_en = "B" }; + db.Members.Add(member); + await db.SaveChangesAsync(); + + Assert.Equal("user-42", member.CreatedBy); + Assert.Equal("user-42", member.UpdatedBy); + Assert.True(member.CreatedAt > DateTimeOffset.UtcNow.AddSeconds(-5)); + } + + [Fact] + public async Task Modified_UpdatesUpdatedAtAndUpdatedBy() + { + var interceptor = BuildInterceptor("user-1"); + using var db = BuildDb(interceptor); + + var member = new Member { FirstName_en = "A", LastName_en = "B" }; + db.Members.Add(member); + await db.SaveChangesAsync(); + + member.NickName = "Nick"; + await db.SaveChangesAsync(); + + Assert.Equal("user-1", member.UpdatedBy); + } +} diff --git a/API/ROLAC.API/Data/Interceptors/AuditSaveChangesInterceptor.cs b/API/ROLAC.API/Data/Interceptors/AuditSaveChangesInterceptor.cs new file mode 100644 index 0000000..c4b15e9 --- /dev/null +++ b/API/ROLAC.API/Data/Interceptors/AuditSaveChangesInterceptor.cs @@ -0,0 +1,55 @@ +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Data.Interceptors; + +public class AuditSaveChangesInterceptor : SaveChangesInterceptor +{ + private readonly IHttpContextAccessor _http; + + public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http; + + public override InterceptionResult SavingChanges( + DbContextEventData eventData, InterceptionResult result) + { + Stamp(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, + CancellationToken cancellationToken = default) + { + Stamp(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private void Stamp(DbContext? db) + { + if (db is null) return; + + var userId = _http.HttpContext?.User + .FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; + var now = DateTimeOffset.UtcNow; + + foreach (var entry in db.ChangeTracker.Entries()) + { + if (entry.Entity is not AuditableEntity audit) continue; + + if (entry.State == EntityState.Added) + { + audit.CreatedAt = now; + audit.CreatedBy = userId; + audit.UpdatedAt = now; + audit.UpdatedBy = userId; + } + else if (entry.State == EntityState.Modified) + { + audit.UpdatedAt = now; + audit.UpdatedBy = userId; + } + } + } +}