修复非参与人报销只读边界(08-28-reimbursement-nonparticipant-readonly-projection)
这个提交包含在:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户