import assert from 'node:assert/strict'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { chromium } from 'playwright'; // 仅使用真实身份接口与只读页面;会话撤销仅针对本脚本创建的会话。 const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8')); const platformAccount = JSON.parse(await readFile(new URL('../../.work/platform-accounts.json', import.meta.url), 'utf8')); const base = (process.env.FRONTEND_URL || 'http://127.0.0.1:18762').replace(/\/$/, ''); 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' }); const context = await browser.newContext(); const page = await context.newPage(); page.setDefaultTimeout(12000); page.setDefaultNavigationTimeout(12000); const products = ['customer', 'merchant', 'platform']; const labels = { customer: 'H5', merchant: '商家后台', platform: '平台后台' }; const targets = { customer: 'customer/orders', merchant: 'merchant/service/quick-replies', platform: 'platform/dashboard' }; const usernames = { customer: 'customer01', merchant: 'staff01', platform: platformAccount.username }; const tokens = new Map(); const identities = new Map(); const createdTokens = new Map(); const checks = [], browserErrors = [], unexpectedWrites = []; const started = Date.now(); const maximumApiRequests = 500; const maximumResourceRequests = 3000; let apiRequests = 0, resourceRequests = 0, modelRequests = 0, requestLimitExceeded = false; function authPath(product) { return product === 'platform' ? '/openapi/v1/platform/auth' : '/openapi/v1/auth'; } function countApiRequest() { apiRequests += 1; if (apiRequests > maximumApiRequests) { requestLimitExceeded = true; throw new Error('真实 API 请求次数超过 500 次上限。'); } } page.on('pageerror', error => browserErrors.push(error.message)); page.on('request', request => { resourceRequests += 1; if (resourceRequests > maximumResourceRequests) { requestLimitExceeded = true; void page.close(); return; } const path = new URL(request.url()).pathname; if (!path.startsWith('/openapi/') && !path.startsWith('/admin/')) return; // 达到上限时终止浏览器连接;不继续产生后续业务请求。 if (apiRequests >= maximumApiRequests) { requestLimitExceeded = true; void page.close(); return; } countApiRequest(); if (!['GET', 'HEAD'].includes(request.method()) && !/^\/openapi\/v1\/(platform\/)?auth\//.test(path)) unexpectedWrites.push(`${request.method()} ${path}`); if (request.method() === 'POST' && (/\/agent\/runs$/.test(path) || /\/messages$/.test(path))) modelRequests += 1; }); async function route(path) { await page.evaluate(value => { location.hash = `/${value}`; }, path); await page.waitForURL(url => url.hash === `#/${path}` || url.hash === `#/${path.split('/')[0]}/login`); } async function expectLogin(product) { await page.waitForURL(url => url.hash === `#/${product}/login`); if (product === 'customer') await page.locator('.customer-login-card').waitFor(); else { await page.locator('.login-panel').waitFor(); assert.equal(await page.locator('.admin-sidebar').count(), 0, `${labels[product]}登录页隐藏管理内容`); } } async function expectProtected(product) { await page.waitForURL(url => url.hash === `#/${targets[product]}`); if (product === 'customer') { await page.locator('.mobile[data-page="orders"]').waitFor(); await page.waitForFunction(() => !document.querySelector('.mobile .content[aria-busy="true"]')); await page.locator('.order-card').filter({ hasText: accounts.order_id }).waitFor(); assert.equal(await page.locator('.customer-login-card').count(), 0); } else { await page.locator('.admin-title').getByRole('heading', { name: product === 'merchant' ? '快捷回复' : '平台概览', exact: true }).waitFor(); await page.locator('.admin-session').getByText(usernames[product], { exact: true }).waitFor(); assert.equal(await page.locator('.login-panel').count(), 0); } } function identityResponse(product) { // 绑定本次操作新发起的请求,入口切换期间已有请求的响应不能成为刷新结果。 const token = tokens.get(product); return page.waitForRequest(request => new URL(request.url()).pathname === `${authPath(product)}/me` && request.headers().authorization === `Bearer ${token}`) .then(async request => { const response = await request.response(); assert.ok(response, `${labels[product]}身份查询取得真实 HTTP 响应`); return { status: response.status(), body: await response.json() }; }); } async function login(product) { await expectLogin(product); const panel = page.locator(product === 'customer' ? '.customer-login-card' : '.login-panel'); if (product === 'merchant') await panel.getByLabel('商家标识').fill(accounts.tenant_id); await panel.getByLabel(product === 'customer' ? '账户' : '员工账户', { exact: true }).fill(usernames[product]); await panel.getByLabel('密码', { exact: true }).fill(product === 'platform' ? platformAccount.password : accounts.password); if (product === 'customer') await panel.getByRole('checkbox').check(); const received = page.waitForResponse(response => new URL(response.url()).pathname === `${authPath(product)}/sessions` && response.request().method() === 'POST'); await panel.getByRole('button', { name: product === 'customer' ? '登录并继续' : '登录', exact: true }).click(); const response = await received; assert.equal(response.status(), 200, `${labels[product]}真实账户登录成功`); const auth = await response.json(); tokens.set(product, auth.access_token); createdTokens.set(auth.access_token, product); await expectProtected(product); } async function reloadAndVerify(product) { await expectProtected(product); // 页面不建立 SSE;等待当前入口的身份与只读查询完成后再开始整页刷新。 await page.waitForLoadState('networkidle'); const before = page.url(); const received = identityResponse(product); await page.reload(); const response = await received; assert.equal(response.status, 200, `${labels[product]}刷新后重新验证真实身份`); const identity = response.body; if (product === 'platform') assert.equal(identity.username, usernames[product]); else { assert.equal(identity.kind, product === 'customer' ? 'CUSTOMER' : 'STAFF'); assert.equal(identity.tenant_id, accounts.tenant_id); assert.ok(identity.grants.some(grant => grant.store_id === accounts.store_id)); } if (identities.has(product)) assert.equal(identity.actor_id, identities.get(product)); else identities.set(product, identity.actor_id); await expectProtected(product); assert.equal(page.url(), before, `${labels[product]}刷新保留原地址`); } async function revoke(product, token = tokens.get(product)) { countApiRequest(); const response = await context.request.delete(`${base}${authPath(product)}/sessions/current`, { headers: { Authorization: `Bearer ${token}` }, timeout: 3000, }); assert.equal(response.status(), 204, `${labels[product]}独立 HTTP 客户端撤销测试会话`); createdTokens.delete(token); } async function checkNetworkRecovery(product) { await route(targets[product]); await expectProtected(product); const network = await context.newCDPSession(page); await network.send('Network.enable'); // 只阻断 API 连接,静态页面照常加载;不拦截或替换服务端响应。 await network.send('Network.setBlockedURLs', { urls: ['*/openapi/v1/*', '*/admin/v1/*'] }); try { await page.reload(); const retry = page.getByRole('button', { name: '重新验证登录', exact: true }); await retry.waitFor(); assert.equal(new URL(page.url()).hash, `#/${targets[product]}`, '身份验证失败时保留当前地址'); assert.equal(await page.locator('.login-panel, .customer-login-card, .admin-sidebar, .order-card').count(), 0, '恢复失败期间不展示登录表单或受保护内容'); await network.send('Network.setBlockedURLs', { urls: [] }); const received = identityResponse(product); await retry.click(); assert.equal((await received).status, 200); await expectProtected(product); checks.push(`${labels[product]}:API 连接中断后刷新保留凭据,恢复网络后手动重新验证`); } finally { await network.send('Network.setBlockedURLs', { urls: [] }); await network.detach(); } } async function logout(product) { await route(product === 'customer' ? 'customer/memory' : targets[product]); const received = page.waitForResponse(response => new URL(response.url()).pathname === `${authPath(product)}/sessions/current` && response.request().method() === 'DELETE'); if (product === 'customer') await page.locator('.mobile').getByRole('button', { name: '退出登录', exact: true }).click(); else { await page.locator('.admin-session').getByRole('button', { name: '退出登录', exact: true }).click(); await page.getByRole('dialog').getByRole('button', { name: '确认操作', exact: true }).click(); } assert.equal((await received).status(), 204); createdTokens.delete(tokens.get(product)); tokens.delete(product); await expectLogin(product); await page.reload(); await expectLogin(product); await route(targets[product]); await expectLogin(product); } async function checkUntrustedStorage() { await route(targets.merchant); await expectProtected('merchant'); // 浏览器缓存允许被改写;额外权限字段不能改变真实服务端身份。 await page.evaluate(() => { const key = 'zhifu.session.merchant'; const credential = JSON.parse(sessionStorage.getItem(key)); sessionStorage.setItem(key, JSON.stringify({ ...credential, role: 'supervisor', permissions: ['approvals:decide'], id: 'untrusted-actor', actorId: 'untrusted-actor', storeId: 'untrusted-store' })); }); await reloadAndVerify('merchant'); assert.equal(await page.getByRole('navigation', { name: '系统导航' }).getByRole('button', { name: '审批待办', exact: true }).count(), 0); await route('merchant/approval/review'); await page.getByRole('heading', { name: '页面不可访问', exact: true }).waitFor(); assert.equal(await page.getByRole('button', { name: '提交审批决定', exact: true }).count(), 0); checks.push('商家缓存附加身份和权限字段后,刷新仍按真实客服权限显示菜单并阻止审批页面'); await route(targets.customer); await expectProtected('customer'); const previousToken = tokens.get('customer'); await page.evaluate(() => { sessionStorage.setItem('zhifu.session.customer', '{invalid-json'); }); await page.reload(); await page.getByText('登录记录无法读取,请重新验证登录。', { exact: true }).waitFor(); await page.getByRole('button', { name: '重新验证登录', exact: true }).click(); await expectLogin('customer'); await revoke('customer', previousToken); for (const product of ['merchant', 'platform']) { await route(targets[product]); await expectProtected(product); } await route(targets.customer); await login('customer'); checks.push('H5 缓存 JSON 损坏后显示恢复错误,手动重新验证可以正常登录,其他入口保持身份'); } async function run() { await page.goto(`${base}/#/${targets.customer}`); for (const product of products) { if (product !== 'customer') await route(targets[product]); await login(product); await reloadAndVerify(product); checks.push(`${labels[product]}:真实登录后刷新保留身份与原页面地址`); } for (const product of products) { await route(targets[product]); await reloadAndVerify(product); } checks.push('同一标签页的三个入口分别恢复本人身份,切换入口并刷新保持独立会话'); for (const product of products) await checkNetworkRecovery(product); for (const product of products) { await route(targets[product]); await expectProtected(product); await revoke(product); await page.waitForLoadState('networkidle'); const received = identityResponse(product); await page.reload(); assert.equal((await received).status, 401); await expectLogin(product); await page.reload(); await expectLogin(product); for (const other of products.filter(value => value !== product)) { await route(targets[other]); await expectProtected(other); } await route(targets[product]); await login(product); checks.push(`${labels[product]}:服务端撤销后刷新要求登录,其他入口会话继续有效`); } await checkUntrustedStorage(); for (const product of products) { await logout(product); for (const other of products.filter(value => tokens.has(value))) { await route(targets[other]); await expectProtected(other); } checks.push(`${labels[product]}:主动退出后刷新与访问业务页均需登录,其他有效入口保持登录`); } assert.deepEqual(browserErrors, [], '浏览器运行异常'); assert.deepEqual(unexpectedWrites, [], '整个流程仅允许创建、读取和撤销登录会话'); assert.equal(modelRequests, 0, '生成模型调用次数'); assert.equal(requestLimitExceeded, false); const result = { date: new Date().toISOString(), checks, browserErrors, apiRequests, resourceRequests, maximumApiRequests, maximumResourceRequests, elapsedMs: Date.now() - started, maximumDurationMs: 180000, generatedModelCalls: 0, businessWrites: 0, networkFailure: 'Chromium 阻断 API 连接后刷新,再解除阻断并手动重新验证登录。', }; await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true }); await writeFile(new URL('../../.work/frontend-verification/session-refresh.json', import.meta.url), JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2)); } let deadline; try { await Promise.race([ run(), new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error('会话刷新验证超过 180 秒上限。')), 180000); }), ]); } finally { clearTimeout(deadline); for (const [token, product] of createdTokens) await revoke(product, token); await browser.close(); }