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(); } });