借款出借人改为从现有员工选择并复用资料(09-17-company-loan-principal-repayment)
- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号 - 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE - 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效 - 电脑/手机共用对话框,写请求在卸载或身份切换时取消 - 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -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;
|
||||
};
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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 }); }
|
||||
|
||||
@@ -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');
|
||||
|
||||
在新工单中引用
屏蔽一个用户