- 上传银行证据后、刷新完成前,证据下拉框与确认按钮禁用并提示,避免空证据提交 - 员工来源版本去掉 updated_at(登录会刷新),保留全部真实资料字段的拦截 - 补登录误报回归、字段变更拦截、两项变异及刷新挂起的浏览器检查 - 任务记录 E 节与 AC52–55,独立只读复核无阻断项 Co-Authored-By: Claude Opus 5.5 <[email protected]>
353 行
27 KiB
JavaScript
353 行
27 KiB
JavaScript
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');
|
|
|
|
// A real login between preview and save (a failed attempt, then a successful
|
|
// one resetting the counters) touches updated_at only and must not block.
|
|
const loginWith = (who, password) => app.inject({ method: 'POST', url: '/api/auth/login',
|
|
headers: { 'x-device-fingerprint': randomUUID() }, payload: { identifier: who.email, password } });
|
|
const loggedIn = await employee('预览后登录');
|
|
const beforeLogin = await source(loggedIn);
|
|
const touched = (await prisma.employee.findUniqueOrThrow({ where: { id: loggedIn.id } })).updated_at;
|
|
assert.notEqual((await loginWith(loggedIn, `${h.password}-wrong`)).statusCode, 200);
|
|
assert.equal((await loginWith(loggedIn, h.password)).statusCode, 200);
|
|
assert.notEqual((await prisma.employee.findUniqueOrThrow({ where: { id: loggedIn.id } })).updated_at.getTime(), touched.getTime(), 'login touches updated_at');
|
|
assert.equal((await source(loggedIn)).sourceVersion, beforeLogin.sourceVersion);
|
|
assert.equal((await link(input(loggedIn, beforeLogin))).lender.employeeId, loggedIn.id);
|
|
// Every real source field still invalidates a preview, with updated_at held.
|
|
for (const [field, patch] of [['name', { name: '合成员工-改名' }], ['bank_name', { bank_name: '合成建行员工收款资料-改' }],
|
|
['legal_entity_id', { legal_entity_id: otherCompany.id }], ['bank_institution_id', { bank_institution_id: null }]]) {
|
|
const who = await employee(`字段-${field}`);
|
|
const seen = await source(who);
|
|
const at = (await prisma.employee.findUniqueOrThrow({ where: { id: who.id } })).updated_at;
|
|
await prisma.employee.update({ where: { id: who.id }, data: { ...patch, updated_at: at } });
|
|
await link(input(who, seen), 409, 'loan.employee_source_changed');
|
|
assert.equal(await prisma.loanLender.count({ where: { employee_id: who.id } }), 0, `${field} change blocks the save`);
|
|
}
|
|
checks.push('employee_login_between_preview_and_save_allowed_real_field_changes_blocked');
|
|
|
|
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 };
|
|
};
|