const Fastify = require("fastify"); const paymentAccountsRoutes = require("../../../src/routes/paymentAccounts"); const { encryptStringOrThrow } = require("../../../src/services/crypto"); const { AppError } = require("../../../src/utils/AppError"); const ACTOR_ID = "11111111-1111-4111-8111-111111111111"; const ACCOUNT_ID = "22222222-2222-4222-8222-222222222222"; const LEGAL_ENTITY_ID = "33333333-3333-4333-8333-333333333333"; const BANK_ID = "44444444-4444-4444-8444-444444444444"; const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); const SYNTHETIC_ACCOUNT = "6217000000000012345"; function buildRecord(overrides = {}) { return { id: ACCOUNT_ID, legal_entity_id: LEGAL_ENTITY_ID, bank_institution_id: BANK_ID, account_name: "合成测试公司", account_number_encrypted: encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, SYNTHETIC_ACCOUNT), enabled: true, archived_at: null, created_at: new Date("2026-08-02T00:00:00.000Z"), updated_at: new Date("2026-08-02T00:00:00.000Z"), legal_entity: { id: LEGAL_ENTITY_ID, code: "TEST", name: "合成测试公司" }, bank_institution: { id: BANK_ID, code: "CCB", name: "中国建设银行" }, ...overrides, }; } async function buildApp(permissionCodes) { const app = Fastify(); const actor = { id: ACTOR_ID, roleCodes: [], permissionCodes }; const record = buildRecord(); const auditCreate = vi.fn(async () => ({ id: "audit" })); const notificationCreateMany = vi.fn(async ({ data }) => ({ count: data.length })); const paymentAccountCreate = vi.fn(async ({ data }) => buildRecord({ ...data })); const tx = { legalEntity: { findFirst: vi.fn(async () => ({ id: LEGAL_ENTITY_ID, name: "合成测试公司" })) }, bankInstitution: { findFirst: vi.fn(async () => ({ id: BANK_ID, code: "CCB", name: "中国建设银行" })) }, paymentAccount: { create: paymentAccountCreate, update: vi.fn(), findFirst: vi.fn(async () => record), }, auditLog: { create: auditCreate }, employeeRole: { findMany: vi.fn(async () => [{ employee_id: ACTOR_ID }]) }, notification: { createMany: notificationCreateMany }, }; app.decorate("config", { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }); app.decorate("prisma", { bankInstitution: { findMany: vi.fn(async () => [{ id: BANK_ID, code: "CCB", name: "中国建设银行" }]) }, paymentAccount: { findMany: vi.fn(async () => [record]), findUnique: vi.fn(async () => ({ id: ACCOUNT_ID, account_number_encrypted: record.account_number_encrypted })), }, auditLog: { create: auditCreate }, $transaction: vi.fn(async (callback) => callback(tx)), }); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("hasPermission", (employee, permission) => employee.permissionCodes.includes("*") || employee.permissionCodes.includes(permission)); app.decorate("requirePermission", (permission) => async (request) => { request.employee = actor; if (!app.hasPermission(actor, permission)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); }); 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(paymentAccountsRoutes, { prefix: "/api" }); return { app, tx, auditCreate, notificationCreateMany, paymentAccountCreate }; } describe("payment account routes", () => { const validBody = { legalEntityId: LEGAL_ENTITY_ID, bankInstitutionId: BANK_ID, accountName: "合成测试公司", accountNumber: SYNTHETIC_ACCOUNT }; it.each([ ["account_input_invalid", "/payment-accounts", { ...validBody, accountNumber: "invalid" }, 400], ["bank_input_invalid", "/bank-institutions", { code: "!", name: "" }, 400], ["reveal_reason_required", `/payment-accounts/${ACCOUNT_ID}/reveal`, { reason: "" }, 400], ["id_invalid", "/payment-accounts/invalid/reveal", { reason: "核对资料" }, 400], ["legal_entity_unavailable", "/payment-accounts", validBody, 409], ["bank_unavailable", "/payment-accounts", validBody, 409], ["legal_entity_change_forbidden", `/payment-accounts/${ACCOUNT_ID}/replace`, { ...validBody, legalEntityId: ACTOR_ID }, 400], ["bank_code_exists", "/bank-institutions", { code: "CCB", name: "合成银行" }, 409], ["bank_in_use", `/bank-institutions/${BANK_ID}/disable`, {}, 409], ])("explains %s without successful mutation or sensitive output", async (reason, url, payload, status) => { const testApp = await buildApp(["*"]); try { if (reason === "legal_entity_unavailable") testApp.tx.legalEntity.findFirst.mockResolvedValue(null); if (reason === "bank_unavailable") testApp.tx.bankInstitution.findFirst.mockResolvedValue(null); if (reason === "bank_code_exists") testApp.tx.bankInstitution.create = vi.fn().mockRejectedValue({ code: "P2002" }); if (reason === "bank_in_use") { testApp.tx.bankInstitution.findUnique = vi.fn().mockResolvedValue({ id: BANK_ID }); testApp.tx.paymentAccount.count = vi.fn().mockResolvedValue(1); } const response = await testApp.app.inject({ method: "POST", url: `/api${url}`, payload }); expect(response.statusCode).toBe(status); expect(response.json().error.code).toBe("VALID_001"); expect(response.json().error.details.reasonCode).toBe(`payment_account.${reason}`); const { getErrorReason } = require("../../../src/constants/errorReasons"); const expected = require("../fixtures/error-reason-messages.json"); expect(getErrorReason(`payment_account.${reason}`).defaultMessage).toBe(expected[`payment_account.${reason}`]); expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_ACCOUNT); expect(testApp.paymentAccountCreate).not.toHaveBeenCalled(); expect(testApp.tx.paymentAccount.update).not.toHaveBeenCalled(); expect(testApp.auditCreate).not.toHaveBeenCalled(); expect(testApp.notificationCreateMany).not.toHaveBeenCalled(); } finally { await testApp.app.close(); } }); it("returns only a masked account on normal reads", async () => { const testApp = await buildApp(["payment.account.read"]); const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" }); expect(response.statusCode).toBe(200); expect(response.json().data.items[0].accountNumberMasked).toBe("6217***********2345"); expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_ACCOUNT); await testApp.app.close(); }); it("requires a reason before revealing the full account and audits the successful view", async () => { const testApp = await buildApp(["payment.account.read_full"]); const missingReason = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: {} }); expect(missingReason.statusCode).toBe(400); const response = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: { reason: "银行制单核对" } }); expect(response.statusCode).toBe(200); expect(response.json().data.accountNumber).toBe(SYNTHETIC_ACCOUNT); expect(testApp.auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "PAYMENT_ACCOUNT_SENSITIVE_VIEWED", extra: { reason: "银行制单核对" } }) }); await testApp.app.close(); }); it("creates an encrypted account with masked audit and notifies administrators", async () => { const testApp = await buildApp(["payment.account.manage"]); const response = await testApp.app.inject({ method: "POST", url: "/api/payment-accounts", payload: { legalEntityId: LEGAL_ENTITY_ID, bankInstitutionId: BANK_ID, accountName: "合成测试公司", accountNumber: SYNTHETIC_ACCOUNT } }); expect(response.statusCode).toBe(201); expect(testApp.paymentAccountCreate.mock.calls[0][0].data.account_number_encrypted).toMatch(/^v1:/); expect(JSON.stringify(testApp.auditCreate.mock.calls)).not.toContain(SYNTHETIC_ACCOUNT); expect(testApp.notificationCreateMany).toHaveBeenCalledOnce(); await testApp.app.close(); }); it("does not grant payment-account access to a system administrator by role implication", async () => { const testApp = await buildApp(["system.settings", "system.accounts.manage"]); const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" }); expect(response.statusCode).toBe(403); await testApp.app.close(); }); });