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, auditCreate, notificationCreateMany, paymentAccountCreate }; } describe("payment account routes", () => { 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(); }); });