154 lines
13 KiB
JavaScript
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(); }
|