| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- 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();
- }
|