收紧报销草稿读取边界 [09-04-reimbursement-state-gap-finance-view][R2]

这个提交包含在:
2026-09-05 09:13:53 +08:00
父节点 1b120e0829
当前提交 8e0af8235e
共修改 14 个文件,包含 704 行新增和 120 行删除
@@ -208,6 +208,7 @@ async function buildApp(options = {}) {
id: reimbursement.id,
owner_id: reimbursement.owner_id,
submitter_id: reimbursement.submitter_id,
status: reimbursement.status,
form_number: reimbursement.form_number,
total_amount: reimbursement.total_amount,
owner: {
@@ -216,6 +217,9 @@ async function buildApp(options = {}) {
},
})),
},
workflowInstance: {
findFirst: vi.fn(async () => options.existingWorkflowInstance ?? null),
},
};
app.decorate("prisma", prisma);
@@ -718,7 +722,7 @@ describe("reimbursements routes expense standard integration", () => {
await app.close();
});
it("allows a view-all nonparticipant to read reimbursement precheck", async () => {
it("allows a view-all nonparticipant to read a submitted reimbursement precheck", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
actor: {
@@ -727,6 +731,59 @@ describe("reimbursements routes expense standard integration", () => {
roleCodes: ["finance"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
reimbursement: {
id: REIMBURSEMENT_ID,
owner_id: EMPLOYEE_ID,
submitter_id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
reimbursement_date: new Date("2026-04-08T00:00:00.000Z"),
status: "approved",
form_number: "BX-20260408-001",
total_amount: "180.00",
},
});
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("rejects a view-all nonparticipant precheck for a never-submitted draft", async () => {
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(403);
expect(response.json().error).toMatchObject({ code: "PERM_001", message: "无权限" });
expect(resolveEntityPrecheckMock).not.toHaveBeenCalled();
await app.close();
});
it("allows a view-all nonparticipant precheck for a returned draft", async () => {
resolveEntityPrecheckMock.mockResolvedValue(buildPrecheck());
const { app } = await buildApp({
actor: {
id: "finance-viewer",
department_id: DEPARTMENT_ID,
roleCodes: ["finance"],
permissionCodes: ["expense.submit", "expense.view_all"],
},
existingWorkflowInstance: { id: "returned-workflow" },
});
const response = await app.inject({
@@ -0,0 +1,107 @@
const {
buildReadableReimbursementWhere,
canReadReimbursementBusinessFact,
} = require("../../../src/services/reimbursementVisibility");
const fs = require("node:fs");
const path = require("node:path");
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_ID = "22222222-2222-4222-8222-222222222222";
const REIMBURSEMENT_ID = "33333333-3333-4333-8333-333333333333";
describe("reimbursement visibility", () => {
it.each([
["global finance", ["finance"], {}],
["global finance manager", ["finance_mgr"], {}],
["global ceo", ["ceo"], {}],
["global hr", ["hr"], {}],
["global admin", ["admin"], {}],
[
"manager team",
["manager"],
{
OR: [
{ owner_id: ACTOR_ID },
{ submitter_id: ACTOR_ID },
{ owner: { direct_manager_id: ACTOR_ID } },
],
},
],
])("keeps the existing %s scope while adding the business-fact filter", (_label, roleCodes, expectedScope) => {
expect(buildReadableReimbursementWhere({
employee: { id: ACTOR_ID, roleCodes },
scope: "all",
filters: { status: "draft" },
})).toEqual({
AND: [
expectedScope,
{
OR: [
{ status: { not: "draft" } },
{ owner_id: ACTOR_ID },
{ submitter_id: ACTOR_ID },
{ workflow_instances: { some: {} } },
],
},
{ status: "draft" },
],
});
});
it("keeps own scope independent from workflow history", () => {
expect(buildReadableReimbursementWhere({
employee: { id: ACTOR_ID, roleCodes: ["employee"] },
scope: "own",
filters: { status: "draft" },
})).toEqual({
AND: [
{ OR: [{ owner_id: ACTOR_ID }, { submitter_id: ACTOR_ID }] },
{ status: "draft" },
],
});
});
it("keeps every reimbursement read surface on the shared visibility contract", () => {
const routesDir = path.resolve(__dirname, "../../../src/routes");
const reimbursementsSource = fs.readFileSync(path.join(routesDir, "reimbursements.js"), "utf8");
const searchSource = fs.readFileSync(path.join(routesDir, "search.js"), "utf8");
const exportsSource = fs.readFileSync(path.join(routesDir, "exports.js"), "utf8");
const dashboardSource = fs.readFileSync(path.join(routesDir, "dashboard.js"), "utf8");
expect(reimbursementsSource).toContain("await ensureOwnOrCanViewAll(fastify, request, reimbursement)");
expect(reimbursementsSource.match(/await ensureOwnCanViewAllOrWorkflowReviewer\(fastify, request/g)).toHaveLength(4);
expect(reimbursementsSource).toContain("buildReadableReimbursementWhere({");
expect(reimbursementsSource).toContain("EXISTS (");
expect(searchSource).toContain("buildReadableReimbursementWhere({");
expect(exportsSource.match(/buildReadableReimbursementWhere\(\{/g)).toHaveLength(2);
expect(dashboardSource).toContain("buildReadableReimbursementWhere({");
});
it.each([
["owner draft", { owner_id: ACTOR_ID, submitter_id: OTHER_ID, status: "draft" }, null, true],
["submitter draft", { owner_id: OTHER_ID, submitter_id: ACTOR_ID, status: "draft" }, null, true],
["other approved", { owner_id: OTHER_ID, submitter_id: OTHER_ID, status: "approved" }, null, true],
["other returned draft", { owner_id: OTHER_ID, submitter_id: OTHER_ID, status: "draft" }, { id: "returned" }, true],
["other withdrawn draft", { owner_id: OTHER_ID, submitter_id: OTHER_ID, status: "draft" }, { id: "cancelled" }, true],
["other never-submitted draft", { owner_id: OTHER_ID, submitter_id: OTHER_ID, status: "draft" }, null, false],
])("classifies %s", async (_label, record, workflowInstance, expected) => {
const prisma = {
reimbursementForm: { findUnique: vi.fn() },
workflowInstance: { findFirst: vi.fn().mockResolvedValue(workflowInstance) },
};
await expect(canReadReimbursementBusinessFact(prisma, {
id: REIMBURSEMENT_ID,
...record,
}, ACTOR_ID)).resolves.toBe(expected);
if (record.status === "draft" && record.owner_id !== ACTOR_ID && record.submitter_id !== ACTOR_ID) {
expect(prisma.workflowInstance.findFirst).toHaveBeenCalledWith({
where: { entity_type: "reimbursement", entity_id: REIMBURSEMENT_ID },
select: { id: true },
});
} else {
expect(prisma.workflowInstance.findFirst).not.toHaveBeenCalled();
}
});
});