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