[CLAUDE] App: golive harden — LeaveBalance concurrency + ItTicket authz-order + DocxRenderer + Travel/Vehicle tests
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s

Pre-golive verification (S56) surfaced 4 issues; all fixed + verified (228 test pass, 0 build warning).

#3 LeaveBalance lost-update (DB11 concurrency): terminal-approve deduction was an in-memory read-modify-write (UsedDays += NumDays) under a bare SaveChanges, so two concurrent terminal approvals of the same (user,type,year) lost an update. Fix: atomic server-side ExecuteUpdateAsync (UsedDays = UsedDays + n) inside an explicit Serializable transaction (matches the codegen/Proposal/TravelVehicle convention; serializes the auto-create-row race too). Exactly-once guard (Status != DaGuiDuyet) intact. No migration.

#5 ItTicket reassign existence-oracle: AssignItTicketHandler checked ticket-NotFound before the Admin-OR-dept-IT Forbidden guard. Reordered so authorization runs first -> fail-closed (a non-IT/non-admin caller gets Forbidden for any ticketId, existent or not).

#6 DocxRenderer CS8602: null-guard MainDocumentPart + Document with clear exceptions (cleared 2 build warnings -> 0).

#4 Travel/Vehicle ApproveV2: added smoke tests (Submit->Approve terminal + outsider-Forbidden) — previously zero coverage.

Tests 216 -> 228 (+12). database-agent DB-layer review PASS; em-main cross-stack review clean (reviewer workflow stage did not emit StructuredOutput -> em-main covered the cross-stack review by reading every diff). Bundles agent-memory harvest (S56).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-09 17:51:38 +07:00
parent bef582594e
commit a20cde89fb
13 changed files with 555 additions and 19 deletions

View File

@ -354,10 +354,22 @@ public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser c
{
p.Status = WorkflowAppStatus.DaDuyet;
p.CurrentApprovalLevelOrder = null;
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;
// P11-B: trừ phép khi duyệt cuối — chạy đúng 1 lần (DaDuyet không approve lại,
// early guard Status != DaGuiDuyet chặn re-approve). UPSERT LeaveBalance theo năm.
// P11-B + S56 #3 (lost-update fix): trừ phép khi duyệt cuối — chạy đúng 1 lần
// (DaDuyet không approve lại, early guard Status != DaGuiDuyet chặn re-approve).
// Race-free: ExecuteUpdateAsync làm increment server-side dưới row lock (2 lượt
// duyệt cuối song song serialize, không mất update). Opinion + status + balance
// gói trong 1 explicit transaction → atomic all-or-nothing như SaveChanges đơn cũ.
// S56: IsolationLevel.Serializable khớp convention codebase (codegen/Proposal/
// TravelVehicle) + serialize cả nhánh auto-create row mới (2 lượt đầu-tiên cùng key).
var year = p.StartDate.Year;
var dbContext = (DbContext)db;
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
// STEP 1 — persist opinion-upsert + status=DaDuyet + ENSURE balance row tồn tại.
// Auto-create: insert UsedDays=0 qua change tracker để STEP 2 increment được.
var bal = await db.LeaveBalances.FirstOrDefaultAsync(
b => b.UserId == p.RequesterUserId && b.LeaveTypeId == p.LeaveTypeId && b.Year == year, ct);
if (bal is null)
@ -377,9 +389,19 @@ public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser c
};
db.LeaveBalances.Add(bal);
}
bal.UsedDays += p.NumDays;
bal.UpdatedAt = clock.UtcNow;
bal.UpdatedBy = cu.UserId;
await db.SaveChangesAsync(ct); // commit opinion + status + (insert row nếu mới) trong tx
// STEP 2 — ATOMIC server-side increment (race-free; KHÔNG qua change tracker).
// bal tracked vẫn giữ UsedDays cũ sau dòng này — AN TOÀN vì không đọc lại bal,
// handler return ngay. KHÔNG thêm bal.UsedDays += ... (sẽ double-count).
await db.LeaveBalances
.Where(b => b.UserId == p.RequesterUserId && b.LeaveTypeId == p.LeaveTypeId && b.Year == year)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.UsedDays, b => b.UsedDays + p.NumDays), ct);
await tx.CommitAsync(ct);
return; // terminal branch xử lý trọn trong tx riêng — bỏ qua trailing SaveChanges
}
p.UpdatedAt = clock.UtcNow;
p.UpdatedBy = cu.UserId;

View File

@ -493,10 +493,10 @@ public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
public async Task Handle(AssignItTicketCommand req, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (t is null) throw new NotFoundException("ItTicket", req.Id);
// S54: authz — chỉ Admin HOẶC thành viên tổ IT mới reassign (controller đã hạ [Authorize] any-auth).
// S54 + S56 #5: authz check TRƯỚC khi tra ticket — fail-closed. Non-Admin/non-IT
// caller nhận ForbiddenException cho MỌI ticketId (tồn tại hay không), tránh
// existence-oracle leak (NotFound vs Forbidden tiết lộ ticket có thật).
var itDeptId = await db.Departments.AsNoTracking()
.Where(d => d.Code == "IT" && !d.IsDeleted)
.Select(d => (Guid?)d.Id)
@ -507,6 +507,9 @@ public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
if (!isAdmin && !(itDeptId is Guid mine && myDeptId == mine))
throw new ForbiddenException("Chỉ Admin hoặc nhân viên tổ IT mới được gán lại ticket.");
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (t is null) throw new NotFoundException("ItTicket", req.Id);
var assignee = await db.Users.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == req.AssignedToUserId && u.IsActive, ct);
if (assignee is null) throw new NotFoundException("User", req.AssignedToUserId);

View File

@ -27,17 +27,21 @@ public class DocxRenderer
using (var doc = WordprocessingDocument.Open(ms, isEditable: true))
{
var body = doc.MainDocumentPart?.Document.Body;
var mainPart = doc.MainDocumentPart
?? throw new InvalidOperationException("Template .docx không có MainDocumentPart");
var document = mainPart.Document
?? throw new InvalidOperationException("Template .docx không có Document");
var body = document.Body;
if (body is null) throw new InvalidOperationException("Template .docx không có Body");
// Xử lý cả main document + headers + footers
ReplaceInElement(body, data);
foreach (var hp in doc.MainDocumentPart!.HeaderParts)
foreach (var hp in mainPart.HeaderParts)
if (hp.Header is not null) ReplaceInElement(hp.Header, data);
foreach (var fp in doc.MainDocumentPart.FooterParts)
foreach (var fp in mainPart.FooterParts)
if (fp.Footer is not null) ReplaceInElement(fp.Footer, data);
doc.MainDocumentPart.Document.Save();
document.Save();
}
return new RenderResult(