session-refresh.mjs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import assert from 'node:assert/strict';
  2. import { mkdir, readFile, writeFile } from 'node:fs/promises';
  3. import { chromium } from 'playwright';
  4. // 仅使用真实身份接口与只读页面;会话撤销仅针对本脚本创建的会话。
  5. const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8'));
  6. const platformAccount = JSON.parse(await readFile(new URL('../../.work/platform-accounts.json', import.meta.url), 'utf8'));
  7. const base = (process.env.FRONTEND_URL || 'http://127.0.0.1:18762').replace(/\/$/, '');
  8. const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || 'C:/Users/Administrator/AppData/Local/ms-playwright/chromium-1228/chrome-win64/chrome.exe' });
  9. const context = await browser.newContext();
  10. const page = await context.newPage();
  11. page.setDefaultTimeout(12000);
  12. page.setDefaultNavigationTimeout(12000);
  13. const products = ['customer', 'merchant', 'platform'];
  14. const labels = { customer: 'H5', merchant: '商家后台', platform: '平台后台' };
  15. const targets = { customer: 'customer/orders', merchant: 'merchant/service/quick-replies', platform: 'platform/dashboard' };
  16. const usernames = { customer: 'customer01', merchant: 'staff01', platform: platformAccount.username };
  17. const tokens = new Map();
  18. const identities = new Map();
  19. const createdTokens = new Map();
  20. const checks = [], browserErrors = [], unexpectedWrites = [];
  21. const started = Date.now();
  22. const maximumApiRequests = 500;
  23. const maximumResourceRequests = 3000;
  24. let apiRequests = 0, resourceRequests = 0, modelRequests = 0, requestLimitExceeded = false;
  25. function authPath(product) {
  26. return product === 'platform' ? '/openapi/v1/platform/auth' : '/openapi/v1/auth';
  27. }
  28. function countApiRequest() {
  29. apiRequests += 1;
  30. if (apiRequests > maximumApiRequests) {
  31. requestLimitExceeded = true;
  32. throw new Error('真实 API 请求次数超过 500 次上限。');
  33. }
  34. }
  35. page.on('pageerror', error => browserErrors.push(error.message));
  36. page.on('request', request => {
  37. resourceRequests += 1;
  38. if (resourceRequests > maximumResourceRequests) {
  39. requestLimitExceeded = true;
  40. void page.close();
  41. return;
  42. }
  43. const path = new URL(request.url()).pathname;
  44. if (!path.startsWith('/openapi/') && !path.startsWith('/admin/')) return;
  45. // 达到上限时终止浏览器连接;不继续产生后续业务请求。
  46. if (apiRequests >= maximumApiRequests) {
  47. requestLimitExceeded = true;
  48. void page.close();
  49. return;
  50. }
  51. countApiRequest();
  52. if (!['GET', 'HEAD'].includes(request.method()) && !/^\/openapi\/v1\/(platform\/)?auth\//.test(path)) unexpectedWrites.push(`${request.method()} ${path}`);
  53. if (request.method() === 'POST' && (/\/agent\/runs$/.test(path) || /\/messages$/.test(path))) modelRequests += 1;
  54. });
  55. async function route(path) {
  56. await page.evaluate(value => { location.hash = `/${value}`; }, path);
  57. await page.waitForURL(url => url.hash === `#/${path}` || url.hash === `#/${path.split('/')[0]}/login`);
  58. }
  59. async function expectLogin(product) {
  60. await page.waitForURL(url => url.hash === `#/${product}/login`);
  61. if (product === 'customer') await page.locator('.customer-login-card').waitFor();
  62. else {
  63. await page.locator('.login-panel').waitFor();
  64. assert.equal(await page.locator('.admin-sidebar').count(), 0, `${labels[product]}登录页隐藏管理内容`);
  65. }
  66. }
  67. async function expectProtected(product) {
  68. await page.waitForURL(url => url.hash === `#/${targets[product]}`);
  69. if (product === 'customer') {
  70. await page.locator('.mobile[data-page="orders"]').waitFor();
  71. await page.waitForFunction(() => !document.querySelector('.mobile .content[aria-busy="true"]'));
  72. await page.locator('.order-card').filter({ hasText: accounts.order_id }).waitFor();
  73. assert.equal(await page.locator('.customer-login-card').count(), 0);
  74. } else {
  75. await page.locator('.admin-title').getByRole('heading', { name: product === 'merchant' ? '快捷回复' : '平台概览', exact: true }).waitFor();
  76. await page.locator('.admin-session').getByText(usernames[product], { exact: true }).waitFor();
  77. assert.equal(await page.locator('.login-panel').count(), 0);
  78. }
  79. }
  80. function identityResponse(product) {
  81. // 绑定本次操作新发起的请求,入口切换期间已有请求的响应不能成为刷新结果。
  82. const token = tokens.get(product);
  83. return page.waitForRequest(request => new URL(request.url()).pathname === `${authPath(product)}/me`
  84. && request.headers().authorization === `Bearer ${token}`)
  85. .then(async request => {
  86. const response = await request.response();
  87. assert.ok(response, `${labels[product]}身份查询取得真实 HTTP 响应`);
  88. return { status: response.status(), body: await response.json() };
  89. });
  90. }
  91. async function login(product) {
  92. await expectLogin(product);
  93. const panel = page.locator(product === 'customer' ? '.customer-login-card' : '.login-panel');
  94. if (product === 'merchant') await panel.getByLabel('商家标识').fill(accounts.tenant_id);
  95. await panel.getByLabel(product === 'customer' ? '账户' : '员工账户', { exact: true }).fill(usernames[product]);
  96. await panel.getByLabel('密码', { exact: true }).fill(product === 'platform' ? platformAccount.password : accounts.password);
  97. if (product === 'customer') await panel.getByRole('checkbox').check();
  98. const received = page.waitForResponse(response => new URL(response.url()).pathname === `${authPath(product)}/sessions` && response.request().method() === 'POST');
  99. await panel.getByRole('button', { name: product === 'customer' ? '登录并继续' : '登录', exact: true }).click();
  100. const response = await received;
  101. assert.equal(response.status(), 200, `${labels[product]}真实账户登录成功`);
  102. const auth = await response.json();
  103. tokens.set(product, auth.access_token);
  104. createdTokens.set(auth.access_token, product);
  105. await expectProtected(product);
  106. }
  107. async function reloadAndVerify(product) {
  108. await expectProtected(product);
  109. // 页面不建立 SSE;等待当前入口的身份与只读查询完成后再开始整页刷新。
  110. await page.waitForLoadState('networkidle');
  111. const before = page.url();
  112. const received = identityResponse(product);
  113. await page.reload();
  114. const response = await received;
  115. assert.equal(response.status, 200, `${labels[product]}刷新后重新验证真实身份`);
  116. const identity = response.body;
  117. if (product === 'platform') assert.equal(identity.username, usernames[product]);
  118. else {
  119. assert.equal(identity.kind, product === 'customer' ? 'CUSTOMER' : 'STAFF');
  120. assert.equal(identity.tenant_id, accounts.tenant_id);
  121. assert.ok(identity.grants.some(grant => grant.store_id === accounts.store_id));
  122. }
  123. if (identities.has(product)) assert.equal(identity.actor_id, identities.get(product));
  124. else identities.set(product, identity.actor_id);
  125. await expectProtected(product);
  126. assert.equal(page.url(), before, `${labels[product]}刷新保留原地址`);
  127. }
  128. async function revoke(product, token = tokens.get(product)) {
  129. countApiRequest();
  130. const response = await context.request.delete(`${base}${authPath(product)}/sessions/current`, {
  131. headers: { Authorization: `Bearer ${token}` }, timeout: 3000,
  132. });
  133. assert.equal(response.status(), 204, `${labels[product]}独立 HTTP 客户端撤销测试会话`);
  134. createdTokens.delete(token);
  135. }
  136. async function checkNetworkRecovery(product) {
  137. await route(targets[product]);
  138. await expectProtected(product);
  139. const network = await context.newCDPSession(page);
  140. await network.send('Network.enable');
  141. // 只阻断 API 连接,静态页面照常加载;不拦截或替换服务端响应。
  142. await network.send('Network.setBlockedURLs', { urls: ['*/openapi/v1/*', '*/admin/v1/*'] });
  143. try {
  144. await page.reload();
  145. const retry = page.getByRole('button', { name: '重新验证登录', exact: true });
  146. await retry.waitFor();
  147. assert.equal(new URL(page.url()).hash, `#/${targets[product]}`, '身份验证失败时保留当前地址');
  148. assert.equal(await page.locator('.login-panel, .customer-login-card, .admin-sidebar, .order-card').count(), 0, '恢复失败期间不展示登录表单或受保护内容');
  149. await network.send('Network.setBlockedURLs', { urls: [] });
  150. const received = identityResponse(product);
  151. await retry.click();
  152. assert.equal((await received).status, 200);
  153. await expectProtected(product);
  154. checks.push(`${labels[product]}:API 连接中断后刷新保留凭据,恢复网络后手动重新验证`);
  155. } finally {
  156. await network.send('Network.setBlockedURLs', { urls: [] });
  157. await network.detach();
  158. }
  159. }
  160. async function logout(product) {
  161. await route(product === 'customer' ? 'customer/memory' : targets[product]);
  162. const received = page.waitForResponse(response => new URL(response.url()).pathname === `${authPath(product)}/sessions/current` && response.request().method() === 'DELETE');
  163. if (product === 'customer') await page.locator('.mobile').getByRole('button', { name: '退出登录', exact: true }).click();
  164. else {
  165. await page.locator('.admin-session').getByRole('button', { name: '退出登录', exact: true }).click();
  166. await page.getByRole('dialog').getByRole('button', { name: '确认操作', exact: true }).click();
  167. }
  168. assert.equal((await received).status(), 204);
  169. createdTokens.delete(tokens.get(product));
  170. tokens.delete(product);
  171. await expectLogin(product);
  172. await page.reload();
  173. await expectLogin(product);
  174. await route(targets[product]);
  175. await expectLogin(product);
  176. }
  177. async function checkUntrustedStorage() {
  178. await route(targets.merchant);
  179. await expectProtected('merchant');
  180. // 浏览器缓存允许被改写;额外权限字段不能改变真实服务端身份。
  181. await page.evaluate(() => {
  182. const key = 'zhifu.session.merchant';
  183. const credential = JSON.parse(sessionStorage.getItem(key));
  184. sessionStorage.setItem(key, JSON.stringify({ ...credential, role: 'supervisor', permissions: ['approvals:decide'], id: 'untrusted-actor', actorId: 'untrusted-actor', storeId: 'untrusted-store' }));
  185. });
  186. await reloadAndVerify('merchant');
  187. assert.equal(await page.getByRole('navigation', { name: '系统导航' }).getByRole('button', { name: '审批待办', exact: true }).count(), 0);
  188. await route('merchant/approval/review');
  189. await page.getByRole('heading', { name: '页面不可访问', exact: true }).waitFor();
  190. assert.equal(await page.getByRole('button', { name: '提交审批决定', exact: true }).count(), 0);
  191. checks.push('商家缓存附加身份和权限字段后,刷新仍按真实客服权限显示菜单并阻止审批页面');
  192. await route(targets.customer);
  193. await expectProtected('customer');
  194. const previousToken = tokens.get('customer');
  195. await page.evaluate(() => { sessionStorage.setItem('zhifu.session.customer', '{invalid-json'); });
  196. await page.reload();
  197. await page.getByText('登录记录无法读取,请重新验证登录。', { exact: true }).waitFor();
  198. await page.getByRole('button', { name: '重新验证登录', exact: true }).click();
  199. await expectLogin('customer');
  200. await revoke('customer', previousToken);
  201. for (const product of ['merchant', 'platform']) {
  202. await route(targets[product]);
  203. await expectProtected(product);
  204. }
  205. await route(targets.customer);
  206. await login('customer');
  207. checks.push('H5 缓存 JSON 损坏后显示恢复错误,手动重新验证可以正常登录,其他入口保持身份');
  208. }
  209. async function run() {
  210. await page.goto(`${base}/#/${targets.customer}`);
  211. for (const product of products) {
  212. if (product !== 'customer') await route(targets[product]);
  213. await login(product);
  214. await reloadAndVerify(product);
  215. checks.push(`${labels[product]}:真实登录后刷新保留身份与原页面地址`);
  216. }
  217. for (const product of products) {
  218. await route(targets[product]);
  219. await reloadAndVerify(product);
  220. }
  221. checks.push('同一标签页的三个入口分别恢复本人身份,切换入口并刷新保持独立会话');
  222. for (const product of products) await checkNetworkRecovery(product);
  223. for (const product of products) {
  224. await route(targets[product]);
  225. await expectProtected(product);
  226. await revoke(product);
  227. await page.waitForLoadState('networkidle');
  228. const received = identityResponse(product);
  229. await page.reload();
  230. assert.equal((await received).status, 401);
  231. await expectLogin(product);
  232. await page.reload();
  233. await expectLogin(product);
  234. for (const other of products.filter(value => value !== product)) {
  235. await route(targets[other]);
  236. await expectProtected(other);
  237. }
  238. await route(targets[product]);
  239. await login(product);
  240. checks.push(`${labels[product]}:服务端撤销后刷新要求登录,其他入口会话继续有效`);
  241. }
  242. await checkUntrustedStorage();
  243. for (const product of products) {
  244. await logout(product);
  245. for (const other of products.filter(value => tokens.has(value))) {
  246. await route(targets[other]);
  247. await expectProtected(other);
  248. }
  249. checks.push(`${labels[product]}:主动退出后刷新与访问业务页均需登录,其他有效入口保持登录`);
  250. }
  251. assert.deepEqual(browserErrors, [], '浏览器运行异常');
  252. assert.deepEqual(unexpectedWrites, [], '整个流程仅允许创建、读取和撤销登录会话');
  253. assert.equal(modelRequests, 0, '生成模型调用次数');
  254. assert.equal(requestLimitExceeded, false);
  255. const result = {
  256. date: new Date().toISOString(), checks, browserErrors,
  257. apiRequests, resourceRequests, maximumApiRequests, maximumResourceRequests,
  258. elapsedMs: Date.now() - started, maximumDurationMs: 180000,
  259. generatedModelCalls: 0, businessWrites: 0,
  260. networkFailure: 'Chromium 阻断 API 连接后刷新,再解除阻断并手动重新验证登录。',
  261. };
  262. await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true });
  263. await writeFile(new URL('../../.work/frontend-verification/session-refresh.json', import.meta.url), JSON.stringify(result, null, 2));
  264. console.log(JSON.stringify(result, null, 2));
  265. }
  266. let deadline;
  267. try {
  268. await Promise.race([
  269. run(),
  270. new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error('会话刷新验证超过 180 秒上限。')), 180000); }),
  271. ]);
  272. } finally {
  273. clearTimeout(deadline);
  274. for (const [token, product] of createdTokens) await revoke(product, token);
  275. await browser.close();
  276. }