951 lines
33 KiB
JavaScript
951 lines
33 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",
|
|
);
|
|
}
|
|
|
|
async function genericOAuthBoundary(page, handle = async () => false) {
|
|
// 外部授权页与后端回调均通过真实弹窗访问;回调遵循生产的 about:blank + close 协议。
|
|
await page.context().route(`${backendOrigin}/oauth-provider`, (route) =>
|
|
route.fulfill({
|
|
contentType: "text/html",
|
|
body: `<meta charset="utf-8"><form action="${backendOrigin}/console/v1/oauth/callback/testprovider"><button>确认本地授权</button></form>`,
|
|
}),
|
|
);
|
|
await oauthBoundary(page, async (route, path) => {
|
|
if (await handle(route, path)) return true;
|
|
if (path === "/common") {
|
|
await respond(route, {
|
|
website_name: "本地授权验收",
|
|
login_phone_password: 1,
|
|
login_email_password: 1,
|
|
register_phone: 1,
|
|
register_email: 1,
|
|
oauth: [{ name: "testprovider", title: "本地授权" }],
|
|
});
|
|
return true;
|
|
}
|
|
if (path === "/oauth/testprovider") {
|
|
await respond(route, { url: `${backendOrigin}/oauth-provider` });
|
|
return true;
|
|
}
|
|
if (path === "/oauth/callback/testprovider") {
|
|
await route.fulfill({
|
|
contentType: "text/html",
|
|
body: '<script>window.location.href="about:blank";window.close()</script>',
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
async function openGenericOAuth(page, button = "本地授权登录") {
|
|
const opened = page.waitForEvent("popup", { timeout: 2500 });
|
|
await page.getByRole("button", { name: button, exact: true }).click();
|
|
const popup = await opened;
|
|
await popup.waitForURL(`${backendOrigin}/oauth-provider`);
|
|
assert.equal(await popup.evaluate(() => window.opener === null), true);
|
|
return popup;
|
|
}
|
|
|
|
test(
|
|
"generic OAuth login completes from its real popup and preserves the requested page",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
let tokenReads = 0;
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path !== "/oauth/token") return false;
|
|
tokenReads += 1;
|
|
await respond(route, { jwt: "e2e-session-token" });
|
|
return true;
|
|
});
|
|
await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`);
|
|
const popup = await openGenericOAuth(page);
|
|
assert.equal(new URL(page.url()).pathname, "/login");
|
|
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
|
await page.waitForURL((url) => url.pathname === "/security/ssh-keys");
|
|
await page.getByText("audit-01", { exact: true }).waitFor();
|
|
await sessionIsPreserved(page, "login");
|
|
assert.equal(popup.isClosed(), true);
|
|
assert.equal(tokenReads, 1);
|
|
return { popupClosed: true, requestedPageRestored: true, tokenReads };
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"generic OAuth continues a verified binding and keeps the requested page",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path === "/oauth/token") {
|
|
await respond(route, { url: `${backendOrigin}/oauth.htm` });
|
|
return true;
|
|
}
|
|
if (path === "/oauth/client/bind") {
|
|
await respond(route, { jwt: "e2e-session-token" });
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
await page.goto(`${appOrigin}/login?redirect=/security/ssh-keys`);
|
|
const popup = await openGenericOAuth(page);
|
|
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
|
await page.waitForURL((url) => url.pathname === "/oauth/bind");
|
|
assert.equal(
|
|
new URL(page.url()).searchParams.get("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 sessionIsPreserved(page, "login");
|
|
return { bindingVerified: true, requestedPageRestored: true };
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"closing a generic OAuth popup requires server confirmation and permits retry",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
let tokenReads = 0;
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path !== "/oauth/token") return false;
|
|
tokenReads += 1;
|
|
await respond(route, {});
|
|
return true;
|
|
});
|
|
await page.goto(`${appOrigin}/login`);
|
|
const popup = await openGenericOAuth(page);
|
|
await popup.close();
|
|
await page.getByRole("alert").waitFor();
|
|
assert.equal(new URL(page.url()).pathname, "/login");
|
|
await sessionIsPreserved(page, "logout");
|
|
assert.equal(tokenReads, 1);
|
|
const retry = await openGenericOAuth(page);
|
|
await retry.close();
|
|
await eventually(
|
|
() => page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
|
Boolean,
|
|
"Cancelled authorization must permit another attempt",
|
|
);
|
|
assert.equal(tokenReads, 2);
|
|
return { noUnconfirmedLogin: true, retryAllowed: true, tokenReads };
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"generic OAuth expires its external popup without consuming a later result",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
let tokenReads = 0;
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path !== "/oauth/token") return false;
|
|
tokenReads += 1;
|
|
await respond(route, { jwt: "e2e-session-token" });
|
|
return true;
|
|
});
|
|
await page.goto(`${appOrigin}/login`);
|
|
const popup = await openGenericOAuth(page);
|
|
// 只推进外部墙上时钟,保留浏览器真实轮询调度,避免混用已启动页面的虚拟计时器。
|
|
await page.clock.setFixedTime(
|
|
await page.evaluate(() => Date.now() + 10 * 60 * 1000 + 1),
|
|
);
|
|
await page.getByRole("alert").waitFor();
|
|
assert.equal(
|
|
await page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
|
true,
|
|
);
|
|
assert.equal(popup.isClosed(), false);
|
|
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
|
await eventually(
|
|
() => popup.isClosed(),
|
|
Boolean,
|
|
"The external callback must close its own popup",
|
|
);
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((done) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(done)),
|
|
),
|
|
);
|
|
await sessionIsPreserved(page, "logout");
|
|
assert.equal(tokenReads, 0);
|
|
return {
|
|
expiredAttemptReleased: true,
|
|
lateCallbackIgnored: true,
|
|
noResultConsumed: true,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"generic OAuth cleans up a blocked popup without starting server authorization",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
let authorizations = 0;
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path === "/oauth/testprovider") authorizations += 1;
|
|
return false;
|
|
});
|
|
await page.goto(`${appOrigin}/login`);
|
|
// 浏览器能力是外部边界:模拟弹窗策略返回 null,不替换应用内部模块。
|
|
await page.evaluate(() => {
|
|
window.open = () => null;
|
|
});
|
|
await page.getByRole("button", { name: "本地授权登录" }).click();
|
|
await page.getByRole("alert").waitFor();
|
|
assert.equal(
|
|
await page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
|
true,
|
|
);
|
|
assert.equal(authorizations, 0);
|
|
await sessionIsPreserved(page, "logout");
|
|
return { blockedPopupReported: true, noAuthorizationStarted: true };
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"generic OAuth rejects an unsafe authorization URL and closes its blank popup",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path !== "/oauth/testprovider") return false;
|
|
await respond(route, { url: "javascript:window.name='unsafe'" });
|
|
return true;
|
|
});
|
|
await page.goto(`${appOrigin}/login`);
|
|
const opened = page.waitForEvent("popup");
|
|
await page.getByRole("button", { name: "本地授权登录" }).click();
|
|
const popup = await opened;
|
|
await page.getByRole("alert").waitFor();
|
|
await eventually(
|
|
() => popup.isClosed(),
|
|
Boolean,
|
|
"An unsafe authorization address must close its popup",
|
|
);
|
|
assert.equal(new URL(page.url()).pathname, "/login");
|
|
await sessionIsPreserved(page, "logout");
|
|
return { unsafeUrlRejected: true, popupClosed: true };
|
|
}),
|
|
);
|
|
|
|
test(
|
|
"generic OAuth discards a pending login result after leaving its page",
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
const started = deferred();
|
|
const release = deferred();
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path !== "/oauth/token") return false;
|
|
started.resolve();
|
|
await release.promise;
|
|
await respond(route, { jwt: "test-older-oauth-session" });
|
|
return true;
|
|
});
|
|
await page.goto(`${appOrigin}/login`);
|
|
const popup = await openGenericOAuth(page);
|
|
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
|
await started.promise;
|
|
await page.getByRole("link", { name: "注册账号", exact: true }).click();
|
|
await page.waitForURL((url) => url.pathname === "/register");
|
|
const response = page.waitForResponse((entry) =>
|
|
entry.url().endsWith("/oauth/token"),
|
|
);
|
|
release.resolve();
|
|
await (await response).finished();
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((done) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(done)),
|
|
),
|
|
);
|
|
assert.equal(new URL(page.url()).pathname, "/register");
|
|
await sessionIsPreserved(page, "logout");
|
|
return { unmountedResultIgnored: true, noOldNavigation: true };
|
|
}),
|
|
);
|
|
|
|
for (const abandon of ["close", "leave", "login", "logout"]) {
|
|
test(
|
|
`generic OAuth ignores a late authorization URL after ${abandon}`,
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
const started = deferred();
|
|
const release = deferred();
|
|
let tokenReads = 0;
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path === "/oauth/testprovider") {
|
|
started.resolve();
|
|
await release.promise;
|
|
await respond(route, { url: `${backendOrigin}/oauth-provider` });
|
|
return true;
|
|
}
|
|
if (path === "/oauth/token") {
|
|
tokenReads += 1;
|
|
await respond(route, { jwt: "test-older-oauth-session" });
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
await page.goto(`${appOrigin}/login`);
|
|
const opened = page.waitForEvent("popup");
|
|
await page.getByRole("button", { name: "本地授权登录" }).click();
|
|
const popup = await opened;
|
|
await started.promise;
|
|
if (abandon === "close") await popup.close();
|
|
else if (abandon === "leave")
|
|
await page
|
|
.getByRole("link", { name: "注册账号", exact: true })
|
|
.click();
|
|
else await changeSession(page, abandon);
|
|
await eventually(
|
|
() => popup.isClosed(),
|
|
Boolean,
|
|
"Abandoned authorization must close its popup",
|
|
);
|
|
const response = page.waitForResponse((entry) =>
|
|
entry.url().endsWith("/oauth/testprovider"),
|
|
);
|
|
release.resolve();
|
|
await (await response).finished();
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((done) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(done)),
|
|
),
|
|
);
|
|
assert.equal(
|
|
new URL(page.url()).pathname,
|
|
abandon === "leave" ? "/register" : "/login",
|
|
);
|
|
await sessionIsPreserved(
|
|
page,
|
|
abandon === "login" ? "login" : "logout",
|
|
);
|
|
assert.equal(tokenReads, 0);
|
|
assert.equal(await page.getByRole("alert").count(), 0);
|
|
return { abandon, lateUrlIgnored: true, popupClosed: true, tokenReads };
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const transition of ["login", "logout"]) {
|
|
for (const outcome of ["jwt", "bind", "error"]) {
|
|
test(
|
|
`generic OAuth ignores a late ${outcome} after cross-tab ${transition}`,
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
const started = deferred();
|
|
const release = deferred();
|
|
await genericOAuthBoundary(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}/login`);
|
|
const popup = await openGenericOAuth(page);
|
|
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
|
await started.promise;
|
|
await changeSession(page, transition);
|
|
const response = page.waitForResponse((entry) =>
|
|
entry.url().endsWith("/oauth/token"),
|
|
);
|
|
release.resolve();
|
|
await (await response).finished();
|
|
await eventually(
|
|
() =>
|
|
page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
|
Boolean,
|
|
"A stale authorization must release its login button",
|
|
);
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((done) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(done)),
|
|
),
|
|
);
|
|
assert.equal(new URL(page.url()).pathname, "/login");
|
|
assert.equal(await page.getByRole("alert").count(), 0);
|
|
await sessionIsPreserved(page, transition);
|
|
return {
|
|
transition,
|
|
outcome,
|
|
lateResultIgnored: true,
|
|
newerSessionPreserved: true,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const abandon of ["leave", "login", "logout"]) {
|
|
test(
|
|
`generic OAuth does not consume a popup closed after ${abandon}`,
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
let tokenReads = 0;
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path !== "/oauth/token") return false;
|
|
tokenReads += 1;
|
|
await respond(route, { jwt: "test-older-oauth-session" });
|
|
return true;
|
|
});
|
|
await page.goto(`${appOrigin}/login`);
|
|
const popup = await openGenericOAuth(page);
|
|
if (abandon === "leave") {
|
|
await page
|
|
.getByRole("link", { name: "注册账号", exact: true })
|
|
.click();
|
|
await page.waitForURL((url) => url.pathname === "/register");
|
|
} else {
|
|
await changeSession(page, abandon);
|
|
await eventually(
|
|
() =>
|
|
page.getByRole("button", { name: "本地授权登录" }).isEnabled(),
|
|
Boolean,
|
|
"A newer session must release the old authorization attempt",
|
|
);
|
|
}
|
|
// 外部窗口保留 opener 隔离;完成旧回调后,已结束的发起页也不得再消费结果。
|
|
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
|
await eventually(
|
|
() => popup.isClosed(),
|
|
Boolean,
|
|
"The old callback must close its own popup",
|
|
);
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((done) =>
|
|
requestAnimationFrame(() => requestAnimationFrame(done)),
|
|
),
|
|
);
|
|
assert.equal(
|
|
new URL(page.url()).pathname,
|
|
abandon === "leave" ? "/register" : "/login",
|
|
);
|
|
assert.equal(await page.getByRole("alert").count(), 0);
|
|
await sessionIsPreserved(
|
|
page,
|
|
abandon === "login" ? "login" : "logout",
|
|
);
|
|
assert.equal(tokenReads, 0);
|
|
return { abandon, endedAttemptNotResumed: true, tokenReads };
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const completed of [true, false]) {
|
|
test(
|
|
`generic OAuth verifies a ${completed ? "completed" : "cancelled"} authenticated link without consuming a login token`,
|
|
{ timeout: 25000 },
|
|
(t) =>
|
|
scenario(t, {}, async (page) => {
|
|
let linked = false;
|
|
let tokenReads = 0;
|
|
await genericOAuthBoundary(page, async (route, path) => {
|
|
if (path === "/account") {
|
|
await respond(route, {
|
|
account: {
|
|
id: 1,
|
|
username: "audit-user",
|
|
oauth: [
|
|
{
|
|
name: "testprovider",
|
|
title: "本地授权",
|
|
link: linked ? 1 : 0,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
return true;
|
|
}
|
|
if (path === "/oauth/callback/testprovider") linked = true;
|
|
if (path === "/oauth/token") {
|
|
tokenReads += 1;
|
|
await respond(route, {});
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
await login(page, "/account/connections");
|
|
await page.getByText("未关联", { exact: true }).waitFor();
|
|
const popup = await openGenericOAuth(page, "关联账号");
|
|
if (completed) {
|
|
await popup.getByRole("button", { name: "确认本地授权" }).click();
|
|
await page.getByText("已关联", { exact: true }).waitFor();
|
|
assert.equal(
|
|
await page
|
|
.getByRole("button", { name: "取消关联", exact: true })
|
|
.isVisible(),
|
|
true,
|
|
);
|
|
} else {
|
|
await popup.close();
|
|
await page.getByRole("alert").waitFor();
|
|
await page.getByText("未关联", { exact: true }).waitFor();
|
|
assert.equal(
|
|
await page
|
|
.getByRole("button", { name: "关联账号", exact: true })
|
|
.isEnabled(),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await page
|
|
.getByRole("button", { name: "取消关联", exact: true })
|
|
.count(),
|
|
0,
|
|
);
|
|
}
|
|
assert.equal(tokenReads, 0);
|
|
await sessionIsPreserved(page, "login");
|
|
return {
|
|
serverLinkVerified: true,
|
|
completed,
|
|
tokenReads,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}),
|
|
);
|
|
}
|