修复非参与人报销只读边界(08-28-reimbursement-nonparticipant-readonly-projection)

这个提交包含在:
2026-08-29 08:22:23 +08:00
父节点 174cb7acde
当前提交 e8d676634f
共修改 24 个文件,包含 891 行新增和 62 行删除
@@ -53,7 +53,7 @@ async function buildApp(options = {}) {
id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
roleCodes: [],
permissionCodes: ["expense.submit"],
permissionCodes: ["expense.submit", "expense.view_own"],
};
const reimbursement = options.reimbursement ?? {
id: REIMBURSEMENT_ID,
@@ -176,6 +176,8 @@ async function buildApp(options = {}) {
reimbursementForm: {
findUnique: vi.fn(async () => ({
id: reimbursement.id,
owner_id: reimbursement.owner_id,
submitter_id: reimbursement.submitter_id,
form_number: reimbursement.form_number,
total_amount: reimbursement.total_amount,
owner: {
@@ -239,6 +241,29 @@ describe("reimbursements routes expense standard integration", () => {
resolveRecipientsForAssigneeMock.mockResolvedValue([]);
});
it("rejects an admin view-all nonparticipant before creating submission side effects", async () => {
const { app, prisma, tx } = await buildApp({
actor: {
id: "finance-viewer",
department_id: DEPARTMENT_ID,
roleCodes: ["admin"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit`,
});
expect(response.statusCode).toBe(403);
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
expect(tx.workflowTask.create).not.toHaveBeenCalled();
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(prisma.reimbursementForm.findUnique).not.toHaveBeenCalled();
await app.close();
});
it("blocks travel reimbursement submit until the structured summary is complete", async () => {
const { app, tx } = await buildApp({ travelItem: true });
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
@@ -347,6 +372,47 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("rejects an ordinary nonparticipant before resolving reimbursement precheck", async () => {
const { app } = await buildApp({
actor: {
id: "ordinary-nonparticipant",
department_id: DEPARTMENT_ID,
roleCodes: ["employee"],
permissionCodes: ["expense.submit"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`,
});
expect(response.statusCode).toBe(403);
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
await app.close();
});
it("allows a view-all nonparticipant to read reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
actor: {
id: "finance-viewer",
department_id: DEPARTMENT_ID,
roleCodes: ["finance"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/precheck`,
});
expect(response.statusCode).toBe(200);
expect(resolveEntityPrecheckMock).toHaveBeenCalledTimes(1);
await app.close();
});
it("blocks submit with 400 and standardViolations when block standard is exceeded", async () => {
resolveEntityPrecheckMock.mockResolvedValue(
buildPrecheck({