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

这个提交包含在:
2026-08-29 08:22:23 +08:00
父节点 174cb7acde
当前提交 e8d676634f
共修改 24 个文件,包含 891 行新增和 62 行删除
+2 -4
查看文件
@@ -1079,8 +1079,7 @@ async function invoicePoolRoutes(fastify) {
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
if (!isOwn) {
throw new AppError({
code: "PERM_001",
message: "无权限",
@@ -1591,8 +1590,7 @@ async function invoicePoolRoutes(fastify) {
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
if (!isOwn) {
throw new AppError({
code: "PERM_001",
message: "无权限",
+46 -18
查看文件
@@ -100,6 +100,9 @@ const REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA = z
.object({ reason: z.string().trim().max(200).optional() })
.strict();
const REIMBURSEMENT_MATERIAL_PREVIEW_BODY_SCHEMA = REIMBURSEMENT_ATTACHMENT_PREVIEW_BODY_SCHEMA;
const REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA = z
.object({ projectId: z.string().uuid().nullable() })
.strict();
function resolveSignedFileOrigin(config, request) {
const configured = String(config.appOrigin ?? "")
@@ -583,16 +586,12 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
};
}
function ensureApplicantOrAdmin(request, reimbursement) {
if (request.employee?.roleCodes?.includes("admin")) {
return;
}
const isApplicant =
function ensureReimbursementParticipant(request, reimbursement) {
const isParticipant =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
if (!isApplicant) {
if (!isParticipant) {
throw new AppError({
code: "PERM_001",
message: "无权限",
@@ -680,6 +679,19 @@ function parseReimbursementUpdateBody(body) {
return parsed.data;
}
function parseReimbursementProjectLinkBody(body) {
const parsed = REIMBURSEMENT_PROJECT_LINK_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: parsed.error.flatten(),
});
}
return parsed.data;
}
function parseReimbursementAttachmentDeleteBody(body) {
const parsed = REIMBURSEMENT_ATTACHMENT_DELETE_BODY_SCHEMA.safeParse(body ?? {});
if (!parsed.success) {
@@ -2758,7 +2770,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
await ensureCutoverAllowed({
prisma: tx,
config: fastify.config,
@@ -2861,7 +2873,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (reimbursement.status !== "draft") {
throw new AppError({
@@ -2928,7 +2940,7 @@ async function reimbursementRoutes(fastify) {
});
}
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
@@ -3140,7 +3152,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
@@ -3617,7 +3629,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -3745,7 +3757,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -3866,7 +3878,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -3910,6 +3922,14 @@ async function reimbursementRoutes(fastify) {
},
async (request, reply) => {
const reimbursementId = parseReimbursementId(request.params?.id);
const reimbursement = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, owner_id: true, submitter_id: true },
});
if (!reimbursement) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, reimbursement);
const precheck = await resolveEntityPrecheck({
prisma: fastify.prisma,
config: fastify.config,
@@ -3948,7 +3968,7 @@ async function reimbursementRoutes(fastify) {
}
const reimbursement = reimbursementRows[0];
ensureApplicantOrAdmin(request, reimbursement);
ensureReimbursementParticipant(request, reimbursement);
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
@@ -4486,7 +4506,7 @@ async function reimbursementRoutes(fastify) {
fastify.post(
"/reimbursements/:id/link-project",
{
preHandler: fastify.authenticate,
preHandler: fastify.requirePermission("expense.edit_own"),
},
async (request, reply) => {
const gate = await evaluateFeatureFlag({
@@ -4500,7 +4520,7 @@ async function reimbursementRoutes(fastify) {
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason } });
}
const reimbursementId = parseReimbursementId(request.params?.id);
const body = parseBody(z.object({ projectId: z.string().uuid().nullable() }).strict(), request.body);
const body = parseReimbursementProjectLinkBody(request.body);
const current = await fastify.prisma.reimbursementForm.findUnique({
where: { id: reimbursementId },
select: { id: true, owner_id: true, submitter_id: true, project_id: true, status: true },
@@ -4508,7 +4528,15 @@ async function reimbursementRoutes(fastify) {
if (!current) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
ensureOwnOrCanViewAll(fastify, request, current);
ensureReimbursementParticipant(request, current);
if (!["draft", "rejected"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_project_link" },
});
}
const updated = await fastify.prisma.reimbursementForm.update({
where: { id: reimbursementId },
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
@@ -87,7 +87,7 @@ function buildPrisma({
return { prisma, state };
}
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] }) {
async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"], employeeId = "actor-1" }) {
const app = Fastify();
app.decorate("prisma", prisma);
@@ -101,7 +101,7 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] })
if (!request.headers.authorization) {
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = { id: "actor-1", permissionCodes };
request.employee = { id: employeeId, permissionCodes };
});
app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes("*") || (employee?.permissionCodes ?? []).includes(permissionCode));
app.decorate("requirePermission", (permissionCode) => async (request) => {
@@ -133,6 +133,84 @@ async function buildApp({ prisma, permissionCodes = ["travel.payment.manage"] })
}
describe("invoicePool travel-order bind routes", () => {
it("rejects a view-all nonparticipant before binding a reimbursement invoice", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({
invoice: {
id: "11111111-1111-4111-8111-111111111111",
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
travel_order_id: null,
total_amount: "1683.00",
},
reimbursement: {
id: reimbursementId,
owner_id: "actor-1",
submitter_id: "actor-1",
status: "draft",
reimbursement_date: new Date("2026-05-08T00:00:00.000Z"),
},
});
const app = await buildApp({
prisma,
employeeId: "finance-viewer",
permissionCodes: ["invoice.upload", "expense.view_all"],
});
const response = await app.inject({
method: "POST",
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/bind",
headers: { authorization: "Bearer test" },
payload: { reimbursementId },
});
expect(response.statusCode).toBe(403);
expect(state.invoice.status).toBe("available");
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
expect(prisma.reimbursementItem.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("rejects a view-all nonparticipant before unbinding a reimbursement invoice", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({
invoice: {
id: "11111111-1111-4111-8111-111111111111",
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: "item-1",
travel_order_id: null,
total_amount: "1683.00",
},
reimbursement: {
id: reimbursementId,
owner_id: "actor-1",
submitter_id: "actor-1",
status: "draft",
},
});
const app = await buildApp({
prisma,
employeeId: "finance-viewer",
permissionCodes: ["expense.view_all"],
});
const response = await app.inject({
method: "POST",
url: "/api/invoice-pool/11111111-1111-4111-8111-111111111111/unbind",
headers: { authorization: "Bearer test" },
payload: {},
});
expect(response.statusCode).toBe(403);
expect(state.invoice.status).toBe("bound");
expect(prisma.invoicePool.update).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("adds invoice to reimbursement and auto-creates an item when no detail matches", async () => {
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const { prisma, state } = buildPrisma({
@@ -44,7 +44,13 @@ function buildMultipartRequest({ files, fields = {} }) {
};
}
async function buildApp({ reimbursementStatus = "draft", materialPreviewStore = null } = {}) {
async function buildApp({
reimbursementStatus = "draft",
materialPreviewStore = null,
employeeId = ACTOR_ID,
roleCodes = ["employee"],
permissionCodes = ["expense.edit_own", "expense.view_own"],
} = {}) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r111-reimbursement-attachments-"));
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const createdAttachments = [];
@@ -160,9 +166,9 @@ async function buildApp({ reimbursementStatus = "draft", materialPreviewStore =
throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
}
request.employee = {
id: ACTOR_ID,
roleCodes: ["employee"],
permissionCodes: ["expense.edit_own", "expense.view_own"],
id: employeeId,
roleCodes,
permissionCodes,
};
});
app.decorate("hasPermission", (employee, permissionCode) => {
@@ -202,6 +208,38 @@ async function buildApp({ reimbursementStatus = "draft", materialPreviewStore =
}
describe("reimbursement attachment routes", () => {
it("rejects an admin view-all nonparticipant before attachment side effects", async () => {
const { app, prisma, tempDir, reimbursementId, createdAttachments } = await buildApp({
employeeId: "finance-viewer",
roleCodes: ["admin"],
permissionCodes: ["expense.edit_own", "expense.view_all"],
});
try {
const requestPayload = buildMultipartRequest({
files: [{
filename: "synthetic.pdf",
contentType: "application/pdf",
content: "%PDF synthetic",
}],
});
const response = await app.inject({
method: "POST",
url: `/api/reimbursements/${reimbursementId}/attachments`,
headers: { authorization: "Bearer test", ...requestPayload.headers },
payload: requestPayload.payload,
});
expect(response.statusCode).toBe(403);
expect(createdAttachments).toHaveLength(0);
expect(prisma.reimbursementAttachment.create).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("uploads five attachment materials with the production global one-file baseline", async () => {
const { app, prisma, tempDir, reimbursementId, createdAttachments } = await buildApp();
@@ -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();
});
});
@@ -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({
@@ -103,7 +103,7 @@ describe("paymentProcessingProgress", () => {
);
const reimbursementAssembler = reimbursementsSource.slice(
reimbursementsSource.indexOf("function buildProcessingProgress("),
reimbursementsSource.indexOf("function ensureApplicantOrAdmin("),
reimbursementsSource.indexOf("function ensureReimbursementParticipant("),
);
const paymentBatchAssembler = paymentBatchesSource.slice(
paymentBatchesSource.indexOf("function buildPaymentBatchProcessingProgress("),