fix: 隔离门户待办身份并取消旧请求(09-11-reimbursement-payment-handoffs)
这个提交包含在:
@@ -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 = [
|
||||
|
||||
在新工单中引用
屏蔽一个用户