fix: 退回报销支持留痕删除(09-04-reimbursement-state-gap-finance-view)
这个提交包含在:
@@ -353,6 +353,13 @@ async function main() {
|
||||
const simultaneous = await Promise.all([retryRequest, unbindRequest]);
|
||||
assert.deepEqual(simultaneous.map((result) => result.status), [200, 200], "retry and unbind must not deadlock");
|
||||
passed.push("create retry and legacy unbind share a consistent lock order");
|
||||
if (process.env.RUN_RETAINED_DELETE_TEST === "1") {
|
||||
await require("./test-reimbursement-retained-delete.cjs")({
|
||||
prisma, pool, app, origin, tokens, employee, manager, expenseType,
|
||||
makeForm, makeInvoice, bind, post, uploadedRecord, storageDir, passed,
|
||||
root, browser, preview, loginPassword,
|
||||
});
|
||||
}
|
||||
await prisma.employee.update({ where: { id: employee.id }, data: { token_version: { increment: 1 } } });
|
||||
assert.equal((await fetch(`${origin}/api/me`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } })).status, 401);
|
||||
passed.push("revoked token version rejected by real auth plugin");
|
||||
@@ -374,4 +381,4 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => { console.error("Isolated HTTP verification failed:", error.code ?? error.name, error.code === "ERR_ASSERTION" ? error.message : "", error.stack?.match(/test-invoice-first-http\.cjs:\d+:\d+/)?.[0] ?? ""); process.exitCode = 1; });
|
||||
main().catch((error) => { console.error("Isolated HTTP verification failed:", error.code ?? error.name, error.code === "ERR_ASSERTION" ? error.message : "", error.stack?.match(/test-(?:invoice-first-http|reimbursement-retained-delete)\.cjs:\d+:\d+/)?.[0] ?? ""); process.exitCode = 1; });
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { readFileSync, writeFileSync } = require("node:fs");
|
||||
const { createHash, randomUUID } = require("node:crypto");
|
||||
const { createRequire } = require("node:module");
|
||||
const path = require("node:path");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
|
||||
module.exports = async function verifyRetainedDeletion(context) {
|
||||
const {
|
||||
prisma, pool, origin, tokens, employee, manager, expenseType,
|
||||
makeForm, makeInvoice, bind, post, uploadedRecord, storageDir, passed,
|
||||
root, browser, preview, loginPassword,
|
||||
} = context;
|
||||
const remove = (form, actor) => post(`/reimbursements/${form.id}`, {}, actor, "DELETE");
|
||||
const get = async (url, actor = employee.id) => {
|
||||
const response = await fetch(`${origin}/api${url}`, {
|
||||
headers: { authorization: `Bearer ${tokens.get(actor)}` }, signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
return { status: response.status, data: (await response.json()).data };
|
||||
};
|
||||
const populate = async (form, invoice) => {
|
||||
assert.equal((await bind(invoice, form)).status, 200);
|
||||
const item = await prisma.reimbursementItem.findFirst({ where: { reimbursement_id: form.id } });
|
||||
assert.equal((await post(`/reimbursements/${form.id}/items/classify`, { itemIds: [item.id], expenseTypeId: expenseType.id })).status, 200);
|
||||
assert.equal((await post(`/reimbursements/${form.id}/items/${item.id}`, { description: "synthetic retained history" }, null, "PATCH")).status, 200);
|
||||
};
|
||||
const prepare = async (action) => {
|
||||
const form = await makeForm();
|
||||
const fileName = `synthetic-delete-${randomUUID()}.pdf`;
|
||||
const bytes = Buffer.concat([readFileSync(path.join(storageDir, uploadedRecord.storage_path)), Buffer.from(`\n% ${fileName}\n`)]);
|
||||
writeFileSync(path.join(storageDir, fileName), bytes);
|
||||
const invoice = await makeInvoice({
|
||||
storage_path: fileName,
|
||||
file_sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
file_size: bytes.length,
|
||||
});
|
||||
await populate(form, invoice);
|
||||
const submitted = await post(`/reimbursements/${form.id}/submit`, {});
|
||||
assert.equal(submitted.status, 200);
|
||||
const instanceId = submitted.body.data.workflowInstanceId;
|
||||
const task = await prisma.workflowTask.findFirst({ where: { instance_id: instanceId, status: "pending" } });
|
||||
if (action === "withdraw") {
|
||||
assert.equal((await post(`/reimbursements/${form.id}/withdraw`, { comment: "synthetic withdraw" })).status, 200);
|
||||
} else {
|
||||
assert.equal((await post(`/workflow/tasks/${task.id}/${action}`, { comment: "synthetic correction" }, manager.id)).status, 200);
|
||||
}
|
||||
return { form, invoice, instanceId };
|
||||
};
|
||||
const verify = async ({ form, invoice, instanceId }, action) => {
|
||||
const record = await prisma.reimbursementForm.findUnique({ where: { id: form.id } });
|
||||
assert.equal(record.status, "cancelled");
|
||||
assert.ok(record.deleted_at);
|
||||
assert.equal(record.deleted_by, employee.id);
|
||||
assert.ok(await prisma.workflowInstance.findUnique({ where: { id: instanceId } }));
|
||||
assert.ok(await prisma.workflowHistory.count({ where: { instance_id: instanceId } }));
|
||||
assert.ok(await prisma.reimbursementItem.count({ where: { reimbursement_id: form.id } }));
|
||||
const detail = await get(`/reimbursements/${form.id}`);
|
||||
assert.equal(detail.status, 200);
|
||||
assert.ok(detail.data.deletedAt);
|
||||
assert.equal(detail.data.invoices.length, 0);
|
||||
if (action !== "withdraw") {
|
||||
const evidence = await prisma.reimbursementAttachment.findFirst({ where: { reimbursement_id: form.id, attachment_type: "invoice" } });
|
||||
assert.ok(evidence);
|
||||
const response = await fetch(`${origin}/api/reimbursements/${form.id}/attachments/${evidence.id}/file`, { headers: { authorization: `Bearer ${tokens.get(employee.id)}` } });
|
||||
assert.equal(response.status, 200);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
assert.equal(createHash("sha256").update(bytes).digest("hex"), evidence.file_sha256);
|
||||
assert.deepEqual(bytes, readFileSync(path.join(storageDir, invoice.storage_path)));
|
||||
}
|
||||
for (const scope of ["own", "all"]) {
|
||||
const listing = await get(`/reimbursements?scope=${scope}&status=cancelled`, scope === "all" ? manager.id : employee.id);
|
||||
assert.equal(listing.status, 200);
|
||||
assert.ok(!listing.data.items.some((item) => item.id === form.id));
|
||||
}
|
||||
const summary = await get("/reimbursements/stats/summary", manager.id);
|
||||
assert.equal(summary.status, 200);
|
||||
assert.equal(summary.data.byStatus.cancelled.count, 0);
|
||||
const serverRequire = createRequire(path.join(root, "server/package.json"));
|
||||
const ledgerWhere = serverRequire("./src/services/financeLedger").buildFinanceLedgerWhere({ status: "cancelled" });
|
||||
assert.equal(await prisma.reimbursementForm.count({ where: ledgerWhere }), 0);
|
||||
assert.equal((await remove(form)).status, 200);
|
||||
assert.equal(await prisma.auditLog.count({ where: { action: "REIMBURSEMENT_DELETED", target_id: form.id } }), 1);
|
||||
assert.equal((await post(`/reimbursements/${form.id}/submit`, {})).status, 409);
|
||||
assert.equal((await post(`/reimbursements/${form.id}`, { description: "forbidden" }, null, "PATCH")).status, 409);
|
||||
assert.equal((await bind(await makeInvoice(), form)).status, 409);
|
||||
const replacement = await makeForm();
|
||||
await populate(replacement, invoice);
|
||||
assert.equal((await remove(form)).status, 200);
|
||||
assert.equal((await prisma.invoicePool.findUnique({ where: { id: invoice.id } })).linked_reimbursement_id, replacement.id);
|
||||
assert.equal((await post(`/reimbursements/${replacement.id}/submit`, {})).status, 200);
|
||||
const historicalDetail = await get(`/reimbursements/${form.id}`);
|
||||
assert.ok(historicalDetail.data.deletedAt);
|
||||
assert.equal(historicalDetail.data.invoices.length, 0);
|
||||
assert.equal(historicalDetail.data.attachments.length, detail.data.attachments.length);
|
||||
assert.ok(historicalDetail.data.processingProgress.stages.every((stage) => !["pending", "current"].includes(stage.state)));
|
||||
};
|
||||
for (const action of ["reject", "return", "withdraw"]) {
|
||||
const fixture = await prepare(action);
|
||||
assert.equal((await remove(fixture.form, manager.id)).status, 403);
|
||||
assert.equal((await remove(fixture.form)).status, 200);
|
||||
await verify(fixture, action);
|
||||
}
|
||||
passed.push("reject/return/withdraw -> retained deletion -> old history readable -> invoice reused and new form submitted; old record immutable; lists/raw stats/ledger exclude deleted records");
|
||||
|
||||
const rollback = await prepare("reject");
|
||||
await prisma.$executeRawUnsafe("CREATE FUNCTION synthetic_reject_delete_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'REIMBURSEMENT_DELETED' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$");
|
||||
await prisma.$executeRawUnsafe("CREATE TRIGGER synthetic_reject_delete_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_reject_delete_audit()");
|
||||
assert.equal((await remove(rollback.form)).status, 500);
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: rollback.form.id } })).status, "rejected");
|
||||
assert.equal((await prisma.invoicePool.findUnique({ where: { id: rollback.invoice.id } })).linked_reimbursement_id, rollback.form.id);
|
||||
assert.equal(await prisma.reimbursementAttachment.count({ where: { reimbursement_id: rollback.form.id } }), 0);
|
||||
await prisma.$executeRawUnsafe("DROP TRIGGER synthetic_reject_delete_audit ON audit_logs");
|
||||
passed.push("audit failure atomically rolls back deletion, invoice release and evidence attachment");
|
||||
|
||||
for (const competitor of ["submit", "bind", "delete"]) {
|
||||
const fixture = await prepare("reject");
|
||||
const blocker = await pool.connect();
|
||||
const waitForLocks = async (count) => {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
const result = await pool.query("SELECT count(*)::int AS count FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock' AND query LIKE '%reimbursement_forms%'");
|
||||
if (result.rows[0].count >= count) return;
|
||||
await delay(20);
|
||||
}
|
||||
assert.fail("delete concurrency barrier timed out");
|
||||
};
|
||||
let deletion;
|
||||
let competing;
|
||||
try {
|
||||
await blocker.query("BEGIN");
|
||||
await blocker.query("SELECT id FROM reimbursement_forms WHERE id = $1 FOR UPDATE", [fixture.form.id]);
|
||||
deletion = remove(fixture.form);
|
||||
await waitForLocks(1);
|
||||
competing = competitor === "delete" ? remove(fixture.form)
|
||||
: competitor === "submit" ? post(`/reimbursements/${fixture.form.id}/submit`, {})
|
||||
: bind(await makeInvoice(), fixture.form);
|
||||
await waitForLocks(2);
|
||||
} finally {
|
||||
await blocker.query("ROLLBACK");
|
||||
blocker.release();
|
||||
}
|
||||
const results = await Promise.all([deletion, competing]);
|
||||
assert.deepEqual(results.map((result) => result.status), [200, competitor === "delete" ? 200 : 409]);
|
||||
assert.equal(await prisma.auditLog.count({ where: { action: "REIMBURSEMENT_DELETED", target_id: fixture.form.id } }), 1);
|
||||
}
|
||||
passed.push("real PostgreSQL barriers: deletion vs submit/bind/repeated delete has one legal terminal state");
|
||||
|
||||
if (browser && preview) {
|
||||
const { expect } = createRequire(path.join(root, "client/package.json"))("@playwright/test");
|
||||
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
|
||||
for (const mobile of [false, true]) {
|
||||
const fixture = await prepare("reject");
|
||||
const browserContext = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
|
||||
try {
|
||||
const fingerprint = `synthetic-delete-${mobile}`;
|
||||
await browserContext.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
|
||||
const login = await browserContext.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: employee.email, password: loginPassword } });
|
||||
assert.equal(login.status(), 200);
|
||||
const page = await browserContext.newPage();
|
||||
const target = `${uiOrigin}/${mobile ? "mobile/" : ""}reimbursements/${fixture.form.id}`;
|
||||
await page.goto(target);
|
||||
if (!mobile) await page.getByTestId("reimbursement-more-toggle").click();
|
||||
else await page.getByTestId("mobile-reimbursement-more").click();
|
||||
await page.getByRole("button", { name: "删除报销单", exact: true }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("审批记录仍保留");
|
||||
await page.getByTestId("action-confirm-cancel").click();
|
||||
assert.equal((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at, null);
|
||||
await page.getByRole("button", { name: "删除报销单", exact: true }).click();
|
||||
await page.getByTestId("action-confirm-submit").click();
|
||||
await expect.poll(async () => Boolean((await prisma.reimbursementForm.findUnique({ where: { id: fixture.form.id } })).deleted_at)).toBe(true);
|
||||
await page.goto(target);
|
||||
await expect(page.getByText("已删除", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-submit")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-invoice-add")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "删除报销单", exact: true })).toHaveCount(0);
|
||||
await verify(fixture, "reject");
|
||||
} finally { await browserContext.close(); }
|
||||
}
|
||||
passed.push("desktop/mobile real browser: rejected deletion cancel/confirm, persisted historical deep link, no edit/submit/delete actions");
|
||||
}
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户