Files
story/test/e2e/login-routing.mjs
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

154 lines
13 KiB
JavaScript

// Real app and router, synthetic account/provider responses. No SMS or external login.
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require('playwright');
const { createServer } = await import(pathToFileURL(resolve('web/node_modules/vite/dist/node/index.js')));
const output = resolve('.design-evidence/login-routing'); await mkdir(output, { recursive: true });
const server = await createServer({ configFile: false, root: resolve('web'), oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 5323, strictPort: true } });
await server.listen();
let browser;
const checks = [], problems = [];
try {
browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.on('pageerror', e => problems.push(e.message));
let signedIn = false, setupLinked = false, exchanges = 0, links = 0, privateRequests = 0, revoked = false;
const setup = () => ({ authenticated: setupLinked, closed: false, setup_id: 'synthetic-setup', sms_enabled: true, expires_at_ms: Date.now() + 600000, state: { device_id: 'test-device', binding_state: 'UNBOUND', activation_state: 'NOT_STARTED', binding_generation: 0, device_revision: 1 } });
await page.route('**/api/v1/**', async route => {
const r = route.request(), path = new URL(r.url()).pathname;
if (path === '/api/v1/events') { assert(signedIn); return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': connected\n\n' }); }
let data = {}, status = 200, code = 'OK';
if (path === '/api/v1/web/operator/account/session') { status = 401; code = 'LOGIN_REQUIRED'; }
else if (path === '/api/v1/web/operator/login/session') data = { sms_enabled: true, password_enabled: true, signup_enabled: false, csrf_token: 'operator-login-csrf' };
else if (path === '/api/v1/web/operator/login/captcha') { assert.equal(r.headers()['x-csrf-token'], 'operator-login-csrf'); data = { captcha_id:'operator-captcha', image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', expires_at_ms:Date.now()+120000 }; }
else if (path === '/api/v1/web/account/session') { if (signedIn) data = { authenticated: true, csrf_token: 'account-csrf' }; else { status = 401; code = 'LOGIN_REQUIRED'; } }
else if (path === '/api/v1/web/login/session') data = { sms_enabled: true, signup_enabled: true, csrf_token: 'login-csrf' };
else if (['/api/v1/web/login/captcha','/api/v1/web/auth/captcha'].includes(path)) { assert.equal(r.headers()['x-csrf-token'],path.includes('/auth/')?'setup-csrf':'login-csrf'); data={captcha_id:'captcha',image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',expires_at_ms:Date.now()+120000}; }
else if (path === '/api/v1/web/login/sms/send') data = { sms_challenge_id: 'challenge', resend_after_ms: 60000, expires_at_ms: Date.now() + 300000 };
else if (path === '/api/v1/web/login/sms/verify') { signedIn = true; data = { csrf_token: 'account-csrf' }; }
else if (path === '/api/v1/web/auth/sms/send') { assert.equal(r.headers()['x-csrf-token'], 'setup-csrf'); data = { sms_challenge_id: 'setup-challenge', resend_after_ms: 60000, expires_at_ms: Date.now() + 300000 }; }
else if (path === '/api/v1/web/auth/sms/verify') { assert.equal(r.headers()['x-csrf-token'], 'setup-csrf'); signedIn = true; setupLinked = true; data = { csrf_token: 'linked-csrf' }; }
else if (path === '/api/v1/me') {
if (!signedIn || revoked) { status = 401; code = 'LOGIN_REQUIRED'; signedIn = false; }
else data = { identity: { display_name: '路由测试用户', preferred_username: '路由测试用户' }, accounts: [] };
} else if (['/api/v1/households', '/api/v1/devices'].includes(path)) { assert(signedIn); privateRequests++; data = { items: [] }; }
else if (path === '/api/v1/web/auth/logout') { assert.equal(r.headers()['x-csrf-token'], 'account-csrf'); signedIn = false; }
else if (path === '/api/v1/web/handoffs/exchange') { exchanges++; assert.deepEqual(r.postDataJSON(), { token: 'one-time-token', verifier: 'one-time-verifier' }); data = { csrf_token: 'setup-csrf' }; }
else if (path === '/api/v1/web/setup/session') data = { csrf_token: setupLinked ? 'linked-csrf' : 'setup-csrf', data: setup() };
else if (path === '/api/v1/web/setup/auth-link') { assert(signedIn); assert.equal(r.headers()['x-csrf-token'], 'setup-csrf'); assert.equal(r.headers().authorization, undefined); links++; setupLinked = true; data = { csrf_token: 'linked-csrf' }; }
else if (path === '/api/v1/web/setup/status') data = setup();
else if (path === '/api/v1/web/profiles') data = { items: [{ household_id: 'family', household_name: '测试家庭', elder_id: 'elder', preferred_name: '奶奶' }] };
else if (path === '/api/v1/web/onboarding/devices') data = { items: [] };
else if (path === '/api/v1/web/account/password') data = { available: false };
else throw new Error(`Unexpected API: ${path}`);
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify({ ok: status === 200, code, message: code, data }) });
});
const origin = 'http://127.0.0.1:5323';
const localURL = () => { const u = new URL(page.url()); return u.pathname + u.search + u.hash; };
async function login() {
await page.getByLabel('手机号', { exact: true }).fill('13800000000');
await page.getByLabel('图片验证码', { exact: true }).fill('654321');
await page.getByRole('button', { name: '获取验证码' }).click();
await page.getByLabel('短信验证码', { exact: true }).fill('654321');
await page.getByRole('button', { name: /^登\s*录$/ }).click();
}
for (const section of ['home', 'members', 'subjects', 'devices', 'conversations', 'stories', 'sharing', 'privacy', 'billing', 'messages', 'profile']) {
const target = `/?section=${section}&record=test%2F123#details`;
await page.goto(origin + target);
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
assert.equal(new URL(page.url()).searchParams.get('returnTo'), target);
assert.equal(await page.locator('.customer-layout').count(), 0);
}
assert.equal(privateRequests, 0);
checks.push('all 11 customer sections redirect before mounting business content or requesting business data');
await login(); await page.getByRole('button', { name: '打开账户菜单' }).waitFor();
assert.equal(localURL(), '/?section=profile&record=test%2F123#details');
await page.reload(); await page.getByRole('button', { name: '打开账户菜单' }).waitFor();
assert.equal(localURL(), '/?section=profile&record=test%2F123#details');
checks.push('successful login and reload preserve path, query parameters and fragment');
await page.goto(origin + '/login'); await page.getByRole('button', { name: '打开账户菜单' }).waitFor();
assert.equal(localURL(), '/?section=profile&record=test%2F123#details');
checks.push('an authenticated visit to /login returns to the last business page');
await page.getByRole('button', { name: '打开账户菜单' }).click();
await page.getByRole('menuitem', { name: '退出登录' }).click();
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
assert.equal(new URL(page.url()).searchParams.get('returnTo'), '/?section=profile&record=test%2F123#details');
assert.equal(await page.getByText('路由测试用户', { exact: true }).count(), 0);
await page.goBack(); await page.getByRole('heading', { name: '手机号登录' }).waitFor();
assert.equal(await page.locator('.customer-layout').count(), 0);
checks.push('logout retains the intended destination and browser Back cannot reveal private content');
await login(); await page.getByRole('button', { name: '打开账户菜单' }).waitFor();
await page.getByRole('button', { name: '打开提醒与待办' }).click();
await page.getByRole('heading', { name: '提醒与待办', exact: true }).waitFor();
const beforeExpiry = localURL();
revoked = true;
await page.evaluate(async () => { const { platformApi } = await import('/src/api/client.ts'); await platformApi.currentUser('').catch(() => {}); });
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
assert.equal(new URL(page.url()).searchParams.get('returnTo'), beforeExpiry);
checks.push('session expiry returns to login using the latest in-app navigation target');
revoked = false;
for (const path of ['/maintenance?area=config', '/onboarding/devices', '/onboarding/setup', '/?designPreview=1']) {
await page.goto(origin + path);
await page.getByRole('heading', { name: path.startsWith('/maintenance') ? '维护中心登录' : '手机号登录' }).waitFor();
assert.equal(new URL(page.url()).searchParams.get('returnTo'), path);
assert.equal(await page.locator('.customer-layout,.maintenance-layout,.onboarding-shell').count(), 0);
}
checks.push('maintenance, onboarding and preview entry points also require login');
const results = await page.evaluate(async () => {
const { safeReturnURL } = await import('/src/auth/navigation.ts');
return ['https://evil.example/', '//evil.example/', '/\\evil.example/', '/login?returnTo=/login', '/auth/callback?code=x', '/%2f%2fevil.example', '/api/v1/me', '/onboarding/entry#token=secret'].map(safeReturnURL);
});
assert.deepEqual(results, ['/', '/', '/', '/', '/', '/', '/', '/onboarding/setup']);
await page.goto(origin + '/login?returnTo=' + encodeURIComponent('//evil.example/'));
await login(); await page.getByRole('button', { name: '打开账户菜单' }).waitFor(); assert.equal(localURL(), '/');
checks.push('unsafe return URLs and login/callback loops fall back to the homepage');
// Exercise the real OIDC startSignin without contacting a provider.
const oidcDestination = '/?section=profile&record=oidc#detail';
const state = await page.evaluate(async target => {
const { userManager, startSignin } = await import('/src/auth/oidc.ts');
let captured;
userManager.signinRedirect = async args => { captured = args.state; };
await startSignin(target); return captured;
}, oidcDestination);
assert.deepEqual(state, { returnTo: oidcDestination });
checks.push('account login carries the same validated destination in OIDC state');
const callbacks = await page.evaluate(async target => {
const { userManager, completeSigninIfNeeded } = await import('/src/auth/oidc.ts');
let count = 0;
userManager.signinRedirectCallback = async () => { count++; return { state: { returnTo: target } }; };
history.replaceState({}, '', '/auth/callback?code=synthetic');
await Promise.all([completeSigninIfNeeded(), completeSigninIfNeeded()]); return count;
}, oidcDestination);
assert.equal(callbacks, 1); assert.equal(localURL(), oidcDestination);
checks.push('OIDC callback restores the intended destination and exchanges a code only once');
signedIn = false;
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })));
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
assert.equal(await page.locator('.customer-layout').count(), 0);
checks.push('restoring a browser-cached document revalidates authentication before showing business content');
signedIn = true;
await page.goto(origin + '/onboarding/setup');
await page.getByLabel('选择使用者', { exact: true }).waitFor();
assert.equal(links, 1);
checks.push('an existing SMS account links to the pending device setup after login');
signedIn = false;
setupLinked = false;
await page.goto(origin + '/onboarding/entry#token=one-time-token&verifier=one-time-verifier');
await page.getByRole('heading', { name: '手机号登录' }).waitFor();
assert.equal(exchanges, 1); assert.equal(new URL(page.url()).searchParams.get('returnTo'), '/onboarding/setup');
assert(!page.url().includes('one-time'));
assert(!await page.evaluate(() => JSON.stringify(sessionStorage).includes('one-time')));
await login(); await page.getByRole('heading', { name: '连接家人的陪伴设备' }).waitFor();
await page.getByLabel('选择使用者', { exact: true }).waitFor();
assert.equal(localURL(), '/onboarding/setup'); assert.equal(links, 1); assert.equal(exchanges, 1);
checks.push('device handoff is exchanged once, secrets are scrubbed, SMS login resumes the same setup');
await page.screenshot({ path: resolve(output, 'onboarding-resumed.png'), fullPage: true });
assert.deepEqual(problems, []);
await writeFile(resolve(output, 'result.json'), JSON.stringify({ checks, problems, boundary: 'Synthetic account and provider responses; no real SMS or OIDC login.' }, null, 2));
console.log(JSON.stringify({ checks, problems }));
} finally { await browser?.close(); await server.close(); }