汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
217 行
8.8 KiB
JavaScript
217 行
8.8 KiB
JavaScript
const Fastify = require("fastify");
|
|
|
|
const usersRoutes = require("../../../src/routes/users");
|
|
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
|
|
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
|
|
const EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
|
|
const DEPARTMENT_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
|
|
async function buildApp(permissionCodes) {
|
|
const app = Fastify();
|
|
const encryptedBank = encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, "6222000012345678");
|
|
const encryptedIdentity = encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, "110101199001011234");
|
|
const actor = {
|
|
id: ACTOR_ID,
|
|
department_id: DEPARTMENT_ID,
|
|
roleCodes: [],
|
|
permissionCodes,
|
|
};
|
|
const employeeFindUnique = vi.fn().mockResolvedValue({
|
|
id: EMPLOYEE_ID,
|
|
bank_name: "合成银行",
|
|
bank_account_encrypted: encryptedBank,
|
|
id_number_encrypted: encryptedIdentity,
|
|
});
|
|
const employeeUpdate = vi.fn(async ({ data }) => ({ id: EMPLOYEE_ID, ...data }));
|
|
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
|
const bankInstitutionFindFirst = vi.fn().mockResolvedValue(null);
|
|
const transaction = vi.fn(async (callback) => callback({
|
|
bankInstitution: { findFirst: bankInstitutionFindFirst },
|
|
employee: {
|
|
findUnique: employeeFindUnique,
|
|
update: employeeUpdate,
|
|
},
|
|
auditLog: { create: auditCreate },
|
|
}));
|
|
|
|
app.decorate("config", { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
|
|
app.decorate("prisma", {
|
|
employee: { findUnique: employeeFindUnique },
|
|
auditLog: { create: auditCreate },
|
|
$transaction: transaction,
|
|
});
|
|
app.decorate("authenticate", async (request) => {
|
|
request.employee = actor;
|
|
});
|
|
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
|
request.employee = actor;
|
|
if (!permissionCodes.includes("*") && !permissionCodes.includes(permissionCode)) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
});
|
|
app.decorate("hasPermission", (employee, permissionCode) => {
|
|
const permissions = employee?.permissionCodes ?? [];
|
|
return permissions.includes("*") || permissions.includes(permissionCode);
|
|
});
|
|
app.decorate("assertPermission", (employee, permissionCode) => {
|
|
if (!app.hasPermission(employee, permissionCode)) {
|
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
}
|
|
return permissionCode;
|
|
});
|
|
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({
|
|
requestId: request.id,
|
|
error: {
|
|
code: error.code ?? "SYS_001",
|
|
message: error.message,
|
|
details: error.details ?? {},
|
|
},
|
|
}));
|
|
|
|
await app.register(usersRoutes, { prefix: "/api" });
|
|
return { app, auditCreate, employeeFindUnique, employeeUpdate, transaction, bankInstitutionFindFirst };
|
|
}
|
|
|
|
describe("user sensitive field permissions", () => {
|
|
it("rejects an OA system administrator before any sensitive employee query", async () => {
|
|
const testApp = await buildApp(["system.accounts.manage", "system.roles.manage", "system.settings"]);
|
|
|
|
const response = await testApp.app.inject({ method: "GET", url: `/api/users/${EMPLOYEE_ID}/sensitive` });
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
expect(testApp.employeeFindUnique).not.toHaveBeenCalled();
|
|
expect(testApp.auditCreate).not.toHaveBeenCalled();
|
|
await testApp.app.close();
|
|
});
|
|
|
|
it("returns only masked bank fields to finance without a full-account view audit", async () => {
|
|
const testApp = await buildApp(["employee.bank.read"]);
|
|
|
|
const response = await testApp.app.inject({ method: "GET", url: `/api/users/${EMPLOYEE_ID}/sensitive` });
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual({
|
|
bankInstitution: null,
|
|
bankName: "合成银行",
|
|
bankAccountMasked: "6222********5678",
|
|
});
|
|
expect(response.json().data).not.toHaveProperty("idNumber");
|
|
expect(testApp.employeeFindUnique).toHaveBeenCalledWith({
|
|
where: { id: EMPLOYEE_ID },
|
|
select: {
|
|
id: true,
|
|
bank_name: true,
|
|
bank_institution_id: true,
|
|
bank_account_encrypted: true,
|
|
bank_institution: { select: { id: true, code: true, name: true } },
|
|
},
|
|
});
|
|
expect(testApp.auditCreate).not.toHaveBeenCalled();
|
|
await testApp.app.close();
|
|
});
|
|
|
|
it("rejects finance writes before opening a transaction", async () => {
|
|
const testApp = await buildApp(["employee.bank.read"]);
|
|
|
|
const response = await testApp.app.inject({
|
|
method: "PATCH",
|
|
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
|
payload: { bankAccount: "6222000099995678" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
expect(testApp.transaction).not.toHaveBeenCalled();
|
|
expect(testApp.employeeUpdate).not.toHaveBeenCalled();
|
|
await testApp.app.close();
|
|
});
|
|
|
|
it("requires every requested sensitive-field permission before mutation", async () => {
|
|
const testApp = await buildApp(["employee.bank.manage"]);
|
|
|
|
const response = await testApp.app.inject({
|
|
method: "PATCH",
|
|
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
|
payload: { bankAccount: "6222000099995678", idNumber: "110101199001011234" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(403);
|
|
expect(testApp.transaction).not.toHaveBeenCalled();
|
|
expect(testApp.employeeUpdate).not.toHaveBeenCalled();
|
|
await testApp.app.close();
|
|
});
|
|
|
|
it("reveals a full bank account only after a reason and records the view audit", async () => {
|
|
const testApp = await buildApp(["employee.bank.read"]);
|
|
|
|
const missingReason = await testApp.app.inject({
|
|
method: "POST",
|
|
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
|
|
payload: {},
|
|
});
|
|
expect(missingReason.statusCode).toBe(400);
|
|
expect(testApp.auditCreate).not.toHaveBeenCalled();
|
|
|
|
const response = await testApp.app.inject({
|
|
method: "POST",
|
|
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
|
|
payload: { reason: "付款核对" },
|
|
});
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual({ bankAccount: "6222000012345678" });
|
|
expect(testApp.auditCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({
|
|
action: "SYSTEM_USER_BANK_ACCOUNT_REVEALED",
|
|
extra: { reason: "付款核对" },
|
|
}),
|
|
});
|
|
await testApp.app.close();
|
|
});
|
|
|
|
it("writes only authorized fields and keeps plaintext out of the audit", async () => {
|
|
const testApp = await buildApp(["employee.bank.manage", "employee.identity.manage"]);
|
|
|
|
const response = await testApp.app.inject({
|
|
method: "PATCH",
|
|
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
|
payload: { bankAccount: "6222000099995678", idNumber: "110101199901011234" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().data).toEqual({
|
|
id: EMPLOYEE_ID,
|
|
updatedFields: ["bankAccountMasked", "idNumberMasked"],
|
|
});
|
|
const auditArgument = testApp.auditCreate.mock.calls[0][0];
|
|
expect(auditArgument.data.extra).toEqual({
|
|
changes: {
|
|
bankAccountMasked: "6222********5678",
|
|
idNumberMasked: "110101********1234",
|
|
},
|
|
});
|
|
expect(JSON.stringify(auditArgument)).not.toContain("6222000099995678");
|
|
expect(JSON.stringify(auditArgument)).not.toContain("110101199901011234");
|
|
await testApp.app.close();
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["PATCH", "/sensitive", { bankAccount: "invalid" }, 400, "user.sensitive_input_invalid", null],
|
|
["PATCH", "/sensitive", {}, 400, "user.sensitive_update_empty", { body: "empty" }],
|
|
["POST", "/sensitive/bank/reveal", { reason: "x" }, 400, "user.bank_reveal_input_invalid", null],
|
|
["PATCH", "/sensitive", { bankInstitutionId: DEPARTMENT_ID }, 409, "user.bank_institution_unavailable", { bankInstitutionId: "not_available" }],
|
|
])("explains %s %s without sensitive writes or successful audit", async (method, suffix, payload, status, reasonCode, details) => {
|
|
const t = await buildApp(["*"]);
|
|
try {
|
|
const response = await t.app.inject({ method, url: `/api/users/${EMPLOYEE_ID}${suffix}`, payload });
|
|
expect(response.statusCode).toBe(status); expect(response.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode } });
|
|
if (details) expect(response.json().error.details).toEqual({ ...details, reasonCode });
|
|
else expect(response.json().error.details).toMatchObject({ formErrors: [], fieldErrors: expect.any(Object) });
|
|
expect(t.employeeUpdate).not.toHaveBeenCalled(); expect(t.auditCreate).not.toHaveBeenCalled();
|
|
if (status === 400) { expect(t.employeeFindUnique).not.toHaveBeenCalled(); expect(t.transaction).not.toHaveBeenCalled(); }
|
|
else expect(t.bankInstitutionFindFirst).toHaveBeenCalledTimes(1);
|
|
} finally { await t.app.close(); }
|
|
});
|