借款出借人改为从现有员工选择并复用资料(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号
- 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE
- 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效
- 电脑/手机共用对话框,写请求在卸载或身份切换时取消
- 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-09 21:03:07 +08:00
共同撰写人 Claude Opus 5.5
父节点 c1034bbe38
当前提交 45737ccb9a
共修改 20 个文件,包含 1157 行新增和 28 行删除
+3 -1
查看文件
@@ -6,7 +6,7 @@ const root = path.resolve(__dirname, '../..');
const req = createRequire(path.join(root, 'client/package.json'));
const { chromium, expect } = req('@playwright/test');
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi, config, relatedParty }) {
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi, config, relatedParty, employeeSources }) {
const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-'));
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
@@ -39,6 +39,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
await page.getByRole('button', { name: '登记出借人', exact: true }).click();
let dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: '登记非员工出借人', exact: true }).click();
await dialog.getByLabel('公司主体').selectOption(company.id);
const lenderName = `合成浏览器出借人-${mode}`;
await dialog.getByLabel('出借人姓名').fill(lenderName);
@@ -301,6 +302,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
browser, origin, output, finance, manager, password, prisma, fixtures: reconciliationUi,
}));
if (relatedParty) checks.push(...await require('./company-loan-related-browser.cjs')({ browser, origin, output, finance, manager, ordinary, password, prisma, config, relatedParty }));
if (employeeSources) checks.push(...await require('./company-loan-employee-browser.cjs')({ browser, origin, output, finance, password, prisma, config, employeeSources }));
const context = await browser.newContext({ serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
@@ -0,0 +1,250 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const { mkdtempSync, mkdirSync } = require('node:fs');
const { createRequire } = require('node:module');
const root = path.resolve(__dirname, '../..');
const clientRequire = createRequire(path.join(root, 'client/package.json'));
const serverRequire = createRequire(path.join(root, 'server/package.json'));
const { chromium, expect } = clientRequire('@playwright/test');
module.exports = async function employeeBrowser(h) {
const { finance, password, prisma, config, employeeSources } = h;
const own = !h.browser;
const browser = h.browser ?? await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
const origin = h.origin ?? await h.app.listen({ host: '127.0.0.1', port: 0 });
const base = path.join(root, 'artifacts/qa-flow/loan-employee-source-20261009'); mkdirSync(base, { recursive: true });
const output = h.output ?? mkdtempSync(path.join(base, 'browser-'));
const oldOrigin = config.appOrigin; config.appOrigin = origin;
const checks = [];
const { encryptStringOrThrow } = serverRequire('./src/services/crypto');
try {
for (const s of employeeSources.ui) {
const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : '';
const contexts = [];
async function login(who) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
contexts.push(context); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${prefix}/company-loans`)}`);
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
return page;
}
async function submit(page, label = '确认', expected = 200) {
const pending = page.waitForResponse(r => r.url().startsWith(`${origin}/api/`) && !r.url().includes('/auth/') && r.request().method() === 'POST');
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click();
const response = await pending;
assert.equal(response.status(), expected, `employee ${s.mode} ${label}: ${response.status()}`);
if (expected === 200) await expect(page.getByRole('dialog')).not.toBeVisible();
return response;
}
async function submitAfterSameActor401(page, label, endpoint) {
let attempts = 0;
const handler = async route => {
attempts++;
if (attempts === 1) return route.fulfill({ status: 401, contentType: 'application/json',
body: JSON.stringify({ requestId: 'synthetic-expired-access', error: { code: 'AUTH_001', message: '合成过期令牌' } }) });
return route.continue();
};
await page.route(`**${endpoint}`, handler);
try {
const final = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST' && r.status() === 200);
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click();
const response = await final;
assert.equal(attempts, 2, 'same-actor expiry keeps the normal single refresh retry');
await expect(page.getByRole('dialog')).not.toBeVisible();
return response;
} finally { await page.unroute(`**${endpoint}`, handler); }
}
async function findCard(page, card) {
for (let i = 0; i < 20; i++) {
await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
if (await card.count()) return;
const next = page.getByRole('button', { name: '下一页', exact: true }); await expect(next).toBeEnabled();
const label = await page.getByText(/^第 \d+ 页$/).first().innerText();
await next.click(); await expect(page.getByText(label, { exact: true })).toHaveCount(0);
}
throw new Error('synthetic employee record not found within ledger pages');
}
try {
const page = await login(finance);
async function selectEmployee(who = s.staff, query = `浏览器-${s.mode}`) {
await page.getByRole('button', { name: '登记出借人', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: '从员工选择出借人' })).toBeVisible();
await dialog.getByLabel('检索员工', { exact: true }).fill(query);
await dialog.getByLabel('已有员工', { exact: true }).selectOption(who.id);
await expect(dialog.getByLabel('借款所属公司', { exact: true })).toHaveValue(s.company.id);
await expect(dialog.getByLabel('员工资料摘要')).toBeVisible();
await expect(dialog.getByRole('textbox', { name: '收款账号', exact: true })).toHaveCount(0);
await expect(dialog.getByRole('textbox', { name: '出借人姓名', exact: true })).toHaveCount(0);
return dialog;
}
let dialog = await selectEmployee();
assert.ok(!(await dialog.innerText()).includes('2'.repeat(16)), 'full employee account is never rendered');
await expect(dialog.getByRole('button', { name: '确认关联员工' })).toBeDisabled();
await page.screenshot({ path: path.join(output, `employee-${s.mode}-source.png`), fullPage: true });
const noOverflow = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
assert.ok(noOverflow, `${s.mode} employee selection has no horizontal overflow`);
// The page's old preview must fail after a real source-row update.
await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)) } });
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
const stale = await submit(page, '确认关联员工', 409);
assert.equal((await stale.json()).error.details.reasonCode, 'loan.employee_source_changed');
await expect(dialog.getByRole('alert')).toContainText('员工资料已变化');
assert.equal(await prisma.loanLender.count({ where: { employee_id: s.staff.id } }), 0);
await dialog.getByRole('button', { name: '重新加载员工资料' }).click();
const checked = dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' });
await expect(checked).not.toBeChecked(); await expect(checked).toBeEnabled(); await checked.check();
const linked = await submitAfterSameActor401(page, '确认关联员工', '/api/loan-lenders/from-employee');
const sent = linked.request().postDataJSON();
assert.deepEqual(Object.keys(sent).sort(), ['detailsConfirmed', 'employeeId', 'expectedSourceVersion', 'legalEntityId', 'requestKey'].sort());
let lender = (await linked.json()).data.lender;
assert.equal(lender.employeeId, s.staff.id); assert.equal(lender.identityStatus, 'verified');
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } })).created_by, finance.id);
dialog = await selectEmployee();
await expect(dialog).toContainText('将使用已有记录');
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
assert.equal((await (await submit(page, '使用已有出借人')).json()).data.lender.id, lender.id);
assert.equal(await prisma.loanLender.count({ where: { employee_id: s.staff.id, legal_entity_id: s.company.id } }), 1);
// Missing data points back to personnel maintenance, with no replacement
// account field in either the new-link or refresh dialog.
await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: '' } });
dialog = await selectEmployee();
await expect(dialog.getByRole('alert').filter({ hasText: '员工收款账号未维护' })).toBeVisible();
await expect(dialog.getByRole('button', { name: '确认关联员工', exact: true })).toBeDisabled();
await expect(dialog.getByRole('textbox', { name: '收款账号', exact: true })).toHaveCount(0);
await dialog.press('Escape');
await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '4'.repeat(16)) } });
const lenderCard = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${s.company.name}`, exact: true }) });
await findCard(page, lenderCard);
await lenderCard.getByRole('button', { name: '使用当前员工资料', exact: true }).click();
dialog = page.getByRole('dialog');
await expect(dialog).toContainText('原公司代表授权须重新登记确认');
await expect(dialog.getByLabel('员工资料摘要')).toContainText('4444');
await dialog.getByLabel('更新依据', { exact: true }).fill('合成核对员工档案中的新收款资料');
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
lender = (await (await submitAfterSameActor401(page, '确认更新出借人资料', `/api/loan-lenders/${lender.id}/employee-refresh`)).json()).data;
assert.equal(lender.accountVersion, 2); assert.equal(lender.identityVersion, 2);
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } })).updated_by, finance.id);
await findCard(page, lenderCard);
checks.push(`${s.mode}_employee_selection_masked_stale_refusal_reuse_missing_and_explicit_refresh`);
await page.getByRole('button', { name: '登记借入', exact: true }).click();
dialog = page.getByRole('dialog'); await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id);
const reference = `合成员工历史借入-${s.mode}`;
await dialog.getByLabel('原借入凭据唯一引用').fill(reference);
await dialog.getByLabel('原始本金', { exact: true }).fill('200.00');
await dialog.getByLabel('核对日未还本金').fill('200.00');
await dialog.getByLabel('实际到账日期').fill('2025-05-01');
await dialog.getByLabel('余额核对截止日').fill('2026-01-01');
await submit(page);
const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference, lender_id: lender.id } });
await page.getByRole('tab', { name: '借款台账', exact: true }).click();
const loanCard = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${loan.loan_no}`, exact: true }) });
await findCard(page, loanCard); await loanCard.locator('summary').click();
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) {
const result = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST');
await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n') });
assert.equal((await result).status(), 200);
}
await loanCard.getByRole('button', { name: '确认期初余额', exact: true }).click();
await page.getByRole('dialog').getByLabel('代账余额确认引用').fill('合成会计往来余额核对记录'); await submit(page);
await expect(loanCard).toContainText('余额已确认');
await page.getByRole('tab', { name: '公司代表授权', exact: true }).click();
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
dialog = page.getByRole('dialog');
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached();
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${lender.id}"]`).count()); i++) {
assert.ok(i < 20);
const response = page.waitForResponse(r => r.url().includes('/loan-lenders?page='));
await dialog.getByRole('button', { name: '下一页出借人', exact: true }).click(); await response;
}
await dialog.getByLabel('授权出借人').selectOption(lender.id);
await dialog.getByRole('checkbox', { name: new RegExp(loan.loan_no) }).check();
await dialog.getByLabel('代表权及授权范围依据').fill('合成财务登记已有公司代表权及此笔历史借款范围'); await submit(page);
await expect(page.getByRole('button', { name: '代表公司确认授权范围', exact: true })).toHaveCount(0);
const managerPage = await login(s.staff);
await expect(managerPage.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0);
await managerPage.getByRole('button', { name: '代表公司确认授权范围', exact: true }).click();
await expect(managerPage.getByRole('dialog')).toContainText(loan.loan_no);
await managerPage.getByRole('dialog').getByLabel('确认或停止的理由').fill('合成公司代表核对本人收款身份与授权范围'); await submit(managerPage);
const auth = await prisma.loanRelatedAuthorization.findFirstOrThrow({ where: { lender_id: lender.id }, include: { events: true } });
assert.equal(auth.approver_id, s.staff.id); assert.equal(auth.proposed_by, finance.id);
assert.ok(auth.events.some(event => event.action === 'confirm' && event.actor_id === s.staff.id));
assert.equal(await prisma.loanRepayment.count({ where: { lender_id: lender.id } }), 0, 'source and authority setup never makes a repayment');
await managerPage.screenshot({ path: path.join(output, `employee-${s.mode}-authorization.png`), fullPage: true });
checks.push(`${s.mode}_employee_history_balance_finance_authority_and_manager_confirmation`);
// Keep the same document alive across real logout/login, so document
// teardown cannot accidentally provide the request cancellation guard.
await page.evaluate(() => { window.__syntheticEmployeeDocument = 'same-document'; });
async function navigateWithinDocument(to) {
await page.evaluate(url => { window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate')); }, to);
}
async function switchActor(who) {
await navigateWithinDocument('/mobile/me');
await page.getByRole('button', { name: '退出登录', exact: true }).click();
await expect(page.getByTestId('login-submit')).toBeVisible();
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click(); await expect(page.getByTestId('login-submit')).toHaveCount(0);
await navigateWithinDocument(`${prefix}/company-loans`);
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
assert.equal(await page.evaluate(() => window.__syntheticEmployeeDocument), 'same-document');
}
for (const action of ['link', 'refresh']) {
if (action === 'refresh') await switchActor(finance);
else await navigateWithinDocument(`${prefix}/company-loans`);
if (action === 'link') dialog = await selectEmployee(s.switchStaff, `浏览器切换-${s.mode}`);
else {
await page.getByRole('tab', { name: '出借人', exact: true }).click(); await findCard(page, lenderCard);
await lenderCard.getByRole('button', { name: '使用当前员工资料', exact: true }).click(); dialog = page.getByRole('dialog');
await dialog.getByLabel('更新依据', { exact: true }).fill('合成旧会话操作不得转交新登录财务');
}
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
const endpoint = action === 'link' ? '/api/loan-lenders/from-employee' : `/api/loan-lenders/${lender.id}/employee-refresh`;
const before = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
const auditBefore = await prisma.auditLog.count({ where: { target_id: lender.id, action: 'LOAN_LENDER_EMPLOYEE_REFRESHED' } });
let release; const gate = new Promise(resolve => { release = resolve; });
let started; const held = new Promise(resolve => { started = resolve; });
let handled; const finished = new Promise(resolve => { handled = resolve; });
let attempts = 0; let firstRequest; let aborted = false;
const onFailed = request => { if (request === firstRequest) aborted = /aborted/i.test(request.failure()?.errorText ?? ''); };
const handler = async route => {
attempts++;
if (attempts > 1) return route.continue();
firstRequest = route.request(); started(); await gate;
try {
await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late-401',
error: { code: 'AUTH_001', message: '合成旧会话迟到响应' } }) });
} finally { handled(); }
};
page.on('requestfailed', onFailed); await page.route(`**${endpoint}`, handler);
try {
await dialog.getByRole('button', { name: action === 'link' ? '确认关联员工' : '确认更新出借人资料', exact: true }).click();
await held; await switchActor(employeeSources.otherFinance);
release(); await finished;
await expect.poll(() => aborted, { message: 'leaving the source form must abort its old write request' }).toBe(true);
assert.equal(attempts, 1, 'a late 401 must never replay the old write as the new actor');
assert.equal(await prisma.loanLender.count({ where: { employee_id: s.switchStaff.id } }), 0);
const after = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(after.identity_version, before.identity_version); assert.equal(after.account_version, before.account_version);
assert.equal(after.updated_by, before.updated_by);
assert.equal(await prisma.auditLog.count({ where: { target_id: lender.id, action: 'LOAN_LENDER_EMPLOYEE_REFRESHED' } }), auditBefore);
checks.push(`${s.mode}_${action}_late_401_aborted_across_real_actor_switch`);
} finally { release(); page.off('requestfailed', onFailed); await page.unroute(`**${endpoint}`, handler); }
}
} catch (error) {
for (const [index, context] of contexts.entries()) {
const page = context.pages()[0];
if (page) await page.screenshot({ path: path.join(output, `employee-${s.mode}-failure-${index}.png`), fullPage: true, timeout: 5000 }).catch(() => {});
}
throw error;
} finally { for (const context of contexts) await context.close(); }
}
} finally { config.appOrigin = oldOrigin; if (own) await browser.close(); }
console.log(JSON.stringify({ stage: 'loan_employee_browser', status: 'PASS', checks, output: path.relative(root, output) }));
return checks;
};
@@ -0,0 +1,328 @@
const assert = require('node:assert/strict');
const { randomUUID, createHash } = require('node:crypto');
module.exports = async function employeeFixture(h) {
const { prisma, pool, call, app, req, finance, manager, ordinary, config, actor, company, bank,
confirmedLoan, repay, exportOrder, receipt } = h;
const { encryptStringOrThrow, decryptStringOrThrow } = req('./src/services/crypto');
const checks = [];
const sourceUrl = id => `/loan-lenders/employee-source/${id}?legalEntityId=${company.id}`;
const source = who => call(finance, 'GET', sourceUrl(who.id));
async function employee(label, roles = ['employee']) {
const who = await actor(roles);
await prisma.employee.update({ where: { id: who.id }, data: { name: `合成员工-${label}`,
bank_institution_id: bank.id, bank_name: '合成建行员工收款资料',
bank_account_encrypted: encryptStringOrThrow(config, '2'.repeat(16)) } });
return who;
}
const input = (who, preview, key = randomUUID()) => ({ employeeId: who.id, legalEntityId: company.id,
expectedSourceVersion: preview.sourceVersion, detailsConfirmed: true, requestKey: key });
const link = (body, status = 200, reason) => call(finance, 'POST', '/loan-lenders/from-employee', body, status, reason);
const refreshInput = preview => ({ expectedSourceVersion: preview.sourceVersion, detailsConfirmed: true,
expectedIdentityVersion: preview.existingLender.identityVersion, expectedAccountVersion: preview.existingLender.accountVersion,
reason: '合成根据员工档案重新核对姓名及收款资料' });
const refresh = (id, body, status = 200, reason) => call(finance, 'POST', `/loan-lenders/${id}/employee-refresh`, body, status, reason);
const staff = await employee('资料复用', ['manager']);
const employeeCount = await prisma.employee.count();
const preview = await source(staff);
assert.equal(preview.linkState, 'new'); assert.deepEqual(preview.problems, []);
assert.ok(!JSON.stringify(preview).includes('2'.repeat(16)), 'preview must contain only a masked account');
assert.ok(!JSON.stringify(preview).includes('encrypted'), 'preview never exposes encrypted source columns');
const body = input(staff, preview);
const created = await link(body); const lender = created.lender;
assert.equal(created.reused, false); assert.equal(lender.employeeId, staff.id); assert.equal(lender.identityStatus, 'verified');
const persisted = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(persisted.name, preview.employee.name);
assert.ok(decryptStringOrThrow(config, persisted.bank_account_encrypted) === '2'.repeat(16), 'account comes from employee');
assert.ok(decryptStringOrThrow(config, persisted.account_name_encrypted) === preview.employee.name, 'account holder comes from employee name');
assert.equal((await link(body)).lender.id, lender.id);
assert.equal((await link(input(staff, await source(staff)))).reused, true);
assert.equal(await prisma.employee.count(), employeeCount, 'linking never creates an employee');
assert.equal(await prisma.loanLender.count({ where: { employee_id: staff.id, legal_entity_id: company.id } }), 1);
const reuseKey = randomUUID();
await link(input(staff, await source(staff), reuseKey));
const keyTarget = await employee('复用请求键冲突');
await link(input(keyTarget, await source(keyTarget), reuseKey), 409, 'loan.duplicate_registration');
assert.equal(await prisma.loanLender.count({ where: { employee_id: keyTarget.id } }), 0);
assert.equal((await link(input(staff, await source(staff), reuseKey))).lender.id, lender.id);
checks.push('employee_source_masked_server_copy_verified_link_and_reuse');
for (const who of [manager, ordinary, null]) {
await call(who, 'GET', sourceUrl(staff.id), undefined, who ? 403 : 401);
await call(who, 'POST', '/loan-lenders/from-employee', body, who ? 403 : 401);
await call(who, 'POST', `/loan-lenders/${lender.id}/employee-refresh`, refreshInput(await source(staff)), who ? 403 : 401);
}
// An otherwise qualified finance user still needs employee bank permission.
const hasPermission = app.hasPermission;
app.hasPermission = (person, code) => code === 'employee.bank.read' || code === 'employee.bank.manage' ? false : hasPermission(person, code);
try {
await call(finance, 'GET', sourceUrl(staff.id), undefined, 403);
await call(finance, 'POST', '/loan-lenders/from-employee', body, 403);
await call(finance, 'POST', `/loan-lenders/${lender.id}/employee-refresh`, {}, 403);
} finally { app.hasPermission = hasPermission; }
for (const addition of [{ name: '伪造姓名' }, { bankAccount: '9'.repeat(16) }, { accountName: '伪造账户名' }, { detailsConfirmed: false }]) {
await link({ ...body, ...addition }, 400, 'loan.invalid_input');
}
await link({ ...body, legalEntityId: randomUUID() }, 409, 'loan.source_unavailable');
const otherCompany = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成另一个借款公司' } });
const explicitCompanySource = await call(finance, 'GET', `/loan-lenders/employee-source/${staff.id}?legalEntityId=${otherCompany.id}`);
assert.equal(explicitCompanySource.employee.legalEntityId, company.id);
const otherCompanyLender = await link({ ...input(staff, explicitCompanySource), legalEntityId: otherCompany.id });
assert.equal(otherCompanyLender.lender.legalEntityId, otherCompany.id, 'employee employer must not replace the explicitly chosen debtor');
checks.push('employee_source_role_bank_permission_and_strict_input_guards');
const incomplete = await employee('资料缺项');
const normal = await prisma.employee.findUniqueOrThrow({ where: { id: incomplete.id } });
const unsupported = await prisma.bankInstitution.create({ data: { code: `SYN-${randomUUID().slice(0, 8)}`, name: '合成其他银行' } });
for (const [patch, problem] of [
[{ name: '' }, 'name_invalid'], [{ bank_name: '' }, 'bank_missing'],
[{ bank_institution_id: null }, 'bank_missing'], [{ bank_institution_id: unsupported.id }, 'bank_unsupported'],
[{ bank_account_encrypted: '' }, 'account_missing'], [{ bank_account_encrypted: 'synthetic-unreadable' }, 'account_unavailable'],
[{ bank_account_encrypted: encryptStringOrThrow({ fieldEncryptionKey: Buffer.alloc(32, 7).toString('base64') }, '3'.repeat(16)) }, 'account_unavailable'],
[{ bank_account_encrypted: encryptStringOrThrow(config, 'invalid-account') }, 'account_invalid'],
]) {
await prisma.employee.update({ where: { id: incomplete.id }, data: { name: normal.name, bank_name: normal.bank_name,
bank_institution_id: normal.bank_institution_id, bank_account_encrypted: normal.bank_account_encrypted, ...patch } });
const invalid = await source(incomplete); assert.ok(invalid.problems.includes(problem), `expected ${problem}; observed ${invalid.problems.join(',')}`);
await link(input(incomplete, invalid), 409, 'loan.employee_source_incomplete');
}
await prisma.employee.update({ where: { id: incomplete.id }, data: { status: 'archived' } });
await call(finance, 'GET', sourceUrl(incomplete.id), undefined, 409, 'loan.source_unavailable');
assert.equal(await prisma.loanLender.count({ where: { employee_id: incomplete.id } }), 0);
checks.push('employee_missing_unreadable_unsupported_and_inactive_sources_rejected');
const stale = await employee('预览变更');
const old = await source(stale);
const row = await prisma.employee.findUniqueOrThrow({ where: { id: stale.id } });
// Holding updated_at constant proves the version binds actual fields too.
await prisma.employee.update({ where: { id: stale.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)), updated_at: row.updated_at } });
await link(input(stale, old), 409, 'loan.employee_source_changed');
assert.equal(await prisma.loanLender.count({ where: { employee_id: stale.id } }), 0);
const fresh = await source(stale);
const freshLender = (await link(input(stale, fresh))).lender;
await link({ ...input(stale, fresh), requestKey: body.requestKey }, 409, 'loan.duplicate_registration');
checks.push('employee_source_version_covers_fields_and_request_key_conflicts');
async function manualFor(who) {
const e = await prisma.employee.findUniqueOrThrow({ where: { id: who.id } });
return call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name: e.name,
bankInstitutionId: bank.id, bankName: e.bank_name, bankAccount: decryptStringOrThrow(config, e.bank_account_encrypted), accountName: e.name });
}
const other = await manualFor(staff);
await call(finance, 'POST', `/loan-lenders/${other.id}/identity-confirmation`, { expectedVersion: other.identityVersion,
employeeId: staff.id, externalPersonConfirmed: false, reason: '合成旧入口重复关联拒绝' }, 409, 'loan.employee_lender_conflict');
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: other.id } })).employee_id, null);
// Model an old ambiguous dataset directly; this is not an application path.
await prisma.loanLender.update({ where: { id: other.id }, data: { employee_id: staff.id } });
assert.equal((await source(staff)).linkState, 'conflict');
await link(input(staff, await source(staff)), 409, 'loan.employee_lender_conflict');
await refresh(lender.id, refreshInput({ ...preview, existingLender: lender }), 409, 'loan.employee_lender_conflict');
await prisma.loanLender.update({ where: { id: other.id }, data: { employee_id: null } });
await prisma.loanLender.update({ where: { id: freshLender.id }, data: { archived_at: new Date() } });
assert.equal((await source(stale)).linkState, 'conflict');
await link(input(stale, await source(stale)), 409, 'loan.employee_lender_conflict');
checks.push('legacy_identity_entry_and_ambiguous_or_archived_links_cannot_duplicate');
const { Pool } = req('pg');
// Pause the real refresh immediately before its audit INSERT checks actor A's
// FK. It already holds employee B; B's concurrent link locks A then waits on B.
// An UPDATE lock on A would deadlock with the audit's implicit KEY SHARE.
const [financeA, financeB] = (await Promise.all([
employee('外键交错甲', ['finance']), employee('外键交错乙', ['finance']),
])).sort((a, b) => a.id.localeCompare(b.id));
const fkLender = (await call(financeA, 'POST', '/loan-lenders/from-employee', input(financeB, await source(financeB)))).lender;
const fkRefresh = refreshInput(await source(financeB));
const fkLink = input(financeA, await source(financeA));
const fkLocks = new Pool({ ...pool.options, max: 2 });
const fkHolder = await fkLocks.connect(); const fkResponses = [];
const deadlocksBefore = Number((await pool.query('SELECT deadlocks FROM pg_stat_database WHERE datname=current_database()')).rows[0].deadlocks);
await pool.query(`CREATE FUNCTION synthetic_pause_employee_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.target_id = TG_ARGV[0]::uuid THEN PERFORM pg_advisory_xact_lock(293174183); END IF; RETURN NEW; END $$`);
await pool.query(`CREATE TRIGGER synthetic_employee_fk_pause BEFORE INSERT ON audit_logs FOR EACH ROW
WHEN (NEW.action = 'LOAN_LENDER_EMPLOYEE_REFRESHED') EXECUTE FUNCTION synthetic_pause_employee_audit('${fkLender.id}')`);
let fkResults; let fkLinkResponse;
try {
await fkHolder.query('SELECT pg_advisory_lock(293174183)');
fkResponses.push(app.inject({ method: 'POST', url: `/api/loan-lenders/${fkLender.id}/employee-refresh`,
headers: financeA.headers, payload: fkRefresh }).then(r => r));
async function observeFkWait(count) {
for (let i = 0; i < 200; i++) {
const rows = await fkLocks.query(`SELECT count(*)::int AS n, count(*) FILTER (WHERE wait_event='advisory')::int AS advisory
FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'`);
if (rows.rows[0].n >= count && rows.rows[0].advisory === 1) return;
await new Promise(resolve => setTimeout(resolve, 10));
}
if (count === 2 && fkLinkResponse?.statusCode === 200) {
throw Object.assign(new Error('source association finished without waiting for the audit-held employee'), { code: 'EMPLOYEE_SOURCE_LOCK_NOT_HELD' });
}
throw new Error('synthetic audit/employee interleaving did not reach both database lock waits');
}
await observeFkWait(1);
fkResponses.push(app.inject({ method: 'POST', url: '/api/loan-lenders/from-employee',
headers: financeB.headers, payload: fkLink }).then(r => { fkLinkResponse = r; return r; }));
await observeFkWait(2);
await fkHolder.query('SELECT pg_advisory_unlock(293174183)');
fkResults = await Promise.all(fkResponses);
} finally {
await fkHolder.query('SELECT pg_advisory_unlock(293174183)'); fkHolder.release();
await Promise.allSettled(fkResponses); await fkLocks.end();
await pool.query('DROP TRIGGER synthetic_employee_fk_pause ON audit_logs');
await pool.query('DROP FUNCTION synthetic_pause_employee_audit()');
}
if (fkResults.some(r => r.statusCode !== 200)) {
// Classify the reverse mutation only with PostgreSQL's actual deadlock
// counter, never a generic 500, timeout or fixture/environment failure.
for (let i = 0; i < 200; i++) {
const count = Number((await pool.query('SELECT deadlocks FROM pg_stat_database WHERE datname=current_database()')).rows[0].deadlocks);
if (count > deadlocksBefore) throw Object.assign(new Error('observed employee source/audit FK deadlock'), { code: 'EMPLOYEE_SOURCE_FK_DEADLOCK' });
await new Promise(resolve => setTimeout(resolve, 10));
}
}
assert.deepEqual(fkResults.map(r => r.statusCode), [200, 200], 'refresh audit and concurrent source association must both finish');
assert.equal(fkResults[0].json().data.identityVersion, fkLender.identityVersion + 1);
assert.equal(fkResults[1].json().data.lender.employeeId, financeA.id);
assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_EMPLOYEE_REFRESHED', target_id: fkLender.id } }), 1);
checks.push('employee_source_locks_allow_implicit_audit_fk_without_deadlock');
const locks = new Pool({ ...pool.options, max: 2 });
async function waiters(count) {
for (let i = 0; i < 200; i++) {
const result = await locks.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'");
if (result.rows[0].n >= count) return;
await new Promise(resolve => setTimeout(resolve, 10));
}
throw new Error('synthetic employee operation did not actually wait for the row lock');
}
async function interleave(who, starts) {
const holder = await locks.connect(); const responses = [];
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM employees WHERE id=$1 FOR UPDATE', [who.id]);
for (const start of starts) { responses.push(start()); await waiters(responses.length); }
await holder.query('COMMIT'); return await Promise.all(responses);
} finally { await holder.query('ROLLBACK'); holder.release(); await Promise.allSettled(responses); }
}
const startLink = body => () => app.inject({ method: 'POST', url: '/api/loan-lenders/from-employee', headers: finance.headers, payload: body }).then(r => r);
try {
const simultaneous = await employee('并发新建'); const p = await source(simultaneous);
const replies = await interleave(simultaneous, [startLink(input(simultaneous, p)), startLink(input(simultaneous, p))]);
assert.deepEqual(replies.map(r => r.statusCode), [200, 200]);
assert.equal(replies[0].json().data.lender.id, replies[1].json().data.lender.id);
assert.equal(await prisma.loanLender.count({ where: { employee_id: simultaneous.id, legal_entity_id: company.id } }), 1);
const keyed = await employee('并发复用请求键'); const concurrentKey = randomUUID();
const keyedReplies = await interleave(finance, [startLink(input(staff, await source(staff), concurrentKey)), startLink(input(keyed, await source(keyed), concurrentKey))]);
assert.deepEqual(keyedReplies.map(r => r.statusCode), [200, 409]);
assert.equal(keyedReplies[1].json().error?.details.reasonCode, 'loan.duplicate_registration');
assert.equal(await prisma.loanLender.count({ where: { employee_id: keyed.id } }), 0);
for (const legacyFirst of [false, true]) {
const who = await employee(`并发旧入口-${legacyFirst}`); const p = await source(who); const manual = await manualFor(who);
const legacy = () => app.inject({ method: 'POST', url: `/api/loan-lenders/${manual.id}/identity-confirmation`, headers: finance.headers,
payload: { expectedVersion: manual.identityVersion, employeeId: who.id, externalPersonConfirmed: false, reason: '合成并发身份关联' } }).then(r => r);
const replies = await interleave(who, legacyFirst ? [legacy, startLink(input(who, p))] : [startLink(input(who, p)), legacy]);
assert.deepEqual(replies.map(r => r.statusCode), legacyFirst ? [200, 200] : [200, 409]);
if (!legacyFirst) assert.equal(replies[1].json().error?.details.reasonCode, 'loan.employee_lender_conflict');
assert.equal(await prisma.loanLender.count({ where: { employee_id: who.id, legal_entity_id: company.id } }), 1);
}
} finally { await locks.end(); }
checks.push('employee_creation_and_legacy_entry_observed_lock_interleavings');
const loan = await confirmedLoan(lender.id, '100.00', `合成员工原借入-${randomUUID()}`);
async function authorize() {
const auth = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: lender.id, loanIds: [loan.id],
requestKey: randomUUID(), basis: '合成已存在的公司代表权及此笔借款范围' });
await call(staff, 'POST', `/loan-related-authorizations/${auth.id}/actions`, { action: 'confirm', reason: '合成公司代表本人确认授权范围' });
return auth;
}
const auth = await authorize(); const batch = await repay(lender.id, loan.id, '10.00');
assert.equal(batch.loanRepayment.approvalMode, 'related_party');
await call(staff, 'POST', `/payment-batches/${batch.id}/review`, {});
await exportOrder(batch.id);
async function snapshots() {
const repayment = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: batch.loanRepaymentId },
select: { lender_identity_version: true, lender_account_version: true, payee_snapshot_json: true, approval_context: true } });
const savedBatch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id },
select: { payment_account_snapshot_json: true, items: { select: { snapshot_json: true } } } });
return createHash('sha256').update(JSON.stringify({ repayment, savedBatch })).digest('hex');
}
const originalSnapshots = await snapshots();
const beforeChange = await source(staff);
await prisma.employee.update({ where: { id: staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)) } });
assert.equal(await snapshots(), originalSnapshots);
const changed = await source(staff); assert.equal(changed.linkState, 'refresh_required'); assert.equal(changed.existingLender.hasInFlightRepayment, true);
await link(input(staff, changed), 409, 'loan.employee_lender_refresh_required');
await refresh(lender.id, refreshInput(changed), 409, 'loan.lender_in_flight');
const proof = await receipt(batch.id, '1000', `SYN-EMPLOYEE-${randomUUID()}`, { payeeAccount: '2'.repeat(16) });
assert.equal(proof.result.status, 'matched');
assert.equal((await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {})).item.status, 'executed');
await refresh(lender.id, refreshInput(beforeChange), 409, 'loan.employee_source_changed');
const updated = await refresh(lender.id, refreshInput(await source(staff)));
assert.equal(updated.accountVersion, lender.accountVersion + 1); assert.equal(updated.identityVersion, lender.identityVersion + 1);
assert.equal(updated.identityStatus, 'verified'); assert.equal(updated.employeeId, staff.id);
assert.equal(await snapshots(), originalSnapshots, 'explicit source refresh never rewrites historical payment snapshots');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '90.00');
await refresh(lender.id, refreshInput(changed), 409, 'loan.version_changed');
await call(finance, 'PATCH', `/loan-lenders/${lender.id}/account`, { expectedVersion: updated.accountVersion,
bankInstitutionId: bank.id, bankName: '伪造覆盖', bankAccount: '9'.repeat(16), accountName: '伪造覆盖' }, 409, 'loan.employee_account_managed');
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: lender.id, reason: '合成旧授权拒绝',
allocations: [{ loanId: loan.id, amount: '1.00' }] }, 409, 'loan.related_scope_invalid');
assert.equal((await prisma.loanRelatedAuthorization.findUniqueOrThrow({ where: { id: auth.id } })).identity_version, lender.identityVersion);
await authorize(); const renewed = await repay(lender.id, loan.id, '1.00');
await call(finance, 'POST', `/payment-batches/${renewed.id}/cancel`, { reason: '合成刷新后新授权验证结束' });
checks.push('employee_refresh_inflight_guard_old_payment_snapshot_settlement_and_authority_versions');
const raceLocks = new Pool({ ...pool.options, max: 2 });
try {
for (const first of ['create', 'refresh']) {
await authorize();
const current = await source(staff); const holder = await raceLocks.connect(); const responses = [];
let results;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [lender.id]);
const starts = {
create: () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: {
requestKey: randomUUID(), lenderId: lender.id, reason: '合成员工刷新并发还款', allocations: [{ loanId: loan.id, amount: '1.00' }] } }).then(r => r),
refresh: () => app.inject({ method: 'POST', url: `/api/loan-lenders/${lender.id}/employee-refresh`, headers: finance.headers,
payload: refreshInput(current) }).then(r => r),
};
for (const action of [first, first === 'create' ? 'refresh' : 'create']) {
responses.push(starts[action]());
let waiting = false;
for (let i = 0; i < 200; i++) {
const count = await raceLocks.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'");
if (count.rows[0].n >= responses.length) { waiting = true; break; }
await new Promise(resolve => setTimeout(resolve, 10));
}
assert.ok(waiting, 'both source refresh and repayment creation must actually wait on the lender lock');
}
await holder.query('COMMIT'); results = await Promise.all(responses);
} finally { await holder.query('ROLLBACK'); holder.release(); await Promise.allSettled(responses); }
assert.deepEqual(results.map(r => r.statusCode), [200, 409], `${first} wins and the other action refuses stale or reserved data`);
assert.equal(results[1].json().error?.details.reasonCode, first === 'create' ? 'loan.lender_in_flight' : 'loan.related_scope_invalid');
const currentLender = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(await prisma.loanRepayment.count({ where: { lender_id: lender.id, allocations: { some: { reservation_state: 'reserved' } },
OR: [{ lender_identity_version: { not: currentLender.identity_version } }, { lender_account_version: { not: currentLender.account_version } }] } }), 0);
if (first === 'create') await call(finance, 'POST', `/payment-batches/${results[0].json().data.item.id}/cancel`, { reason: '合成员工刷新交错验证结束' });
}
} finally { await raceLocks.end(); }
checks.push('employee_refresh_and_repayment_creation_both_observed_lender_lock_orders');
const rollbackStaff = await employee('审计回滚'); const rollbackSource = await source(rollbackStaff);
await pool.query(`CREATE FUNCTION synthetic_fail_employee_lender_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action IN ('LOAN_LENDER_EMPLOYEE_CREATED','LOAN_LENDER_EMPLOYEE_REFRESHED') THEN RAISE EXCEPTION 'synthetic audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_employee_lender_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_employee_lender_audit()');
try {
await link(input(rollbackStaff, rollbackSource), 500);
assert.equal(await prisma.loanLender.count({ where: { employee_id: rollbackStaff.id } }), 0);
const state = await source(staff); await refresh(lender.id, refreshInput(state), 500);
const retained = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(retained.identity_version, state.existingLender.identityVersion); assert.equal(retained.account_version, state.existingLender.accountVersion);
} finally {
await pool.query('DROP TRIGGER synthetic_employee_lender_audit ON audit_logs');
await pool.query('DROP FUNCTION synthetic_fail_employee_lender_audit()');
}
checks.push('employee_source_creation_and_refresh_audit_failure_roll_back');
const ui = [];
for (const mode of ['desktop', 'mobile']) ui.push({ mode, staff: await employee(`浏览器-${mode}`, ['manager']),
switchStaff: await employee(`浏览器切换-${mode}`), company });
console.log(JSON.stringify({ stage: 'loan_employee_http', status: 'PASS', checks }));
return { ui, checks, otherFinance: financeB };
};
@@ -0,0 +1,49 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const path = require('node:path');
const Module = require('node:module');
// Mutate only the isolated fixture process. Repository files and the production
// module cache remain unchanged; all route exports are restored after each case.
module.exports = async function employeeMutations(h, runFixture) {
const filename = h.req.resolve('./src/services/loanLenderEmployee');
const source = readFileSync(filename, 'utf8');
const live = h.req('./src/services/loanLenderEmployee'); const original = { ...live };
const mutations = [
['bank_permission', 'if (!fastify.hasPermission(request.employee, "employee.bank.read") &&\n !fastify.hasPermission(request.employee, "employee.bank.manage"))', 'if (false)'],
['source_version', 'if (source.sourceVersion !== expectedSourceVersion)', 'if (false)'],
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)'],
['employee_lock', 'if (unique.length) await tx.$queryRaw', 'if (false) await tx.$queryRaw'],
['employee_lock_fk_compatibility', 'ORDER BY id FOR NO KEY UPDATE', 'ORDER BY id FOR UPDATE'],
['in_flight', 'await assertLenderNotInFlight(tx, id);', ''],
['lender_versions', 'assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion);\n assertCurrentVersion(lender.account_version, input.expectedAccountVersion);', ''],
['reuse_request_binding', 'if (!previousRequest) await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_REUSED", lender.id, {\n ...requestBinding, employeeSourceRequestKey: input.requestKey,\n });', ''],
['required_audit', 'await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_CREATED", created.id, {\n ...requestBinding, employeeSourceRequestKey: input.requestKey,\n });', ''],
];
const results = [];
for (const [name, from, to] of mutations) {
assert.equal(source.split(from).length, 2, `mutation ${name} must have exactly one target`);
const mutant = new Module(filename, module); mutant.filename = filename;
mutant.paths = Module._nodeModulePaths(path.dirname(filename));
mutant._compile(source.replace(from, to), filename);
let failure;
Object.assign(live, mutant.exports);
try { await runFixture(h); } catch (error) { failure = error; }
finally { Object.assign(live, original); }
assert.ok(failure, `mutation ${name} survived`);
// A generic environment, SQL or parsing failure is not a killed guard.
const wrongAcceptance = failure.code === 'ERR_ASSERTION' && failure.actual === 200 && [403, 409, 500].includes(failure.expected);
const duplicateIdentity = name === 'employee_lock' && failure.code === 'ERR_ASSERTION' &&
typeof failure.actual === 'string' && typeof failure.expected === 'string' &&
/^[0-9a-f-]{36}$/.test(failure.actual) && /^[0-9a-f-]{36}$/.test(failure.expected) && failure.actual !== failure.expected;
const missingLock = name === 'employee_lock' && (failure.message === 'synthetic employee operation did not actually wait for the row lock' ||
failure.code === 'EMPLOYEE_SOURCE_LOCK_NOT_HELD');
const fkDeadlock = name === 'employee_lock_fk_compatibility' && failure.code === 'EMPLOYEE_SOURCE_FK_DEADLOCK';
const hint = failure.code === 'ERR_ASSERTION' ? String(failure.message).split('\n')[0] : failure.name;
assert.ok(wrongAcceptance || duplicateIdentity || missingLock || fkDeadlock, `mutation ${name} failed outside its guard: ${hint} ${failure.code ?? ''}`);
results.push({ name, caught: true, witness: wrongAcceptance ? 'unsafe_http_acceptance' : duplicateIdentity ? 'duplicate_employee_lender' : fkDeadlock ? 'observed_postgres_deadlock' : 'missing_row_lock' });
console.log(JSON.stringify({ stage: 'loan_employee_mutation', ...results.at(-1) }));
}
console.log(JSON.stringify({ stage: 'loan_employee_mutations', status: 'PASS', count: results.length, sourceFilesChanged: false }));
return results;
};
+46 -13
查看文件
@@ -19,9 +19,18 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
paymentBatchEnabled: true,
};
app.decorate('config', config); app.decorate('prisma', prisma);
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({
code: error.code ?? 'SYS_001', details: error.details ?? {},
}));
const { AppError } = req('./src/utils/AppError');
app.setErrorHandler((error, request, reply) => {
const known = error instanceof AppError;
const tooLarge = error.code === 'FST_REQ_FILE_TOO_LARGE';
// Match the production envelope, so browser refusals exercise real reason
// projection instead of silently falling back to a generic server failure.
return reply.code(tooLarge ? 400 : known ? error.statusCode : 500).send({ requestId: request.id, error: {
code: tooLarge ? 'VALID_001' : known ? error.code : 'SYS_001',
message: tooLarge ? '参数校验失败' : known ? error.message : '服务器内部错误',
details: tooLarge ? { file: 'file_too_large' } : known ? error.details ?? {} : {},
} });
});
await app.register(req('@fastify/cookie'));
await app.register(req('@fastify/multipart'));
await app.register(req('./src/plugins/auth'));
@@ -64,8 +73,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
const ordinary = await actor(['employee']);
async function call(who, method, url, payload, status = 200, reason) {
const response = await app.inject({ method, url: `/api${url}`, headers: who?.headers ?? {}, payload });
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().details?.reasonCode ?? ''}`);
if (reason) assert.equal(response.json().details?.reasonCode, reason);
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().error?.details?.reasonCode ?? ''}`);
if (reason) assert.equal(response.json().error?.details?.reasonCode, reason);
return response.json().data;
}
for (const who of [manager, system, admin, ordinary]) {
@@ -216,7 +225,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
return result;
}
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(exportResponse.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().details)}`);
assert.equal(exportResponse.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().error?.details)}`);
assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe');
@@ -367,7 +376,16 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
// Fresh lenders below: the first lender keeps unknown-state reservations from
// the negative receipt loop above, which by design block its maintenance.
async function verifiedLender(name, employeeId, digit, account = digit.repeat(16)) {
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name,
// Each independent scenario needs its own company when the same synthetic
// employee already has a lender there. Do not seed a forbidden duplicate.
let legalEntityId = company.id;
if (employeeId && await prisma.loanLender.count({ where: { employee_id: employeeId, legal_entity_id: company.id } })) {
const isolatedCompany = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: `合成场景公司-${randomUUID().slice(0, 8)}` } });
legalEntityId = isolatedCompany.id;
await prisma.paymentAccount.create({ data: { legal_entity_id: legalEntityId, bank_institution_id: bank.id,
account_name: '合成付款公司', account_number_encrypted: encryptStringOrThrow(config, payerAccount) } });
}
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId, name,
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: account, accountName: name });
const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, {
expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' });
@@ -384,8 +402,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
requestKey: randomUUID(), lenderId, reason: '合成还本', allocations: [{ loanId, amount }] })).item;
async function exportOrder(batchId, status = 200, reason) {
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(response.statusCode, status, `export ${response.statusCode} ${response.headers['content-type']?.includes('json') ? response.json().details?.reasonCode ?? '' : '<binary omitted>'}`);
if (reason) assert.equal(response.json().details?.reasonCode, reason);
assert.equal(response.statusCode, status, `export ${response.statusCode} ${response.headers['content-type']?.includes('json') ? response.json().error?.details?.reasonCode ?? '' : '<binary omitted>'}`);
if (reason) assert.equal(response.json().error?.details?.reasonCode, reason);
}
const lenderState = async (id) => (await call(finance, 'GET', '/loan-lenders')).items.find((item) => item.id === id);
const independent = await actor(['ceo']);
@@ -396,6 +414,15 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-related-browser.cjs')({ app, finance, manager, ordinary, password, prisma, config, relatedParty });
return;
}
const employeeHarness = { prisma, pool, call, app, req, finance, manager, ordinary,
config, password, actor, company, bank, confirmedLoan, repay, exportOrder, receipt };
const employeeSources = await require('./company-loan-employee-fixture.cjs')(employeeHarness);
if (process.env.RUN_ISOLATED_LOAN_EMPLOYEE_MUTATIONS === '1') await require('./company-loan-employee-mutations.cjs')(
employeeHarness, require('./company-loan-employee-fixture.cjs'));
if (process.env.RUN_ISOLATED_LOAN_EMPLOYEE_ONLY === '1') {
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-employee-browser.cjs')({ app, finance, password, prisma, config, employeeSources });
return;
}
// Lender linked to a manager: that manager cannot approve, an independent CEO can.
@@ -433,6 +460,12 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
checks.push('reserved_allocation_blocks_lender_maintenance_before_and_after_export');
// Account change after release revokes identity and blocks new requests until re-verified.
await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.employee_account_managed');
// The hand-entered account path now belongs only to a verified external
// person. Employee refresh is covered separately with real source data.
await call(finance, 'POST', `/loan-lenders/${linked.id}/identity-confirmation`, {
expectedVersion: (await lenderState(linked.id)).identityVersion, employeeId: null,
externalPersonConfirmed: true, reason: '合成旧手工账户路径隔离为非员工测试' });
const changedAccount = await accountChange((await lenderState(linked.id)).accountVersion);
assert.equal(changedAccount.identityStatus, 'unverified');
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: linked.id, reason: '合成未核实',
@@ -471,7 +504,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
if (first === 'create') {
assert.equal(created.statusCode, 200);
assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().details?.reasonCode, 'loan.lender_in_flight');
assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().error?.details?.reasonCode, 'loan.lender_in_flight');
} else {
assert.equal(maintained.statusCode, 200, 'maintenance first succeeds');
assert.equal(created.statusCode, 200, 'creation after maintenance uses the new versions');
@@ -699,7 +732,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
assert.equal(uploaded.statusCode, 200, `${order} competitor upload`);
if (order === 'settle_first') assert.equal(executed.statusCode, 200, 'settlement checked before the competitor existed');
else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().details?.reasonCode, 'loan.receipt_attribution_required'); }
else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().error?.details?.reasonCode, 'loan.receipt_attribution_required'); }
// Keep the shared OCR queue clean for later scenarios.
await call(finance, 'POST', `/payment-batches/${competitor.id}/receipt-attachments/${uploaded.json().data.items[0].id}/void`, { reason: '合成并发验证结束' });
}
@@ -1181,7 +1214,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
const dupEvidence = await app.inject({ method: 'POST', url: `/api/payment-batches/${wdB.id}/loan-terminal-evidences`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${dupBoundary}` },
payload: Buffer.concat([Buffer.from(`--${dupBoundary}\r\nContent-Disposition: form-data; name="file"; filename="bank-letter.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), sharedLetter, Buffer.from(`\r\n--${dupBoundary}--\r\n`)]) });
assert.equal(dupEvidence.statusCode, 409); assert.equal(dupEvidence.json().details?.reasonCode, 'loan.terminal_evidence_invalid');
assert.equal(dupEvidence.statusCode, 409); assert.equal(dupEvidence.json().error?.details?.reasonCode, 'loan.terminal_evidence_invalid');
checks.push('s3_withdrawn_ref_and_bank_letter_cannot_move_to_another_batch');
// Evidence separation and history.
@@ -1356,7 +1389,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
reconciliationUi.late.push({ id: original.id, lenderId: lateLender.id, reference: `UI-LATE-${mode}`,
payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted), payee: lateLender.bankAccount });
}
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty });
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty, employeeSources });
}
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
} finally { await app.close(); await prisma.$disconnect(); }
+1 -1
查看文件
@@ -37,7 +37,7 @@ module.exports = async function relatedBrowser(h) {
await d.getByRole('button', { name: confirm, exact: true }).click();
const result = await response;
if (!result.ok()) {
const failure = (await result.json().catch(() => ({}))).details?.reasonCode;
const failure = (await result.json().catch(() => ({}))).error?.details?.reasonCode;
throw new Error(`related ${s.mode} ${confirm}: HTTP ${result.status()} ${failure ?? ''}`);
}
try { await expect(d).not.toBeVisible({ timeout: 15000 }); }
+1 -1
查看文件
@@ -540,7 +540,7 @@ module.exports = async function relatedFixture(h) {
const results = await Promise.all([decision, stopped]);
assert.equal(results[1].statusCode, 200, `stop during ${kind}`);
assert.equal(results[0].statusCode, stopFirst ? 409 : 200, `${kind} stopFirst=${stopFirst}: ${results[0].statusCode}`);
if (stopFirst) assert.equal(results[0].json().details.reasonCode, 'loan.related_authorization_required');
if (stopFirst) assert.equal(results[0].json().error?.details.reasonCode, 'loan.related_authorization_required');
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
const state = await balance(s);
assert.equal(state.outstanding, '100.00'); assert.equal(state.reserved, kind === 'release' && !stopFirst ? '0.00' : '10.00');