fix: 完善回单失败引导及真实认证验证(09-08-reimbursement-payment-closeout)
这个提交包含在:
@@ -0,0 +1,198 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { mkdtempSync, rmSync, writeFileSync } = require('node:fs');
|
||||
const { tmpdir } = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
module.exports = async function receiptBrowser({ root, prisma, config, employee, reviewer, manualFixture, serverRequire, passed }) {
|
||||
assert.equal(process.env.RUN_ISOLATED_DB_TEST, '1');
|
||||
assert.ok(!process.argv.some(argument => /^dotenv_config_/i.test(argument)));
|
||||
assert.match(process.env.DATABASE_URL, /^postgresql:\/\/postgres:[^@]+@127\.0\.0\.1:\d+\/postgres$/);
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'receipt-browser-'));
|
||||
const previous = { ...process.env };
|
||||
let app;
|
||||
let browser;
|
||||
let preview;
|
||||
let stage = 'setup';
|
||||
try {
|
||||
const envFile = path.join(directory, 'synthetic.env');
|
||||
writeFileSync(envFile, '', { mode: 0o600 });
|
||||
process.env.DOTENV_CONFIG_PATH = envFile;
|
||||
delete process.env.DOTENV_CONFIG_OVERRIDE;
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (/^(WECOM_|TENCENT_|SMTP_|LIVE_SMOKE_)/.test(key)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, { NODE_ENV: 'test', JWT_SECRET: randomUUID(), AUTH_CODE_PEPPER: randomUUID(),
|
||||
FIELD_ENCRYPTION_KEY: config.fieldEncryptionKey, STORAGE_DIR: directory, STORAGE_DRIVER: 'local',
|
||||
JOBS_ENABLED: 'false', OCR_PROVIDER: 'off', EMAIL_ENABLED: 'false', IM_NOTIFIER_ENABLED: 'false',
|
||||
ERP_SYNC_ENABLED: 'false', SERVE_CLIENT_DIST: 'false', COOKIE_SECURE: 'false', APP_ORIGIN: '',
|
||||
PAYMENT_BATCH_ENABLED: 'true', OPENAPI_DOCS_ENABLED: 'false' });
|
||||
const password = `Synthetic!${randomUUID()}`;
|
||||
await prisma.employee.updateMany({ where: { id: { in: [employee.id, reviewer.id] } }, data: {
|
||||
password_hash: await serverRequire('./src/utils/crypto').hashPassword(password), must_change_password: false,
|
||||
} });
|
||||
const context = await manualFixture('SYN-BROWSER');
|
||||
await prisma.paymentBatch.update({ where: { id: context.batch.id }, data: {
|
||||
reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: 'bank',
|
||||
} });
|
||||
await prisma.paymentBatchReceiptAttachment.updateMany({ data: { archived_at: new Date() } });
|
||||
const sourceItems = await prisma.paymentBatchItem.findMany({ where: { payment_batch: { entity_type: 'reimbursement' } } });
|
||||
for (const item of sourceItems) {
|
||||
await prisma.reimbursementForm.upsert({ where: { id: item.entity_id }, update: {}, create: {
|
||||
id: item.entity_id, submitter_id: employee.id, owner_id: employee.id,
|
||||
department_id: employee.department_id, reimbursement_date: new Date(),
|
||||
status: 'approved', total_amount: item.amount,
|
||||
} });
|
||||
}
|
||||
const plugin = serverRequire('fastify-plugin');
|
||||
app = await serverRequire('./src/app').buildApp({
|
||||
prismaPlugin: plugin(async instance => { instance.decorate('prisma', prisma); }),
|
||||
startJobs: async () => {},
|
||||
});
|
||||
app.log.level = 'silent';
|
||||
await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const apiOrigin = `http://127.0.0.1:${app.server.address().port}`;
|
||||
const clientRequire = createRequire(path.join(root, 'client/package.json'));
|
||||
const vite = await import(clientRequire.resolve('vite'));
|
||||
stage = 'preview';
|
||||
preview = await (vite.default ?? vite).preview({ configFile: false, envFile: false, root: path.join(root, 'client'),
|
||||
preview: { host: '127.0.0.1', port: 0, proxy: { '/api': apiOrigin } } });
|
||||
const origin = `http://127.0.0.1:${preview.httpServer.address().port}`;
|
||||
app.config.appOrigin = origin;
|
||||
const { chromium, expect } = clientRequire('@playwright/test');
|
||||
stage = 'browser_launch';
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ serviceWorkers: 'block', viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('response', async response => {
|
||||
if (response.status() >= 400 && response.url().includes('/api/')) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
console.error(JSON.stringify({ status: 'SYNTHETIC_API_DIAGNOSTIC',
|
||||
endpoint: new URL(response.url()).pathname.replace(/[0-9a-f-]{36}/g, '[id]'),
|
||||
httpStatus: response.status(), code: body.error?.code, reason: body.error?.details?.reasonCode }));
|
||||
}
|
||||
});
|
||||
let externalRequests = 0;
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== origin && !['data:', 'blob:'].includes(url.protocol)) {
|
||||
externalRequests += 1;
|
||||
return route.abort();
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
stage = 'login';
|
||||
await page.goto(origin + '/login');
|
||||
await page.getByTestId('login-identifier').fill(employee.email);
|
||||
await page.getByTestId('login-password').fill(password);
|
||||
await page.getByTestId('login-submit').click();
|
||||
await expect(page).not.toHaveURL(/\/login/, { timeout: 20000 });
|
||||
console.error(JSON.stringify({status: 'SYNTHETIC_COOKIE_DIAGNOSTIC',
|
||||
refreshPresent: (await page.context().cookies()).some(cookie => cookie.name === 'refresh_token')}));
|
||||
stage = 'upload';
|
||||
await page.goto(origin + '/payment-batches?id=' + context.batch.id);
|
||||
const document = await serverRequire('pdf-lib').PDFDocument.create();
|
||||
document.addPage().drawText('Synthetic receipt only');
|
||||
await page.getByTestId('payment-batch-receipt-file-input').setInputFiles({
|
||||
name: 'synthetic-receipt.pdf', mimeType: 'application/pdf', buffer: Buffer.from(await document.save()),
|
||||
});
|
||||
await expect.poll(async () => prisma.paymentBatchReceiptAttachment.count({
|
||||
where: { payment_batch_id: context.batch.id, archived_at: null, receipt_ocr_state: 'queued' },
|
||||
})).toBe(1);
|
||||
const { runReceiptOcrOnce } = serverRequire('./src/jobs/paymentReceiptOcrJob');
|
||||
const { normalizeBankSlip } = serverRequire('./src/services/paymentReceiptMatching');
|
||||
let providerCalls = 0;
|
||||
stage = 'worker';
|
||||
const result = await runReceiptOcrOnce({ prisma, config: app.config, recognize: async () => {
|
||||
providerCalls += 1;
|
||||
return { status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1,
|
||||
...normalizeBankSlip(Object.entries({ 小写金额: '10.00', 币别: '人民币', 付款人账号: '000011112222',
|
||||
收款人账号: '000033334444', 单据状态: '交易成功', 凭证号: 'SYN-BROWSER' })
|
||||
.map(([Name, Value]) => ({ Name, Value }))) }] };
|
||||
} });
|
||||
assert.equal(result.status, 'matched');
|
||||
assert.equal(providerCalls, 1);
|
||||
stage = 'register';
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: '登记付款完成', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: /确认/ }).click();
|
||||
await expect.poll(async () => (await prisma.reimbursementForm.findUnique({
|
||||
where: { id: context.batch.items[0].entity_id },
|
||||
})).status).toBe('paid');
|
||||
assert.ok(await prisma.paymentReceiptClaim.findUnique({ where: { item_id: context.batch.items[0].id } }));
|
||||
assert.ok(await prisma.auditLog.count({ where: { action: 'PAYMENT_BATCH_EXECUTED', target_id: context.batch.id } }));
|
||||
assert.equal(externalRequests, 0);
|
||||
passed.push('browser_password_login_upload_worker_match_and_payment_same_database');
|
||||
for (const scenario of ['wrong_amount', 'system_failure', 'duplicate_transaction']) {
|
||||
stage = scenario;
|
||||
const blocked = await manualFixture('SYN-' + scenario);
|
||||
await prisma.paymentBatch.update({ where: { id: blocked.batch.id }, data: {
|
||||
reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: 'bank',
|
||||
} });
|
||||
await prisma.paymentBatchReceiptAttachment.update({ where: { id: blocked.attachment.id }, data: { archived_at: new Date() } });
|
||||
await page.goto(origin + '/payment-batches?id=' + blocked.batch.id);
|
||||
await page.getByTestId('payment-batch-receipt-file-input').setInputFiles({
|
||||
name: 'synthetic-' + scenario + '.pdf', mimeType: 'application/pdf', buffer: Buffer.from(await document.save()),
|
||||
});
|
||||
await expect.poll(async () => prisma.paymentBatchReceiptAttachment.count({ where: {
|
||||
payment_batch_id: blocked.batch.id, archived_at: null, receipt_ocr_state: 'queued',
|
||||
} })).toBe(1);
|
||||
const outcome = await runReceiptOcrOnce({ prisma,
|
||||
config: scenario === 'system_failure' ? { ...app.config, fieldEncryptionKey: '' } : app.config,
|
||||
recognize: async () => ({ status: 'recognized', pagesExpected: 1, pagesProcessed: 1, pages: [{ pageNumber: 1,
|
||||
...normalizeBankSlip(Object.entries({ 小写金额: scenario === 'wrong_amount' ? '11.00' : '10.00',
|
||||
币别: '人民币', 付款人账号: '000011112222', 收款人账号: '000033334444',
|
||||
单据状态: '交易成功', 凭证号: 'SYN-BROWSER' }).map(([Name, Value]) => ({ Name, Value }))) }] }),
|
||||
});
|
||||
assert.equal(outcome.status, scenario === 'wrong_amount' ? 'needs_review' : scenario === 'system_failure' ? 'key_failed' : 'matched');
|
||||
await page.reload();
|
||||
await expect(page.getByText('回单尚未满足付款登记条件', { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '登记付款完成', exact: true })).toHaveCount(0);
|
||||
if (scenario === 'system_failure') {
|
||||
await expect(page.getByRole('status').filter({ hasText: '回单尚未满足付款登记条件' })).toContainText('存在系统核验故障');
|
||||
await expect(page.getByText('请查看核验详情', { exact: false })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '重新识别', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '核验详情', exact: true })).toHaveCount(0);
|
||||
}
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: blocked.batch.items[0].entity_id } })).status, 'approved');
|
||||
assert.equal(await prisma.paymentReceiptClaim.count({ where: { item_id: blocked.batch.items[0].id } }), 0);
|
||||
passed.push('browser_' + scenario + '_does_not_record_payment');
|
||||
}
|
||||
stage = 'identity_switch';
|
||||
for (const actor of [reviewer, employee]) {
|
||||
await page.getByTestId('topbar-profile-trigger').click();
|
||||
await page.getByRole('menuitem', { name: '退出登录', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByTestId('payment-batch-receipt-file-input')).toHaveCount(0);
|
||||
await page.getByTestId('login-identifier').fill(actor.email);
|
||||
await page.getByTestId('login-password').fill(password);
|
||||
const profileResponse = page.waitForResponse(response =>
|
||||
new URL(response.url()).pathname === '/api/me' && response.status() === 200);
|
||||
await page.getByTestId('login-submit').click();
|
||||
const profile = await (await profileResponse).json();
|
||||
assert.equal(profile.data.id, actor.id);
|
||||
await expect(page).not.toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
}
|
||||
assert.equal(externalRequests, 0);
|
||||
passed.push('browser_real_logout_reviewer_login_and_finance_relogin');
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ status: 'RECEIPT_BROWSER_FAILED', stage,
|
||||
errorType: error.name, httpStatus: typeof error.actual === 'number' ? error.actual : null }));
|
||||
throw error;
|
||||
} finally {
|
||||
try { if (browser) await browser.close(); }
|
||||
finally {
|
||||
try { if (preview) await new Promise((resolve, reject) => preview.httpServer.close(error => error ? reject(error) : resolve())); }
|
||||
finally {
|
||||
try { if (app) await app.close(); }
|
||||
finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
for (const key of Object.keys(process.env)) if (!(key in previous)) delete process.env[key];
|
||||
Object.assign(process.env, previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -22,6 +22,7 @@ const docker = (args, input) => {
|
||||
|
||||
async function main() {
|
||||
assert.equal(process.env.RUN_ISOLATED_DB_TEST, "1");
|
||||
const realAuthentication = process.env.RUN_RECEIPT_REAL_AUTH === "1";
|
||||
let container;
|
||||
let prisma;
|
||||
let pool;
|
||||
@@ -428,20 +429,29 @@ async function main() {
|
||||
const app = serverRequire("fastify")();
|
||||
app.decorate("prisma", prisma);
|
||||
app.decorate("config", { ...config, paymentBatchEnabled: true, appTimezone: "Asia/Shanghai",
|
||||
erpSyncEnabled: false, emailEnabled: false, imNotifierEnabled: false, appOrigin: "http://127.0.0.1" });
|
||||
app.decorate("authenticate", async (request) => {
|
||||
const actorId = request.headers.authorization === "Bearer synthetic-finance" ? employee.id :
|
||||
request.headers.authorization === "Bearer synthetic-reviewer" ? reviewer.id : null;
|
||||
if (!actorId) throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
const actor = await prisma.employee.findUnique({ where: { id: actorId }, include: { roles: true } });
|
||||
request.employee = { ...actor, roleCodes: actor.roles.map((role) => role.role_code) };
|
||||
});
|
||||
app.decorate("hasPermission", (actor, permission) => hasPermission(resolvePermissionsForRoles(actor.roleCodes), permission));
|
||||
erpSyncEnabled: false, emailEnabled: false, imNotifierEnabled: false, appOrigin: "http://127.0.0.1",
|
||||
jwtSecret: randomUUID(), accessTokenTtl: "15m", refreshTtlSeconds: 3600,
|
||||
refreshCookieName: "synthetic_refresh", cookieSecure: false,
|
||||
loginFailMaxAttempts: 5, loginFailWindowSeconds: 300, loginLockSeconds: 60 });
|
||||
if (realAuthentication) {
|
||||
await app.register(serverRequire("@fastify/cookie"));
|
||||
await app.register(serverRequire("./src/plugins/auth"));
|
||||
await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" });
|
||||
} else {
|
||||
app.decorate("authenticate", async (request) => {
|
||||
const actorId = request.headers.authorization === "Bearer synthetic-finance" ? employee.id :
|
||||
request.headers.authorization === "Bearer synthetic-reviewer" ? reviewer.id : null;
|
||||
if (!actorId) throw new AppError({ code: "AUTH_001", message: "未登录", statusCode: 401 });
|
||||
const actor = await prisma.employee.findUnique({ where: { id: actorId }, include: { roles: true } });
|
||||
request.employee = { ...actor, roleCodes: actor.roles.map((role) => role.role_code) };
|
||||
});
|
||||
app.decorate("hasPermission", (actor, permission) => hasPermission(resolvePermissionsForRoles(actor.roleCodes), permission));
|
||||
}
|
||||
app.setErrorHandler((error, request, reply) => reply.code(error instanceof AppError ? error.statusCode : 500)
|
||||
.send({ error: { code: error.code ?? "unexpected", details: error instanceof AppError ? error.details : undefined } }));
|
||||
await app.register(serverRequire("./src/routes/paymentBatches"), { prefix: "/api" });
|
||||
const financeHeaders = { authorization: "Bearer synthetic-finance" };
|
||||
const reviewerHeaders = { authorization: "Bearer synthetic-reviewer" };
|
||||
let financeHeaders = { authorization: "Bearer synthetic-finance" };
|
||||
let reviewerHeaders = { authorization: "Bearer synthetic-reviewer" };
|
||||
async function readyHttp(context) {
|
||||
await prisma.paymentBatch.update({ where: { id: context.batch.id }, data: {
|
||||
reviewed_by: reviewer.id, reviewed_at: new Date(), default_payment_channel: "bank",
|
||||
@@ -449,6 +459,30 @@ async function main() {
|
||||
return `/api/payment-batches/${context.batch.id}`;
|
||||
}
|
||||
try {
|
||||
if (realAuthentication) {
|
||||
const loginPassword = `Synthetic!${randomUUID()}`;
|
||||
const passwordHash = await serverRequire("./src/utils/crypto").hashPassword(loginPassword);
|
||||
await prisma.employee.updateMany({ where: { id: { in: [employee.id, reviewer.id] } },
|
||||
data: { password_hash: passwordHash, must_change_password: false } });
|
||||
async function loginActor(actorId) {
|
||||
const actor = await prisma.employee.findUniqueOrThrow({ where: { id: actorId } });
|
||||
const response = await app.inject({ method: "POST", url: "/api/auth/login",
|
||||
headers: { "x-device-fingerprint": `synthetic-${actorId}` },
|
||||
payload: { identifier: actor.email, password: loginPassword } });
|
||||
assert.equal(response.statusCode, 200, "synthetic password login failed");
|
||||
const token = response.json().data.accessToken;
|
||||
assert.equal(typeof token, "string");
|
||||
assert.ok(response.cookies.some(cookie => cookie.name === "synthetic_refresh"));
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
financeHeaders = await loginActor(employee.id);
|
||||
reviewerHeaders = await loginActor(reviewer.id);
|
||||
passed.push("actual_password_login_jwt_and_refresh_cookie");
|
||||
const forged = await app.inject({ method: "GET", url: `/api/payment-batches/${randomUUID()}/receipt-readiness`,
|
||||
headers: { authorization: "Bearer synthetic-finance" } });
|
||||
assert.equal(forged.statusCode, 401);
|
||||
passed.push("synthetic_actor_header_rejected_by_real_authentication");
|
||||
}
|
||||
const httpManual = await manualFixture("SYN-HTTP-ORIGINAL");
|
||||
const httpBase = await readyHttp(httpManual);
|
||||
const httpProposalUrl = `${httpBase}/receipt-attachments/${httpManual.attachment.id}/manual-verification`;
|
||||
@@ -489,6 +523,12 @@ async function main() {
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: rollback.batch.items[0].entity_id } })).status, "approved");
|
||||
passed.push("real_db_http_source_amount_change_rolls_back_claim_alias_and_payment");
|
||||
} finally { await app.close(); }
|
||||
if (process.env.RUN_RECEIPT_BROWSER === "1") {
|
||||
assert.ok(realAuthentication);
|
||||
await require("./test-payment-receipt-browser.cjs")({
|
||||
root, prisma, config, employee, reviewer, manualFixture, serverRequire, passed,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try { if (prisma) await prisma.$disconnect(); }
|
||||
finally {
|
||||
@@ -504,7 +544,9 @@ async function main() {
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ status: "PASS_ISOLATED_RECEIPT_DB", passed, cleanupComplete: true,
|
||||
productionAccess: false, cloudCalls: false, httpTested: true, authentication: "synthetic_actor_fixture" }));
|
||||
productionAccess: false, cloudCalls: false, httpTested: true,
|
||||
authentication: realAuthentication ? "actual_password_login_and_jwt" : "synthetic_actor_fixture",
|
||||
browserTested: process.env.RUN_RECEIPT_BROWSER === "1" }));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户