收紧报销草稿读取边界 [09-04-reimbursement-state-gap-finance-view][R2]
这个提交包含在:
@@ -2,6 +2,7 @@ const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility");
|
||||
|
||||
const QUERY_SCHEMA = z.object({
|
||||
dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
@@ -40,14 +41,8 @@ function parseQuery(query) {
|
||||
};
|
||||
}
|
||||
|
||||
function canViewAll(request) {
|
||||
return request.employee.roleCodes.some((code) =>
|
||||
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
|
||||
);
|
||||
}
|
||||
|
||||
function buildReimbursementWhere(request, query) {
|
||||
const where = {
|
||||
const filters = {
|
||||
...(query.dateFrom || query.dateTo
|
||||
? {
|
||||
reimbursement_date: {
|
||||
@@ -59,18 +54,11 @@ function buildReimbursementWhere(request, query) {
|
||||
...(query.departmentId ? { department_id: query.departmentId } : {}),
|
||||
};
|
||||
|
||||
if (canViewAll(request)) {
|
||||
return where;
|
||||
}
|
||||
|
||||
return {
|
||||
...where,
|
||||
OR: [
|
||||
{ owner_id: request.employee.id },
|
||||
{ submitter_id: request.employee.id },
|
||||
{ owner: { direct_manager_id: request.employee.id } },
|
||||
],
|
||||
};
|
||||
return buildReadableReimbursementWhere({
|
||||
employee: request.employee,
|
||||
scope: "all",
|
||||
filters,
|
||||
});
|
||||
}
|
||||
|
||||
function moneyString(value) {
|
||||
|
||||
+11
-48
@@ -19,6 +19,7 @@ const {
|
||||
} = require("../services/employeeDirectoryFilters");
|
||||
const { BUDGET_RISK_CODES, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility");
|
||||
const {
|
||||
FINANCE_LEDGER_EXPORT_HEADERS,
|
||||
REIMBURSEMENT_LEDGER_INVOICE_STATUS_VALUES,
|
||||
@@ -189,32 +190,6 @@ function ensureHasAnyPermission(fastify, request, permissionCodes) {
|
||||
});
|
||||
}
|
||||
|
||||
function buildOwnReimbursementScopeWhere(employeeId) {
|
||||
return {
|
||||
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
|
||||
};
|
||||
}
|
||||
|
||||
function isGlobalViewRole(roleCodes) {
|
||||
return roleCodes.some((code) =>
|
||||
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
|
||||
);
|
||||
}
|
||||
|
||||
function buildViewAllScopeWhere(request) {
|
||||
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ owner_id: request.employee.id },
|
||||
{ submitter_id: request.employee.id },
|
||||
{ owner: { direct_manager_id: request.employee.id } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function toDateRange(query) {
|
||||
const dateFrom = query.dateFrom ? new Date(`${query.dateFrom}T00:00:00.000Z`) : null;
|
||||
const dateTo = query.dateTo ? new Date(`${query.dateTo}T23:59:59.999Z`) : null;
|
||||
@@ -244,22 +219,11 @@ function buildDashboardWhere(request, query) {
|
||||
|
||||
const departmentWhere = query.departmentId ? { department_id: query.departmentId } : {};
|
||||
|
||||
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
|
||||
return {
|
||||
...dateWhere,
|
||||
...departmentWhere,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...dateWhere,
|
||||
...departmentWhere,
|
||||
OR: [
|
||||
{ owner_id: request.employee.id },
|
||||
{ submitter_id: request.employee.id },
|
||||
{ owner: { direct_manager_id: request.employee.id } },
|
||||
],
|
||||
};
|
||||
return buildReadableReimbursementWhere({
|
||||
employee: request.employee,
|
||||
scope: "all",
|
||||
filters: { ...dateWhere, ...departmentWhere },
|
||||
});
|
||||
}
|
||||
|
||||
async function* streamDashboardReimbursementsCsv(prisma, where, limit) {
|
||||
@@ -1873,12 +1837,11 @@ async function exportsRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const where = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(scope === "all"
|
||||
? buildViewAllScopeWhere(request)
|
||||
: buildOwnReimbursementScopeWhere(request.employee.id)),
|
||||
};
|
||||
const where = buildReadableReimbursementWhere({
|
||||
employee: request.employee,
|
||||
scope,
|
||||
filters: query.status ? { status: query.status } : {},
|
||||
});
|
||||
|
||||
const format = resolveExportFormat(query);
|
||||
const limit = query.limit ?? 5000;
|
||||
|
||||
@@ -26,6 +26,11 @@ const { mapInvoiceDocumentMeta } = require("../services/invoiceDocumentMeta");
|
||||
const { dispatchNotificationBatch, flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { ensureCutoverAllowed } = require("../services/cutover");
|
||||
const {
|
||||
buildReadableReimbursementWhere,
|
||||
canReadReimbursementBusinessFact,
|
||||
isGlobalReimbursementViewRole,
|
||||
} = require("../services/reimbursementVisibility");
|
||||
const { resolveApplicableBudgetPoolByContext } = require("../services/budgetPool");
|
||||
const {
|
||||
buildPersistedRiskFlag,
|
||||
@@ -245,9 +250,12 @@ const ATTACHMENT_MIME_ALLOWLIST = new Set([
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
async function ensureOwnOrCanViewAll(fastify, request, reimbursement) {
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
if (canViewAll) {
|
||||
if (
|
||||
canViewAll &&
|
||||
await canReadReimbursementBusinessFact(fastify.prisma, reimbursement, request.employee.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -275,7 +283,11 @@ async function ensureOwnCanViewAllOrWorkflowReviewer(fastify, request, reimburse
|
||||
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
|
||||
const isOwn = reimbursement.owner_id === request.employee.id || reimbursement.submitter_id === request.employee.id;
|
||||
const canViewOwn = fastify.hasPermission(request.employee, "expense.view_own") && isOwn;
|
||||
if (canViewAll || canViewOwn) return;
|
||||
if (canViewOwn) return;
|
||||
if (
|
||||
canViewAll &&
|
||||
await canReadReimbursementBusinessFact(fastify.prisma, reimbursement, request.employee.id)
|
||||
) return;
|
||||
|
||||
const delegatedPrincipalIds = await listActiveDelegationPrincipals(fastify.prisma, {
|
||||
delegateEmployeeId: request.employee.id,
|
||||
@@ -852,7 +864,7 @@ function buildReimbursementStatsConditions({
|
||||
conditions.push(Prisma.sql`${sqlIdentifier(formAlias, "department_id")} = ${departmentId}`);
|
||||
}
|
||||
|
||||
if (!isGlobalViewRole(request.employee.roleCodes ?? [])) {
|
||||
if (!isGlobalReimbursementViewRole(request.employee.roleCodes ?? [])) {
|
||||
conditions.push(
|
||||
Prisma.sql`(
|
||||
${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id}
|
||||
@@ -862,6 +874,20 @@ function buildReimbursementStatsConditions({
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push(
|
||||
Prisma.sql`(
|
||||
${sqlIdentifier(formAlias, "status")}::text <> 'draft'
|
||||
OR ${sqlIdentifier(formAlias, "owner_id")} = ${request.employee.id}
|
||||
OR ${sqlIdentifier(formAlias, "submitter_id")} = ${request.employee.id}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM "workflow_instances" visibility_wi
|
||||
WHERE visibility_wi.entity_type = 'reimbursement'
|
||||
AND visibility_wi.entity_id = ${sqlIdentifier(formAlias, "id")}
|
||||
)
|
||||
)`,
|
||||
);
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
@@ -1030,32 +1056,6 @@ function parseReimbursementListQuery(query) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildOwnScopeWhere(employeeId) {
|
||||
return {
|
||||
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
|
||||
};
|
||||
}
|
||||
|
||||
function isGlobalViewRole(roleCodes) {
|
||||
return roleCodes.some((code) =>
|
||||
["admin", "ceo", "finance", "finance_mgr", "hr"].includes(code),
|
||||
);
|
||||
}
|
||||
|
||||
function buildViewAllScopeWhere(request) {
|
||||
if (isGlobalViewRole(request.employee.roleCodes ?? [])) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ owner_id: request.employee.id },
|
||||
{ submitter_id: request.employee.id },
|
||||
{ owner: { direct_manager_id: request.employee.id } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function mapReimbursementSummary(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -2042,12 +2042,11 @@ async function reimbursementRoutes(fastify) {
|
||||
delegateEmployeeId: request.employee.id,
|
||||
allowedScopes: ["all"],
|
||||
});
|
||||
const where = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(scope === "all"
|
||||
? buildViewAllScopeWhere(request)
|
||||
: buildOwnScopeWhere(request.employee.id)),
|
||||
};
|
||||
const where = buildReadableReimbursementWhere({
|
||||
employee: request.employee,
|
||||
scope,
|
||||
filters: query.status ? { status: query.status } : {},
|
||||
});
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.reimbursementForm.findMany({
|
||||
@@ -3335,6 +3334,7 @@ async function reimbursementRoutes(fastify) {
|
||||
select: {
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -3435,7 +3435,7 @@ async function reimbursementRoutes(fastify) {
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
download_policy: true,
|
||||
reimbursement_form: { select: { owner_id: true, submitter_id: true } },
|
||||
reimbursement_form: { select: { owner_id: true, submitter_id: true, status: true } },
|
||||
},
|
||||
});
|
||||
if (!attachment) {
|
||||
@@ -3508,6 +3508,7 @@ async function reimbursementRoutes(fastify) {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
submitter_id: true,
|
||||
status: true,
|
||||
invoice_pool_items: {
|
||||
orderBy: [{ created_at: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
@@ -4132,12 +4133,12 @@ async function reimbursementRoutes(fastify) {
|
||||
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 },
|
||||
select: { id: true, owner_id: true, submitter_id: true, status: true },
|
||||
});
|
||||
if (!reimbursement) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
ensureOwnOrCanViewAll(fastify, request, reimbursement);
|
||||
await ensureOwnOrCanViewAll(fastify, request, reimbursement);
|
||||
const precheck = await resolveEntityPrecheck({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
|
||||
+11
-13
@@ -3,6 +3,7 @@ const { z } = require("zod");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { buildReadableReimbursementWhere } = require("../services/reimbursementVisibility");
|
||||
|
||||
const QUERY_SCHEMA = z.object({
|
||||
q: z.string().trim().min(2).max(100),
|
||||
@@ -213,19 +214,16 @@ async function searchRoutes(fastify) {
|
||||
if (fastify.hasPermission(request.employee, "expense.view_own") || fastify.hasPermission(request.employee, "expense.view_all")) {
|
||||
tasks.push(
|
||||
fastify.prisma.reimbursementForm.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
fastify.hasPermission(request.employee, "expense.view_all") || isAdmin
|
||||
? {}
|
||||
: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] },
|
||||
{
|
||||
OR: [
|
||||
{ form_number: { contains: q, mode: "insensitive" } },
|
||||
{ description: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
where: buildReadableReimbursementWhere({
|
||||
employee: request.employee,
|
||||
scope: fastify.hasPermission(request.employee, "expense.view_all") || isAdmin ? "all" : "own",
|
||||
filters: {
|
||||
OR: [
|
||||
{ form_number: { contains: q, mode: "insensitive" } },
|
||||
{ description: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
take: limit,
|
||||
orderBy: { updated_at: "desc" },
|
||||
select: { id: true, form_number: true, description: true, status: true, updated_at: true },
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
const GLOBAL_REIMBURSEMENT_VIEW_ROLES = new Set([
|
||||
"admin",
|
||||
"ceo",
|
||||
"finance",
|
||||
"finance_mgr",
|
||||
"hr",
|
||||
]);
|
||||
|
||||
function isGlobalReimbursementViewRole(roleCodes = []) {
|
||||
return roleCodes.some((code) => GLOBAL_REIMBURSEMENT_VIEW_ROLES.has(code));
|
||||
}
|
||||
|
||||
function buildOwnReimbursementScopeWhere(employeeId) {
|
||||
return {
|
||||
OR: [{ owner_id: employeeId }, { submitter_id: employeeId }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildViewAllReimbursementScopeWhere(employee) {
|
||||
if (isGlobalReimbursementViewRole(employee.roleCodes ?? [])) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ owner_id: employee.id },
|
||||
{ submitter_id: employee.id },
|
||||
{ owner: { direct_manager_id: employee.id } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildReimbursementBusinessFactWhere(employeeId) {
|
||||
return {
|
||||
OR: [
|
||||
{ status: { not: "draft" } },
|
||||
{ owner_id: employeeId },
|
||||
{ submitter_id: employeeId },
|
||||
{ workflow_instances: { some: {} } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildReadableReimbursementWhere({ employee, scope = "all", filters = {} }) {
|
||||
const scopeWhere = scope === "own"
|
||||
? buildOwnReimbursementScopeWhere(employee.id)
|
||||
: buildViewAllReimbursementScopeWhere(employee);
|
||||
|
||||
if (scope === "own") {
|
||||
return { AND: [scopeWhere, filters] };
|
||||
}
|
||||
|
||||
return {
|
||||
AND: [scopeWhere, buildReimbursementBusinessFactWhere(employee.id), filters],
|
||||
};
|
||||
}
|
||||
|
||||
async function canReadReimbursementBusinessFact(prisma, reimbursement, employeeId) {
|
||||
if (reimbursement.owner_id === employeeId || reimbursement.submitter_id === employeeId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let status = reimbursement.status;
|
||||
if (!status) {
|
||||
const current = await prisma.reimbursementForm.findUnique({
|
||||
where: { id: reimbursement.id },
|
||||
select: { status: true },
|
||||
});
|
||||
status = current?.status ?? null;
|
||||
}
|
||||
if (status !== "draft") {
|
||||
return Boolean(status);
|
||||
}
|
||||
|
||||
const workflowInstance = await prisma.workflowInstance.findFirst({
|
||||
where: {
|
||||
entity_type: "reimbursement",
|
||||
entity_id: reimbursement.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return Boolean(workflowInstance);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildOwnReimbursementScopeWhere,
|
||||
buildReadableReimbursementWhere,
|
||||
buildReimbursementBusinessFactWhere,
|
||||
buildViewAllReimbursementScopeWhere,
|
||||
canReadReimbursementBusinessFact,
|
||||
isGlobalReimbursementViewRole,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户