fix: 隔离门户待办身份并取消旧请求(09-11-reimbursement-payment-handoffs)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-11 20:21:46 +08:00
父节点 9e41f107fa
当前提交 5d8cdf12b4
共修改 10 个文件,包含 466 行新增和 26 行删除
+76 -1
查看文件
@@ -85,7 +85,10 @@ async function main() {
await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" });
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
const envelope = (data) => ({ requestId: "synthetic-http", data });
app.get("/api/me", { preHandler: app.authenticate }, async (request) => envelope({ id: request.employee.id, employeeNo: request.employee.employee_no, name: "synthetic", email: request.employee.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: request.employee.roleCodes, notificationPushEnabled: false }));
app.get("/api/me", { preHandler: app.authenticate }, async (request) => {
const profile = await prisma.employee.findUniqueOrThrow({ where: { id: request.employee.id }, select: { employee_no: true, email: true } });
return envelope({ id: request.employee.id, employeeNo: profile.employee_no, name: "synthetic", email: profile.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: request.employee.roleCodes, notificationPushEnabled: false });
});
app.get("/api/system/navigation", async () => envelope({ workspace: { id: "synthetic", profile: "small_team_internal", orgMode: "single_company", moduleVisibility: {} }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [] }));
app.get("/api/notifications", async () => envelope({ items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 }));
await app.register(serverRequire("./src/routes/expenseTypes"), { prefix: "/api" });
@@ -134,6 +137,78 @@ async function main() {
};
assert.deepEqual(await getHandoffs(), await getHandoffs());
assert.equal(await prisma.auditLog.count(), auditCount, "handoff reads do not create audit or workflow writes");
if (browser && preview && ["reimbursement_edit", "payment_review"].includes(kind)
&& [employee.id, approvers.ceo.id].includes(actor.id)) {
const clientRequire = createRequire(path.join(root, "client/package.json"));
const { expect } = clientRequire("@playwright/test");
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
const context = await browser.newContext({ serviceWorkers: "block", viewport: { width: 390, height: 844 } });
try {
const fingerprint = `synthetic-handoff-${randomUUID()}`;
await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
const login = await context.request.post(`${uiOrigin}/api/auth/login`, {
headers: { "x-device-fingerprint": fingerprint }, data: { identifier: actor.email, password: loginPassword },
});
assert.equal(login.status(), 200, "non-finance mobile real password login");
const page = await context.newPage();
page.setDefaultTimeout(20000);
const loaded = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
await page.goto(`${uiOrigin}/mobile`);
assert.equal((await loaded).status(), 200, "non-finance mobile cold entry actually loads handoffs");
const href = kind === "reimbursement_edit" ? `/mobile/reimbursements/${entityId}` : `/mobile/payment-batches?id=${entityId}`;
const row = page.getByTestId("mobile-business-handoffs").locator(`a[href="${href}"]`);
await expect(row).toHaveCount(expectedCount);
if (expectedCount) await expect(row).toBeVisible();
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
passed.push(`handoffs: non-finance mobile cold entry with real auth/HTTP ${kind} count=${expectedCount}, no finance card`);
if (kind === "reimbursement_edit" && expectedCount === 1 && actor.id === employee.id) {
await page.evaluate(() => { Reflect.set(window, "__realHandoffSwitchDocument", "same-document"); });
await page.locator('a[href="/mobile/me"]').last().click();
await page.getByRole("button", { name: "退出登录", exact: true }).click();
await expect(page).toHaveURL(/\/login/);
await page.getByTestId("login-identifier").fill(approvers.ceo.email);
await page.getByTestId("login-password").fill(loginPassword);
const secondLogin = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/auth/login");
const secondProfile = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/me");
await page.getByTestId("login-submit").click();
assert.equal((await secondLogin).status(), 200, "second actor UI password login");
const profileResponse = await secondProfile;
assert.equal(profileResponse.status(), 200, "second actor profile loaded");
assert.equal((await profileResponse.json()).data.id === approvers.ceo.id, true, "second actor identity matches");
await expect(page).toHaveURL(/\/mobile\/me$/);
await expect(page.getByTestId("mobile-me-profile-copy")).toContainText(approvers.ceo.employee_no);
let releaseResponse;
let secondActorResponseVerified = false;
const responseGate = new Promise((resolve) => { releaseResponse = resolve; });
await page.route("**/api/portal/home", async (route) => {
const response = await route.fetch();
assert.equal(response.status(), 200, "second actor real portal response");
const data = (await response.json()).data.businessHandoffs;
assert.equal(data.items.some((item) => item.href === `/reimbursements/${entityId}`), false);
secondActorResponseVerified = true;
await responseGate;
await route.fulfill({ response });
});
try {
await page.locator('a[href="/mobile"]').last().click();
await expect.poll(() => secondActorResponseVerified).toBe(true);
assert.equal(await page.evaluate(() => Reflect.get(window, "__realHandoffSwitchDocument")), "same-document");
await expect(row).toHaveCount(0);
const refreshed = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
releaseResponse();
await refreshed;
await expect(row).toHaveCount(0);
passed.push("handoffs: same-document employee-to-CEO mobile switch with real password login and isolated HTTP, no former actor handoff while response is held");
} finally {
releaseResponse();
await page.unrouteAll({ behavior: "wait" });
}
}
} finally {
await context.close();
}
}
};
const bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor);
const objects = [