Files
DadaoTask/web/e2e/session-refresh.mjs
Steven 2c338490e5
CI / backend (push) Canceled after 0s
fix(auth): 令牌到期自动续上,不再用半小时就被踢回登录页
服务端一直签 access(30 分钟)和 refresh(14 天)两个令牌,但两个客户端拿到 401 都是直接清
令牌跳登录页,refresh 从来没被用过——"14 天有效"只是纸面参数,真实寿命就是 30 分钟。

- web/client.ts:请求前剩不到 90 秒先换;撞 401 先换再重放一次原请求;并发共用一次刷新;
  上传附件同一条路
- android/Api.kt:call 拆 call/callOnce,2002 时用 Mutex 串行刷新后重放
- 后端 /auth/refresh 看一眼账号状态:停用的账号不能靠 refresh 一直续(2012)
- 新增 session-refresh.mjs:假 access + 真 refresh 要能留住人,两个都假才回登录页
2026-11-03 22:53:43 +08:00

116 lines
4.2 KiB
JavaScript

/**
* 登录态续期的验收:访问令牌过期时,界面不该把人踢回登录页。
*
* 背景:access 只有 30 分钟,refresh 有 14 天,但以前前端拿到 401 就直接清令牌跳登录页,
* refresh 存着没人用——用户的感觉就是"用一会儿就被踢出去"。
* 现在前端会先用 refresh 换一对令牌、把原请求重放一次,换不动才清。
*
* 这里不能造一个"真的过期"的签名令牌,所以把 access 换成一串假的:
* 服务端照样回 401,走的还是同一条续期分支。
*
* 用法:node web/e2e/session-refresh.mjs
*/
import { chromium } from "playwright-core";
import { readFileSync } from "node:fs";
const WEB = process.env.WEB ?? "http://127.0.0.1:5180";
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
const PHONE = process.env.PHONE ?? "13900000001";
const CHROME =
process.env.CHROME ??
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url);
let pass = 0;
let fail = 0;
function check(name, ok, extra = "") {
if (ok) {
pass += 1;
console.log(`PASS ${name}`);
} else {
fail += 1;
console.log(`FAIL ${name}${extra ? ` — ${extra}` : ""}`);
}
}
const password = (() => {
if (process.env.PASSWORD) return process.env.PASSWORD;
try {
return readFileSync(PWD_FILE, "utf8").trim();
} catch {
return "";
}
})();
if (!password) throw new Error("没有密码:设 PASSWORD,或先跑一遍 account.mjs 生成 .local/e2e-account-password.txt");
async function login() {
const r = await fetch(`${API}/auth/login/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: PHONE, password }),
});
const body = await r.json();
if (body.code !== 0) throw new Error(`登录失败:${body.code} ${body.message}`);
return body.data.tokens;
}
const tokens = await login();
const browser = await chromium.launch({ executablePath: CHROME });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const errors = [];
page.on("pageerror", (e) => errors.push(String(e)));
// ① 假 access + 真 refresh:应该自己续上,留在首页
await page.addInitScript(
([access, refresh]) => {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
["bogus.access.token", tokens.refresh_token],
);
await page.goto(`${WEB}/`, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
check("令牌过期也不会被踢回登录页", !page.url().includes("/login"), page.url());
check(
"首页照常渲染(能看到工作区列表或新建入口)",
(await page.getByText(/我的工作区|新建工作区/).first().isVisible().catch(() => false)) === true,
);
const afterRefresh = await page.evaluate(() => localStorage.getItem("dadao.access"));
check("拿到的是一对新的访问令牌", Boolean(afterRefresh) && afterRefresh !== "bogus.access.token");
// 新令牌要真能用:再请求一次业务接口
const probe = await page.evaluate(async () => {
const token = localStorage.getItem("dadao.access");
const r = await fetch("/api/v1/auth/me", { headers: { Authorization: `Bearer ${token}` } });
return r.status;
});
check("新令牌能直接调业务接口", probe === 200, `HTTP ${probe}`);
// ② access 与 refresh 都是假的:这次才该回登录页
const page2 = await context.newPage();
await page2.addInitScript(() => {
localStorage.setItem("dadao.access", "bogus.access.token");
localStorage.setItem("dadao.refresh", "bogus.refresh.token");
});
await page2.goto(`${WEB}/`, { waitUntil: "networkidle" });
await page2.waitForTimeout(800);
check(
"连 refresh 都失效时,才回到登录页",
page2.url().includes("/login") ||
(await page2.getByText(/手机号|邮箱/).first().isVisible().catch(() => false)) === true,
page2.url(),
);
check("控制台没有未捕获异常", errors.length === 0, errors.join(" | "));
await page.screenshot({ path: "e2e/artifacts/session-refresh.png" });
await browser.close();
console.log(fail === 0 ? "ALL PASS" : `${fail} FAILED`);
process.exit(fail === 0 ? 0 : 1);