diff --git a/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs index d8918b7..a0b0e0e 100644 --- a/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs @@ -32,7 +32,12 @@ public class ExpenseSnapshotServiceTests db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" }); db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" }); db.SaveChanges(); - return (new ExpenseSnapshotService(db), db); + + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var http = new Mock(); + http.Setup(x => x.HttpContext).Returns(ctx); + return (new ExpenseSnapshotService(db, http.Object), db); } private static CreateExpenseSnapshotRequest Rent() => new() @@ -62,6 +67,15 @@ public class ExpenseSnapshotServiceTests await Assert.ThrowsAsync(() => svc.CreateAsync(r)); } + [Fact] + public async Task Create_WithInvalidFunctionalClass_Throws() + { + var (svc, _) = Build(); + var r = Rent(); + r.Lines[0].FunctionalClass = "NotAClass"; + await Assert.ThrowsAsync(() => svc.CreateAsync(r)); + } + [Fact] public async Task GetById_ReturnsLines_TotalsAndCreatorName() { @@ -138,4 +152,14 @@ public class ExpenseSnapshotServiceTests Assert.Empty(await svc.GetAllAsync()); Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id)); } + + [Fact] + public async Task Delete_StampsDeletedBy() + { + var (svc, db) = Build("deleter-1"); + var id = await svc.CreateAsync(Rent()); + await svc.DeleteAsync(id); + var row = await db.ExpenseSnapshots.IgnoreQueryFilters().FirstAsync(s => s.Id == id); + Assert.Equal("deleter-1", row.DeletedBy); + } } diff --git a/API/ROLAC.API/Services/ExpenseSnapshotService.cs b/API/ROLAC.API/Services/ExpenseSnapshotService.cs index cd85877..3df66bb 100644 --- a/API/ROLAC.API/Services/ExpenseSnapshotService.cs +++ b/API/ROLAC.API/Services/ExpenseSnapshotService.cs @@ -1,3 +1,5 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Expense; @@ -8,7 +10,17 @@ namespace ROLAC.API.Services; public class ExpenseSnapshotService : IExpenseSnapshotService { private readonly AppDbContext _db; - public ExpenseSnapshotService(AppDbContext db) => _db = db; + private readonly IHttpContextAccessor _http; + public ExpenseSnapshotService(AppDbContext db, IHttpContextAccessor http) + { _db = db; _http = http; } + + // The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false), + // so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it), + // then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production. + private string CurrentUserId => + _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? _http.HttpContext?.User.FindFirstValue("sub") + ?? "system"; public async Task> GetAllAsync() { @@ -101,7 +113,7 @@ public class ExpenseSnapshotService : IExpenseSnapshotService { var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Snapshot {id} not found."); - s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow; + s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow; s.DeletedBy = CurrentUserId; await _db.SaveChangesAsync(); } @@ -115,6 +127,8 @@ public class ExpenseSnapshotService : IExpenseSnapshotService throw new InvalidOperationException("Each snapshot line needs a category group and subcategory."); if (l.Amount <= 0) throw new InvalidOperationException("Each snapshot line amount must be greater than zero."); + if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass)) + throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'."); } }