[CLAUDE] App: YC-016 guard file-khi-duyệt KHKK theo TAY CẦM PHIẾU (slot 67 đóng)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 6m17s

Anh chốt "(c) THEO TAY CẦM PHIẾU": Nháp/Trả-lại (tay người soạn) ⇒ chỉ
người soạn up file duyệt; Chờ-duyệt ⇒ chỉ thành viên quy trình đã pin
(membership, không đòi đúng-lượt — chữ anh @S171 không thu hẹp); terminal
DaDuyet/TuChoi ⇒ chặn cả hai (hồ sơ đã chốt).

Guard mới EnsureApprovalAttachmentUploaderAsync (WorkflowGuards) wire vào
UploadContractSigningPlanAttachmentCommandHandler — CHỈ rẽ nhánh
Purpose=ApprovalAttachment; đường căn-cứ dossier giữ nguyên hành vi cũ
(có control-test chặn nới guard ngoài phán quyết).

Đóng lỗ đo prod @S172: drafter qua policy KeHoachKyKet.Update up được
file vào phiếu không thuộc tay mình (#82 hai tầng — policy là quyền
module, không phải quyền theo tay). FE 0-đổi (nút vốn chỉ trong dialog
duyệt — display-layer; guard này là API-layer thật).

Test 660 → 668 (+8: 3 nhánh tay × 2 chủ thể + ngoài-cuộc + terminal-2-vế
+ control đường-soạn). YC-016 vào sổ; YC-013 nhắc=2 (YC-015 verify 4-chân).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-08-08 13:10:31 +07:00
parent 2a83e4410c
commit 72568f052e
4 changed files with 240 additions and 1 deletions

View File

