Files
console-dev/tests/review-oauth-session.e2e.test.mjs
T

426 lines
15 KiB
JavaScript

/** 登录结果的会话归属:真实页面、真实跨标签 storage 事件和受控外部 HTTP 响应。 */
import assert from "node:assert/strict";
import { test } from "node:test";
import {
appOrigin,
backendOrigin,
eventually,
login,
scenario,
} from "./helpers/console-browser.mjs";
function deferred() {
let resolve;
const promise = new Promise((done) => (resolve = done));
return { promise, resolve };
}
async function respond(route, data, status = 200, msg = "") {
return route.fulfill({
json: { status, msg, data },
headers: { "Access-Control-Allow-Origin": "*" },
});
}
async function oauthBoundary(page, handle) {
// 只声明 OAuth 的外部协议;普通登录、账户和列表仍使用独立 HTTP 假后端。
await page
.context()
.route(`${backendOrigin}/console/v1/**`, async (route) => {
const path = new URL(route.request().url()).pathname.replace(
/^\/console\/v1/,
"",
);
if (await handle(route, path)) return;
// 基线复现若错误接纳旧会话,让该外部账号的真实首页继续加载,不以 401 掩盖覆盖。
const previousAccount = {
"/index": {
account: { id: 99, username: "older-oauth-account", credit: "0.00" },
},
"/menu": { menu: [] },
"/client_care/mail": { credit_limit: { count: 0, list: [], type: [] } },
"/certification/info": {},
"/host": { list: [], count: 0 },
"/ticket": { list: [], count: 0 },
"/ticket/status": { list: [] },
"/ticket/statistic": {},
"/announcement": { list: [], count: 0 },
"/recommend/promoter": {},
"/transaction": { list: [], count: 0 },
"/withdraw/rule/credit": {},
"/ssh_key": { list: [], count: 0 },
};
if (
route.request().headers().authorization ===
"Bearer test-older-oauth-session" &&
Object.hasOwn(previousAccount, path)
)
return respond(route, previousAccount[path]);
return route.fallback();
});
}
async function changeSession(page, transition) {
// 不预置 token,也不手工派发 storage;另一个真实标签完成登录和可选的退出。
await page.evaluate(() => {
window.oauthSessionTransitions = [];
window.addEventListener("storage", (event) => {
if (event.key === "jwt")
window.oauthSessionTransitions.push(
event.newValue ? "login" : "logout",
);
});
});
const other = await page.context().newPage();
await login(other);
await other.getByText("audit-01", { exact: true }).waitFor();
await eventually(
() => page.evaluate(() => window.oauthSessionTransitions),
(events) => events.includes("login"),
"New login must synchronize before the pending OAuth result is released",
);
if (transition === "logout") {
await other.getByRole("button", { name: "用户菜单", exact: true }).click();
await other
.getByRole("menuitem", { name: "退出登录", exact: true })
.click();
await other.waitForURL((url) => url.pathname === "/login");
await eventually(
() => page.evaluate(() => window.oauthSessionTransitions),
(events) => events.includes("logout"),
"Logout must synchronize before the pending OAuth result is released",
);
}
return other;
}
async function sessionIsPreserved(page, transition) {
assert.equal(
await page.evaluate(
(loggedOut) =>
localStorage.getItem("jwt") ===
(loggedOut ? null : "e2e-session-token"),
transition === "logout",
),
true,
"An older result must preserve the session established by the newer action",
);
}
test(
"OAuth callback establishes a current session normally",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
await oauthBoundary(page, async (route, path) => {
if (path !== "/oauth/token") return false;
await respond(route, { jwt: "e2e-session-token" });
return true;
});
await page.goto(`${appOrigin}/oauth/callback`);
await page.waitForURL((url) => url.pathname === "/");
await page
.getByRole("button", { name: "用户菜单", exact: true })
.waitFor();
await sessionIsPreserved(page, "login");
return { currentOAuthSessionAccepted: true, memberHomeVisible: true };
}),
);
test(
"OAuth verified binding establishes a current session normally",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
await oauthBoundary(page, async (route, path) => {
if (path === "/oauth/token") {
await respond(route, { url: "/oauth/bind" });
return true;
}
if (path === "/oauth/client/bind") {
await respond(route, { jwt: "e2e-session-token" });
return true;
}
return false;
});
await page.goto(`${appOrigin}/oauth/bind?redirect=/security/ssh-keys`);
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
await page.getByLabel("验证码", { exact: true }).fill("123456");
await page.getByRole("checkbox").check();
await page.getByRole("button", { name: "确认绑定", exact: true }).click();
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
await page.getByText("audit-01", { exact: true }).waitFor();
await sessionIsPreserved(page, "login");
return { verifiedBindingAccepted: true, protectedPageVisible: true };
}),
);
for (const pagePath of ["/oauth/callback", "/oauth/bind"]) {
for (const transition of ["login", "logout"]) {
for (const outcome of ["jwt", "bind", "error"]) {
test(
`${pagePath} ignores older ${outcome} after cross-tab ${transition}`,
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
const started = deferred();
const release = deferred();
await oauthBoundary(page, async (route, path) => {
if (path !== "/oauth/token") return false;
started.resolve();
await release.promise;
await respond(
route,
outcome === "jwt"
? { jwt: "test-older-oauth-session" }
: { url: "/oauth/bind" },
outcome === "error" ? 422 : 200,
outcome === "error" ? "过期授权请求失败" : "",
);
return true;
});
await page.goto(`${appOrigin}${pagePath}`);
await started.promise;
await changeSession(page, transition);
release.resolve();
const retry = page.getByRole("button", {
name:
pagePath === "/oauth/callback" ? "重新验证" : "验证授权会话",
exact: true,
});
await eventually(
() => retry.isEnabled().catch(() => false),
Boolean,
"The old request must finish without navigating or enabling its old binding flow",
);
assert.equal(new URL(page.url()).pathname, pagePath);
assert.equal(await page.getByRole("alert").count(), 0);
assert.equal(
await page.getByLabel("手机号", { exact: true }).count(),
0,
);
await sessionIsPreserved(page, transition);
return {
transition,
outcome,
newerSessionPreserved: true,
noOldNavigation: true,
noOldError: true,
noOldBindingForm: true,
};
}),
);
}
}
}
for (const transition of ["login", "logout"]) {
test(
`OAuth binding submission ignores its late session after cross-tab ${transition}`,
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
const started = deferred();
const release = deferred();
await oauthBoundary(page, async (route, path) => {
if (path === "/oauth/token") {
await respond(route, { url: "/oauth/bind" });
return true;
}
if (path === "/oauth/client/bind") {
started.resolve();
await release.promise;
await respond(route, { jwt: "test-older-oauth-session" });
return true;
}
return false;
});
await page.goto(`${appOrigin}/oauth/bind?redirect=/security/ssh-keys`);
await page.getByLabel("手机号", { exact: true }).fill("13800138000");
await page.getByLabel("验证码", { exact: true }).fill("123456");
await page.getByRole("checkbox").check();
const submit = page.getByRole("button", {
name: "确认绑定",
exact: true,
});
await submit.click();
await started.promise;
await changeSession(page, transition);
release.resolve();
await eventually(
() => submit.isEnabled().catch(() => false),
Boolean,
"The old binding must finish without replacing the newer session",
);
assert.equal(new URL(page.url()).pathname, "/oauth/bind");
await sessionIsPreserved(page, transition);
return {
transition,
lateBindingIgnored: true,
newerSessionPreserved: true,
};
}),
);
}
async function wechatBoundary(page, statusHandler) {
await oauthBoundary(page, async (route, path) => {
if (path === "/common") {
await respond(route, {
website_name: "本地扫码验收",
login_phone_password: 1,
login_email_password: 1,
login_phone_verify: 1,
register_phone: 1,
register_email: 1,
plugin_configuration: { mp_weixin_notice: { scan_login: 1 } },
oauth: [],
});
return true;
}
if (path === "/mp_weixin_notice/qrcode") {
await respond(route, {
img_url:
"data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==",
ticket: "local-qr-ticket",
token: "local-qr-token",
});
return true;
}
return statusHandler(route, path);
});
}
async function requestWechat(page) {
await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`);
await page.getByRole("button", { name: "获取二维码", exact: true }).click();
await page.getByRole("img", { name: "微信扫码登录", exact: true }).waitFor();
}
test(
"a current WeChat scan establishes its session normally",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
await wechatBoundary(page, async (route, path) => {
if (path !== "/mp_weixin_notice/qrcode/status") return false;
await respond(route, { status: "Success", jwt: "e2e-session-token" });
return true;
});
await requestWechat(page);
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
await page.getByText("audit-01", { exact: true }).waitFor();
await sessionIsPreserved(page, "login");
return { currentWechatSessionAccepted: true };
}),
);
test(
"a current WeChat account selection establishes its session normally",
{ timeout: 25000 },
(t) =>
scenario(t, {}, async (page) => {
await wechatBoundary(page, async (route, path) => {
if (path === "/mp_weixin_notice/qrcode/status") {
await respond(route, {
status: "SelectClient",
client: [{ id: 1, username: "current-qr-account", status: 1 }],
});
return true;
}
if (path === "/mp_weixin_notice/qrcode/select_client_login") {
await respond(route, { jwt: "e2e-session-token" });
return true;
}
return false;
});
await requestWechat(page);
await page.locator(".qr-client-btn").click();
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
await page.getByText("audit-01", { exact: true }).waitFor();
await sessionIsPreserved(page, "login");
return { currentWechatSelectionAccepted: true };
}),
);
for (const outcome of ["Success", "WaitBind", "SelectClient"]) {
test(
`WeChat ${outcome} result cannot resume its old scan after a newer session`,
{ timeout: 30000 },
(t) =>
scenario(t, {}, async (page) => {
const started = deferred();
const release = deferred();
const transition = outcome === "SelectClient" ? "logout" : "login";
await wechatBoundary(page, async (route, path) => {
if (path === "/mp_weixin_notice/qrcode/status") {
if (outcome === "SelectClient") {
await respond(route, {
status: "SelectClient",
client: [{ id: 99, username: "older-qr-account", status: 1 }],
});
} else {
started.resolve();
await release.promise;
await respond(route, {
status: outcome,
...(outcome === "Success"
? { jwt: "test-older-oauth-session" }
: {}),
});
}
return true;
}
if (path === "/mp_weixin_notice/qrcode/select_client_login") {
started.resolve();
await release.promise;
await respond(route, { jwt: "test-older-oauth-session" });
return true;
}
return false;
});
await requestWechat(page);
if (outcome === "SelectClient")
await page.locator(".qr-client-btn").click();
await started.promise;
await changeSession(page, transition);
const completed = page.waitForResponse((response) =>
response
.url()
.endsWith(
outcome === "SelectClient"
? "/mp_weixin_notice/qrcode/select_client_login"
: "/mp_weixin_notice/qrcode/status?ticket=local-qr-ticket&token=local-qr-token",
),
);
release.resolve();
await (await completed).finished();
// 网络完成后跨过一次实际渲染,观察迟到结果,而非在释放响应前就读取已清空的二维码。
await page.evaluate(
() =>
new Promise((done) =>
requestAnimationFrame(() => requestAnimationFrame(done)),
),
);
await page
.getByRole("button", { name: "获取二维码", exact: true })
.waitFor();
assert.equal(new URL(page.url()).pathname, "/login");
assert.equal(await page.locator(".qr-client-btn").count(), 0);
assert.equal(
await page.evaluate(
() => sessionStorage.getItem("console.wechat-binding") === null,
),
true,
);
await sessionIsPreserved(page, transition);
return {
outcome,
transition,
oldScanDiscarded: true,
newerSessionPreserved: true,
};
}),
);
}