90 lines
11 KiB
JavaScript
90 lines
11 KiB
JavaScript
// Real UI/router with synthetic HTTP responses. Backend checks run separately against PostgreSQL/Redis.
|
||||
|
|
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 server=await createServer({configFile:false,root:resolve('web'),oxc:{jsx:{runtime:'automatic'}},logLevel:'error',server:{host:'127.0.0.1',port:5324,strictPort:true}});
|
|||
|
|
const output=resolve('.design-evidence/phone-password');await mkdir(output,{recursive:true});
|
|||
|
|
await server.listen();let browser;
|
|||
|
|
const checks=[],errors=[],sentPurposes=[];
|
|||
|
|
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:1280,height:900}});
|
|||
|
|
page.on('pageerror',e=>errors.push(e.message));await page.clock.install();
|
|||
|
|
let signedIn=false,smsEnabled=true,captchaCount=0,sendCount=0,passwordAttempts=0,configured=true,recentSMS=false,saveCount=0,trustedDevice=false;
|
|||
|
|
await page.route('**/api/v1/**',async route=>{
|
|||
|
|
const r=route.request(),path=new URL(r.url()).pathname,body=r.method()==='POST'?r.postDataJSON():undefined;
|
|||
|
|
let status=200,code='OK',message='',data={};
|
|||
|
|
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:smsEnabled,password_enabled:true,csrf_token:'login-csrf'};
|
|||
|
|
else if(path==='/api/v1/web/login/captcha'){
|
|||
|
|
assert.equal(r.headers()['x-csrf-token'],'login-csrf');captchaCount++;
|
|||
|
|
data={captcha_id:`captcha-${captchaCount}`,image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',expires_at_ms:await page.evaluate(()=>Date.now()+120000)};
|
|||
|
|
} else if(path==='/api/v1/web/login/password'){
|
|||
|
|
passwordAttempts++;assert.equal(r.headers()['x-csrf-token'],'login-csrf');assert.equal(body.phone_e164,'+8613800000000');assert.equal(body.captcha_id,`captcha-${captchaCount}`);
|
|||
|
|
if(body.captcha_code!=='654321'){status=401;code='CAPTCHA_INVALID';message='图片验证码错误或已过期,请重新输入';}
|
|||
|
|
else if(body.password!=='LocalOnly123!'){status=401;code='PASSWORD_INVALID';message='手机号或密码不正确';}
|
|||
|
|
else if(!trustedDevice){status=403;code='NEW_DEVICE_SMS_REQUIRED';message='首次在此设备登录,请使用手机验证码完成验证';}
|
|||
|
|
else{signedIn=true;recentSMS=false;data={csrf_token:'account-csrf'};}
|
|||
|
|
} else if(path==='/api/v1/web/login/sms/send'){assert.equal(body.captcha_code,'654321');assert.equal(body.captcha_id,`captcha-${captchaCount}`);sentPurposes.push(body.purpose);sendCount++;data={sms_challenge_id:'sms-challenge',expires_at_ms:await page.evaluate(()=>Date.now()+300000),resend_after_ms:60000};}
|
|||
|
|
else if(path==='/api/v1/web/login/sms/verify'){signedIn=true;trustedDevice=true;recentSMS=true;data={csrf_token:'account-csrf'};}
|
|||
|
|
else if(path==='/api/v1/web/account/password'){
|
|||
|
|
if(r.method()==='GET')data={available:true,phone_masked:'+86138****0000',password_configured:configured,sms_verified_recently:recentSMS};
|
|||
|
|
else {assert(signedIn);assert.equal(r.headers()['x-csrf-token'],'account-csrf');if(!recentSMS)assert.equal(body.current_password,'LocalOnly123!');assert.equal(body.password,'abc123');saveCount++;configured=true;recentSMS=false;}
|
|||
|
|
} else if(path==='/api/v1/me'){assert(signedIn);data={identity:{display_name:'手机号测试用户'},accounts:[]};}
|
|||
|
|
else if(path==='/api/v1/web/auth/logout'){signedIn=false;}
|
|||
|
|
else throw new Error(`Unexpected API ${path}`);
|
|||
|
|
await route.fulfill({status,contentType:'application/json',body:JSON.stringify({ok:status===200,code,message,data})});
|
|||
|
|
});
|
|||
|
|
const origin='http://127.0.0.1:5324',target='/?section=profile&record=123#details';
|
|||
|
|
await page.goto(origin+target);await page.getByRole('heading',{name:'手机号登录'}).waitFor();
|
|||
|
|
const phone=page.getByLabel('手机号',{exact:true}),submit=page.locator('form.sms-login-form').getByRole('button',{name:/^登\s*录$/});
|
|||
|
|
await phone.fill('13800000000');assert(await page.getByRole('button',{name:'获取验证码',exact:true}).isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();
|
|||
|
|
await page.getByText(/验证码已发送/).waitFor();const smsBounds=await page.locator('.login-panel').boundingBox();const loginURL=page.url();
|
|||
|
|
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('img',{name:'图片验证码',exact:true}).waitFor();
|
|||
|
|
assert.equal(page.url(),loginURL);assert.equal(await phone.inputValue(),'13800000000');
|
|||
|
|
const passwordBounds=await page.locator('.login-panel').boundingBox();assert.deepEqual(passwordBounds,smsBounds);
|
|||
|
|
assert.equal(await page.getByRole('button',{name:'账号登录',exact:true}).count(),0);
|
|||
|
|
checks.push('same card position and size, phone preserved, no external login or username field');
|
|||
|
|
await page.getByRole('button',{name:'验证码登录',exact:true}).click();assert.equal(await phone.inputValue(),'13800000000');assert.equal(sendCount,1);assert(await page.getByRole('button',{name:/秒后重发/}).isDisabled());
|
|||
|
|
checks.push('switching back preserves SMS challenge and resend countdown');
|
|||
|
|
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByLabel('密码',{exact:true}).fill('LocalOnly123!');
|
|||
|
|
const beforeRejected=captchaCount;assert(await submit.isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('000000');await submit.click();
|
|||
|
|
await page.getByText('图片验证码错误或已过期,请重新输入',{exact:true}).waitFor();await page.waitForFunction(()=>document.querySelector('#customer-image-code').value==='');
|
|||
|
|
assert.equal(passwordAttempts,1);assert.equal(captchaCount,beforeRejected+1);
|
|||
|
|
checks.push('captcha is required and refreshed after a rejected login');
|
|||
|
|
await page.getByRole('button',{name:'换一张',exact:true}).click();await page.waitForFunction(()=>!document.querySelector('#customer-image-code').disabled);assert.equal(captchaCount,beforeRejected+2);
|
|||
|
|
await page.clock.fastForward(121000);await page.getByText('图片验证码已过期,请换一张',{exact:true}).waitFor();
|
|||
|
|
assert(await submit.isDisabled());await page.getByRole('button',{name:'换一张',exact:true}).click();
|
|||
|
|
checks.push('manual refresh and captcha expiry prevent stale submissions');
|
|||
|
|
for(const width of [375,320]){await page.setViewportSize({width,height:850});assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));}
|
|||
|
|
checks.push('password and captcha fit 320px and 375px screens');
|
|||
|
|
await page.getByLabel('图片验证码',{exact:true}).fill('654321');await submit.click();
|
|||
|
|
await page.getByText('首次在此浏览器登录,请完成手机验证码验证。验证后记住此浏览器 30 天。',{exact:true}).waitFor();
|
|||
|
|
assert.equal(signedIn,false);assert.equal(await phone.inputValue(),'13800000000');assert.equal(await page.getByLabel('密码',{exact:true}).count(),0);
|
|||
|
|
await page.screenshot({path:resolve(output,'new-device-sms-mobile.png')});
|
|||
|
|
await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();
|
|||
|
|
await page.getByLabel('短信验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'验证并登录'}).click();await page.getByRole('button',{name:'打开账户菜单'}).waitFor();
|
|||
|
|
checks.push('new device redirects to existing LOGIN SMS verification without granting a password-only session');
|
|||
|
|
assert.equal(page.url(),origin+target);await page.reload();await page.getByText('手机号:+86138****0000').waitFor();
|
|||
|
|
checks.push('password login and reload restore the original path, query and fragment');
|
|||
|
|
await page.getByLabel('新密码',{exact:true}).fill('abc12');await page.getByLabel('确认新密码',{exact:true}).fill('abc12');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('密码须为 6–64 个字符').waitFor();assert.equal(saveCount,0);
|
|||
|
|
await page.getByLabel('新密码',{exact:true}).fill('abc123');await page.getByLabel('确认新密码',{exact:true}).fill('abc123');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('密码已更新',{exact:true}).waitFor();assert.equal(saveCount,1);
|
|||
|
|
checks.push('profile allows password changes with current password and account CSRF');
|
|||
|
|
await page.getByRole('button',{name:'打开账户菜单'}).click();await page.getByRole('menuitem',{name:'退出登录'}).click();await page.getByRole('heading',{name:'手机号登录'}).waitFor();
|
|||
|
|
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('button',{name:'忘记密码'}).click();
|
|||
|
|
await phone.fill('13800000000');assert(await page.getByRole('button',{name:'获取验证码',exact:true}).isDisabled());await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();await page.getByLabel('短信验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'验证并重置密码'}).click();
|
|||
|
|
await page.getByRole('dialog',{name:'重置密码'}).waitFor();assert.equal(await page.getByLabel('当前密码',{exact:true}).count(),0);
|
|||
|
|
await page.getByLabel('新密码',{exact:true}).fill('abc123');await page.getByLabel('确认新密码',{exact:true}).fill('mismatch');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('两次输入的密码不一致').waitFor();assert.equal(saveCount,1);
|
|||
|
|
await page.getByLabel('确认新密码',{exact:true}).fill('abc123');await page.getByRole('button',{name:'保存密码'}).click();await page.getByRole('button',{name:'打开账户菜单'}).waitFor();assert.equal(saveCount,2);assert.equal(page.url(),origin+target);
|
|||
|
|
assert.deepEqual(sentPurposes,['LOGIN','LOGIN','CHANGE_PASSWORD']);
|
|||
|
|
checks.push('forgot password uses the password-change template purpose, verifies SMS, confirms the new password, and preserves destination');
|
|||
|
|
signedIn=false;smsEnabled=false;await page.goto(origin+'/login?returnTo='+encodeURIComponent(target));await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('img',{name:'图片验证码',exact:true}).waitFor();
|
|||
|
|
checks.push('password login remains available when SMS delivery is not configured');
|
|||
|
|
assert.deepEqual(errors,[]);await writeFile(resolve(output,'result.json'),JSON.stringify({checks,errors},null,2));console.log(JSON.stringify({checks,errors}));
|
|||
|
|
}finally{await browser?.close();await server.close();}
|