@ -1448,4 +1448,186 @@ public class ContractSigningPlanApprovalTests
+ "là khoá cứng mọi phiếu đang pin bản đã nghỉ, đúng nhóm phiếu cần sửa nhất");
plan.ApprovalWorkflowId.Should().Be(banMoi, "và pin không được tự đổi trong lượt sửa GhiChu");
}
// ===================================================================
// [YC-016 @S183 — anh chốt "(c) THEO TAY CẦM PHIẾU"] File-khi-duyệt (Purpose=ApprovalAttachment)
// đi theo TAY cầm phiếu — 8 ca, mỗi ca 1 DbContext MỚI (khuôn (2) đầu file).
// Guard: `ContractSigningPlanWorkflowGuards.EnsureApprovalAttachmentUploaderAsync`.
// Control (ca 8): đường căn-cứ (DossierScan) KHÔNG bị rào mới đụng — hành vi cũ giữ nguyên.
// ===================================================================
private sealed class FakeStorage : SolutionErp.Application.Common.Interfaces.IFileStorage
{
public Task<string> SaveAsync(string relativePath, Stream content, CancellationToken ct = default)
=> Task.FromResult(relativePath);
public Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default)
=> Task.FromResult<Stream>(new MemoryStream());
public Task DeleteAsync(string relativePath, CancellationToken ct = default) => Task.CompletedTask;
public bool Exists(string relativePath) => true;
}
private static async Task<ContractSigningPlanAttachmentDto> UploadAsync(
KhkkWorkflowFixture f, TestCurrentUser actor, Guid planId,
ContractSigningPlanAttachmentPurpose purpose = ContractSigningPlanAttachmentPurpose.ApprovalAttachment)
{
await using var db = f.NewDb(actor);
var handler = new UploadContractSigningPlanAttachmentCommandHandler(db, new FakeStorage(), actor);
await using var content = new MemoryStream(new byte[] { 1, 2, 3, 4 });
return await handler.Handle(new UploadContractSigningPlanAttachmentCommand(
planId, DossierItemId: null, "bien-ban.pdf", "application/pdf", 4, content,
purpose, Note: "test"), CancellationToken.None);
}
private static async Task<int> CountAttachmentsAsync(KhkkWorkflowFixture f, Guid planId)
{
await using var db = f.NewDb(TestCurrentUser.System());
return await db.ContractSigningPlanAttachments
.CountAsync(a => a.ContractSigningPlanId == planId, CancellationToken.None);
}
[Fact]
public async Task UploadDuyet_Nhap_NguoiSoan_DuocUp()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
var approver = await AddUserAsync(f, "NV duyệt");
var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/701",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 100m)]);
var dto = await UploadAsync(f, drafter, planId);
dto.Purpose.Should().Be(ContractSigningPlanAttachmentPurpose.ApprovalAttachment);
(await CountAttachmentsAsync(f, planId)).Should().Be(1,
"Nháp = tay người soạn ⇒ người soạn up file duyệt được (YC-016 nhánh 1)");
}
[Fact]
public async Task UploadDuyet_TraLai_NguoiSoan_DuocUp()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
var approver = await AddUserAsync(f, "NV duyệt");
var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/702",
ContractSigningPlanPhase.TraLai, null, null, [new LineSeed(100m, 100m)]);
await UploadAsync(f, drafter, planId);
(await CountAttachmentsAsync(f, planId)).Should().Be(1,
"Trả lại = phiếu về tay người soạn ⇒ vẫn là tay người soạn");
}
[Fact]
public async Task UploadDuyet_ChoDuyet_NguoiSoan_409_DiaBatDong()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
var approver = await AddUserAsync(f, "NV duyệt");
var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/703",
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]);
var act = async () => await UploadAsync(f, drafter, planId);
(await act.Should().ThrowAsync<ConflictException>(
"phiếu đã rời tay người soạn — up tiếp là nhét tài liệu vào hồ sơ đang duyệt"))
.Which.Message.Should().Contain("chờ duyệt").And.Contain("người duyệt");
(await CountAttachmentsAsync(f, planId)).Should().Be(0, "409 thì đĩa phải bất động");
}
[Fact]
public async Task UploadDuyet_ChoDuyet_ThanhVienQuyTrinh_DuocUp()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
var approver1 = await AddUserAsync(f, "NV duyệt Bước 1");
var approver2 = await AddUserAsync(f, "NV duyệt Bước 2");
var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver1.Id]], [[approver2.Id]]]);
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/704",
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]);
// approver2 thuộc BƯỚC SAU (chưa tới lượt) — membership là đủ, KHÔNG đòi đúng-lượt
// (chữ anh @S171 không thu hẹp về acting-level; guard doc-comment khai rõ).
await UploadAsync(f, As(approver2, "DeptManager"), planId);
(await CountAttachmentsAsync(f, planId)).Should().Be(1,
"thành viên quy trình (kể cả bước sau) up được khi phiếu đang chờ duyệt");
}
[Fact]
public async Task UploadDuyet_Nhap_NguoiDuyet_409()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
var approver = await AddUserAsync(f, "NV duyệt");
var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/705",
ContractSigningPlanPhase.DangSoanThao, null, null, [new LineSeed(100m, 100m)]);
var act = async () => await UploadAsync(f, As(approver, "DeptManager"), planId);
(await act.Should().ThrowAsync<ConflictException>(
"Nháp = phiếu chưa trình — người duyệt chưa có việc gì với nó"))
.Which.Message.Should().Contain("tay người soạn");
}
[Fact]
public async Task UploadDuyet_ChoDuyet_NgoaiCuoc_409()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
var approver = await AddUserAsync(f, "NV duyệt");
var outsider = await AddUserAsync(f, "Người ngoài quy trình");
var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/706",
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]);
// outsider đã QUA policy module từ controller (ca đo prod @S172) — guard là rào CUỐI.
var act = async () => await UploadAsync(f, As(outsider, "DeptManager"), planId);
(await act.Should().ThrowAsync<ConflictException>(
"đây chính là ca đo prod @S172: qua policy nhưng KHÔNG thuộc tay nào"))
.Which.Message.Should().Contain("người duyệt trong quy trình");
}
[Fact]
public async Task UploadDuyet_Terminal_ChanCaHai()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
var approver = await AddUserAsync(f, "NV duyệt");
var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/707",
ContractSigningPlanPhase.DaDuyet, null, null, [new LineSeed(100m, 100m)]);
var actDrafter = async () => await UploadAsync(f, drafter, planId);
var actApprover = async () => await UploadAsync(f, As(approver, "DeptManager"), planId);
(await actDrafter.Should().ThrowAsync<ConflictException>())
.Which.Message.Should().Contain("đã kết thúc");
(await actApprover.Should().ThrowAsync<ConflictException>(
"terminal = không ai cầm phiếu — hồ sơ đã chốt, cả hai đều bị chặn"))
.Which.Message.Should().Contain("đã kết thúc");
(await CountAttachmentsAsync(f, planId)).Should().Be(0);
}
[Fact]
public async Task UploadCanCu_DuongSoan_KhongBiRaoMoiDung()
{
using var f = new KhkkWorkflowFixture();
var drafter = As(await AddUserAsync(f, "Người soạn"), "Drafter");
var approver = await AddUserAsync(f, "NV duyệt");
var wf = await SeedWorkflowAsync(f, "KHKK-N1", steps: [[[approver.Id]]]);
var planId = await SeedPlanAsync(f, drafter, wf, "KHKK/2026/708",
ContractSigningPlanPhase.ChoDuyet, 0, 1, [new LineSeed(100m, 100m)]);
// CONTROL: purpose = DossierScan (đường căn-cứ, KHÔNG phải file-khi-duyệt) — guard mới
// CHỈ rẽ nhánh ApprovalAttachment ⇒ hành vi cũ (cho qua) GIỮ NGUYÊN. Ca này chết nếu
// ai đó nới guard sang mọi purpose "cho chắc" — đó là đổi hành vi NGOÀI phán quyết YC-016.
await UploadAsync(f, drafter, planId, ContractSigningPlanAttachmentPurpose.DossierScan);
(await CountAttachmentsAsync(f, planId)).Should().Be(1,
"đường căn-cứ giữ nguyên hành vi cũ — YC-016 chỉ phán về file-khi-duyệt");
}
}