109 行
5.9 KiB
JavaScript
109 行
5.9 KiB
JavaScript
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();
|
|
});
|
|
});
|