收紧报销草稿读取边界 [09-04-reimbursement-state-gap-finance-view][R2]
这个提交包含在:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户