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

这个提交包含在:
2026-08-29 08:22:23 +08:00
父节点 174cb7acde
当前提交 e8d676634f
共修改 24 个文件,包含 891 行新增和 62 行删除
@@ -177,7 +177,26 @@ async function buildApp({
paymentActors = [],
} = {}) {
const app = Fastify();
const tx = {
$queryRaw: vi.fn(async () => [reimbursement]),
reimbursementForm: {
update: vi.fn(),
delete: vi.fn(),
},
invoicePool: {
updateMany: vi.fn(),
},
reimbursementItem: {
create: vi.fn(),
update: vi.fn(),
deleteMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
};
const prisma = {
$transaction: vi.fn(async (callback) => callback(tx)),
reimbursementForm: {
findUnique: vi.fn(async ({ where }) => {
if (where.id !== REIMBURSEMENT_ID) {
@@ -185,6 +204,22 @@ async function buildApp({
}
return reimbursement;
}),
update: vi.fn(async ({ data }) => ({
id: REIMBURSEMENT_ID,
project_id: data.project_id ?? null,
project: data.project_id
? { id: data.project_id, project_no: "P-001", name: "合成项目" }
: null,
})),
},
featureFlag: {
findUnique: vi.fn(async () => ({
flag_key: "project_linking_v1",
override_mode: "inherit",
rollout_type: "global",
department_ids: [],
version: 1,
})),
},
workflowInstance: {
findFirst: vi.fn(async () => latestWorkflowInstance),
@@ -210,6 +245,7 @@ async function buildApp({
app.decorate("config", {
budgetControlMode: "warn",
appTimezone: "Asia/Shanghai",
projectLinkingEnabled: true,
});
app.decorate("authenticate", async (request) => {
if (!request.headers.authorization) {
@@ -255,7 +291,7 @@ async function buildApp({
});
await app.register(reimbursementRoutes, { prefix: "/api" });
return { app, prisma };
return { app, prisma, tx };
}
describe("GET /api/reimbursements/:id detail enhancements", () => {
@@ -886,3 +922,101 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
await app.close();
});
});
describe("POST /api/reimbursements/:id/link-project authorization", () => {
const projectId = "66666666-6666-4666-8666-666666666666";
it("rejects a view-all nonparticipant without changing the project", async () => {
const { app, prisma } = await buildApp({
employeeId: "finance-viewer",
permissionCodes: ["expense.view_all", "expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`,
headers: { authorization: "Bearer test" },
payload: { projectId },
});
expect(response.statusCode, response.body).toBe(403);
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
await app.close();
});
it.each(["approved", "paid"])("rejects participant project changes in %s status", async (status) => {
const { app, prisma } = await buildApp({
employeeId: OWNER_ID,
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`,
headers: { authorization: "Bearer test" },
payload: { projectId },
});
expect(response.statusCode, response.body).toBe(409);
expect(prisma.reimbursementForm.update).not.toHaveBeenCalled();
await app.close();
});
it("allows a draft participant to change the project", async () => {
const { app, prisma } = await buildApp({
employeeId: SUBMITTER_ID,
permissionCodes: ["expense.edit_own"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${REIMBURSEMENT_ID}/link-project`,
headers: { authorization: "Bearer test" },
payload: { projectId },
});
expect(response.statusCode, response.body).toBe(200);
expect(prisma.reimbursementForm.update).toHaveBeenCalledTimes(1);
await app.close();
});
});
describe("reimbursement maintenance authorization", () => {
const itemId = "77777777-7777-4777-8777-777777777777";
const expenseTypeId = "88888888-8888-4888-8888-888888888888";
it.each([
["PATCH", `/api/reimbursements/${REIMBURSEMENT_ID}`, { description: "合成修改" }, "expense.edit_own"],
["DELETE", `/api/reimbursements/${REIMBURSEMENT_ID}`, undefined, "expense.delete_own"],
["POST", `/api/reimbursements/${REIMBURSEMENT_ID}/items`, {
expenseTypeId,
expenseDate: "2026-04-10",
amount: "10.00",
description: "合成明细",
}, "expense.edit_own"],
["PATCH", `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, { amount: "20.00" }, "expense.edit_own"],
["DELETE", `/api/reimbursements/${REIMBURSEMENT_ID}/items/${itemId}`, undefined, "expense.edit_own"],
])("rejects a view-all nonparticipant for %s %s before mutations", async (method, url, payload, permission) => {
const { app, tx } = await buildApp({
employeeId: "finance-viewer",
permissionCodes: [permission, "expense.view_all"],
reimbursement: buildReimbursementRecord({ status: "draft" }),
});
const response = await app.inject({
method,
url,
headers: { authorization: "Bearer test" },
...(payload ? { payload } : {}),
});
expect(response.statusCode, response.body).toBe(403);
expect(tx.reimbursementForm.update).not.toHaveBeenCalled();
expect(tx.reimbursementForm.delete).not.toHaveBeenCalled();
expect(tx.invoicePool.updateMany).not.toHaveBeenCalled();
expect(tx.reimbursementItem.create).not.toHaveBeenCalled();
expect(tx.reimbursementItem.update).not.toHaveBeenCalled();
expect(tx.reimbursementItem.deleteMany).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
});