feat(1099): add Payee1099Service recipient CRUD with TIN protection
Implement IPayee1099Service and Payee1099Service: list/get/create/update/ soft-delete and RevealTin. TIN is encrypted via ITinProtector on write; TinLast4 is the only clear-text fragment stored. Null Tin on update preserves the existing ciphertext. Four xUnit tests cover encrypt-on-create, null-tin-keeps-ciphertext, list-masks-to-last4, and soft-delete hides from list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using System.Security.Claims;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Payee;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class Payee1099ServiceTests
|
||||
{
|
||||
private static (Payee1099Service svc, AppDbContext db) Build()
|
||||
{
|
||||
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||
var accessorMock = new Mock<IHttpContextAccessor>();
|
||||
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(accessorMock.Object))).Options);
|
||||
var tin = new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
|
||||
return (new Payee1099Service(db, tin), db);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_encrypts_tin_and_stores_last4_only_in_clear()
|
||||
{
|
||||
var (svc, db) = Build();
|
||||
var id = await svc.CreateAsync(new SavePayee1099Request
|
||||
{ LegalName = "Pat Player", TinType = "SSN", Tin = "123-45-6789", W9Status = "OnFile" });
|
||||
|
||||
var saved = await db.Payee1099s.FindAsync(id);
|
||||
Assert.NotNull(saved);
|
||||
Assert.Equal("6789", saved!.TinLast4);
|
||||
Assert.NotNull(saved.TinEncrypted);
|
||||
Assert.DoesNotContain("123-45-6789", saved.TinEncrypted!);
|
||||
Assert.Equal("123-45-6789", await svc.RevealTinAsync(id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_with_null_tin_keeps_existing_ciphertext()
|
||||
{
|
||||
var (svc, db) = Build();
|
||||
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "X", Tin = "11-2223333" });
|
||||
var before = (await db.Payee1099s.FindAsync(id))!.TinEncrypted;
|
||||
|
||||
await svc.UpdateAsync(id, new SavePayee1099Request { LegalName = "X renamed", Tin = null });
|
||||
|
||||
var after = await db.Payee1099s.FindAsync(id);
|
||||
Assert.Equal("X renamed", after!.LegalName);
|
||||
Assert.Equal(before, after.TinEncrypted);
|
||||
Assert.Equal("3333", after.TinLast4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_dto_masks_tin_to_last4()
|
||||
{
|
||||
var (svc, _) = Build();
|
||||
await svc.CreateAsync(new SavePayee1099Request { LegalName = "Y", Tin = "999-88-7777" });
|
||||
var list = await svc.GetAllAsync(includeInactive: true);
|
||||
var item = Assert.Single(list);
|
||||
Assert.Equal("7777", item.TinLast4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_is_soft_and_hides_from_list()
|
||||
{
|
||||
var (svc, _) = Build();
|
||||
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "Z" });
|
||||
await svc.DeleteAsync(id);
|
||||
Assert.Empty(await svc.GetAllAsync(includeInactive: true));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user