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