90 lines
9.8 KiB
JavaScript
90 lines
9.8 KiB
JavaScript
// Real login/router/API clients; synthetic delivery and business responses.
|
|||
|
|
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:5325,strictPort:true}});
|
||
|
|
await server.listen();const output=resolve('.design-evidence/operator-phone');await mkdir(output,{recursive:true});
|
||
|
|
let browser;const checks=[],errors=[];
|
||
|
|
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:1000}});
|
||
|
|
page.on('pageerror',e=>errors.push(e.message));
|
||
|
|
let customer=true,operator=false,recentSMS=false,captcha=0,opReads=0,writes=0,sends=0;
|
||
|
|
const tenant='10000000-0000-0000-0000-000000000011';
|
||
|
|
await page.route('**/*',async route=>{
|
||
|
|
const r=route.request(),path=new URL(r.url()).pathname;
|
||
|
|
if(!path.startsWith('/api/')&&!path.startsWith('/health/'))return route.continue();
|
||
|
|
const body=r.method()==='POST'?r.postDataJSON():undefined;
|
||
|
|
let status=200,data={},code='OK',message='';
|
||
|
|
if(path.startsWith('/health/'))data={status:path.endsWith('/live')?'alive':'ready',service:'api',checks:{database:'ready'}};
|
||
|
|
else if(path==='/api/v1/version')data={version:'test'};
|
||
|
|
else if(path==='/api/v1/web/operator/account/session'){if(operator)data={authenticated:true,csrf_token:'operator-csrf'};else{status=401;code='LOGIN_REQUIRED';}}
|
||
|
|
else if(path==='/api/v1/web/account/session'){assert(customer);data={authenticated:true,csrf_token:'customer-csrf'};}
|
||
|
|
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');captcha++;
|
||
|
|
data={captcha_id:`image-${captcha}`,image:'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=',expires_at_ms:Date.now()+120000};
|
||
|
|
} else if(path==='/api/v1/web/operator/login/password'){
|
||
|
|
assert.equal(r.headers()['x-csrf-token'],'operator-login-csrf');assert.equal(body.phone_e164,'+8618941657635');assert.equal(body.captcha_id,`image-${captcha}`);
|
||
|
|
if(body.captcha_code!=='654321'){status=401;code='CAPTCHA_INVALID';message='图片验证码错误或已过期,请重新输入';}
|
||
|
|
else{assert.equal(body.password,'OperatorOnly123!');operator=true;recentSMS=false;data={csrf_token:'operator-csrf'};}
|
||
|
|
} else if(path==='/api/v1/web/operator/login/sms/send'){
|
||
|
|
assert.equal(r.headers()['x-csrf-token'],'operator-login-csrf');assert.equal(body.captcha_id,`image-${captcha}`);assert.equal(body.captcha_code,'654321');sends++;
|
||
|
|
data={sms_challenge_id:'sms-op',expires_at_ms:Date.now()+300000,resend_after_ms:60000};
|
||
|
|
} else if(path==='/api/v1/web/operator/login/sms/verify'){assert.equal(body.code,'654321');operator=true;recentSMS=true;data={csrf_token:'operator-csrf'};}
|
||
|
|
else if(path==='/api/v1/me'){
|
||
|
|
const op=r.headers()['x-login-portal']==='operator';
|
||
|
|
if(op&&!operator){status=401;code='LOGIN_REQUIRED';}
|
||
|
|
else data={identity:{subject:op?'admin':'owner',display_name:op?'测试管理员':'测试家庭用户'},accounts:op?[{tenant_id:tenant,tenant_name:'测试组织',user_id:'admin',platform_role:'ops_admin',account_kind:'operator',household_memberships:[]}]:[]};
|
||
|
|
} else if(path==='/api/v1/web/operator/account/password'){
|
||
|
|
assert(operator);if(r.method()==='POST'){assert.equal(r.headers()['x-csrf-token'],'operator-csrf');assert.equal(body.current_password,'OperatorOnly123!');writes++;}
|
||
|
|
data={available:true,phone_masked:'+86189****7635',password_configured:true,sms_verified_recently:recentSMS};
|
||
|
|
} else if(path==='/api/v1/web/account/password')data={available:false};
|
||
|
|
else if(path==='/api/v1/web/operator/auth/logout'){assert.equal(r.headers()['x-csrf-token'],'operator-csrf');operator=false;}
|
||
|
|
else if(path.startsWith('/api/v1/ops/')){
|
||
|
|
assert(operator);assert.equal(r.headers()['x-login-portal'],'operator');assert.equal(r.headers().authorization,undefined);assert.equal(r.headers()['x-tenant-id'],tenant);
|
||
|
|
if(r.method()==='POST'){assert.equal(r.headers()['x-csrf-token'],'operator-csrf');writes++;}else opReads++;
|
||
|
|
data={items:[]};
|
||
|
|
} else if(['/api/v1/households','/api/v1/devices'].includes(path))data={items:[]};
|
||
|
|
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:5325',target='/maintenance?area=devices&record=123#detail';
|
||
|
|
await page.goto(origin+target);await page.getByRole('heading',{name:'维护中心登录'}).waitFor();
|
||
|
|
assert.equal(opReads,0);assert.equal(await page.locator('.maintenance-layout').count(),0);
|
||
|
|
const phone=page.getByLabel('手机号',{exact:true});await phone.fill('18941657635');
|
||
|
|
assert(await page.getByRole('button',{name:'获取验证码',exact:true}).isDisabled());
|
||
|
|
const bounds=await page.locator('.login-panel').boundingBox();
|
||
|
|
await page.getByRole('button',{name:'密码登录',exact:true}).click();await page.getByRole('img',{name:'图片验证码',exact:true}).waitFor();
|
||
|
|
assert.deepEqual(await page.locator('.login-panel').boundingBox(),bounds);assert.equal(await phone.inputValue(),'18941657635');
|
||
|
|
checks.push('customer session cannot enter management; both admin login modes share one card');
|
||
|
|
await page.getByLabel('密码',{exact:true}).fill('OperatorOnly123!');await page.getByLabel('图片验证码',{exact:true}).fill('000000');await page.locator('form.sms-login-form button[type=submit]').click();
|
||
|
|
await page.getByText('图片验证码错误或已过期,请重新输入',{exact:true}).waitFor();await page.waitForFunction(()=>!document.querySelector('#customer-image-code').disabled);
|
||
|
|
await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.locator('form.sms-login-form button[type=submit]').click();
|
||
|
|
await page.getByRole('button',{name:'打开管理账户菜单'}).waitFor();assert.equal(page.url(),origin+target);
|
||
|
|
await page.reload();await page.getByRole('button',{name:'打开管理账户菜单'}).waitFor();assert(opReads>0);
|
||
|
|
checks.push('operator password/captcha login, API reads and reload retain original destination');
|
||
|
|
await page.evaluate(async tenant=>{const {authenticatedJSON}=await import('/src/api/client.ts');await authenticatedJSON('POST','/api/v1/ops/devices','',undefined,{}, {'X-Tenant-Id':tenant});const {onboardingRequest}=await import('/src/api/onboarding.ts');await onboardingRequest('/api/v1/ops/device-inventory',{}, {tenant,token:''});},tenant);
|
||
|
|
assert.equal(writes,2);checks.push('both operator API clients send operator CSRF and tenant without an empty Bearer');
|
||
|
|
await page.getByRole('button',{name:'打开管理账户菜单'}).click();await page.getByRole('menuitem',{name:'登录与安全'}).click();await page.getByText('手机号:+86189****7635').waitFor();
|
||
|
|
await page.getByLabel('当前密码',{exact:true}).fill('OperatorOnly123!');await page.getByLabel('新密码',{exact:true}).fill('ChangedOperator123!');await page.getByLabel('确认新密码',{exact:true}).fill('ChangedOperator123!');await page.getByRole('button',{name:'保存密码'}).click();await page.getByText('密码已更新').waitFor();
|
||
|
|
await page.locator('.ant-drawer-close').click();
|
||
|
|
checks.push('management account menu offers isolated password settings');
|
||
|
|
await page.getByRole('button',{name:'打开管理账户菜单'}).click();await page.getByRole('menuitem',{name:'退出登录'}).click();await page.getByRole('heading',{name:'维护中心登录'}).waitFor();assert(customer);
|
||
|
|
await page.evaluate(async()=>{const {navigateTo}=await import('/src/auth/navigation.ts');navigateTo('/?section=profile');});await page.getByRole('button',{name:'打开账户菜单'}).waitFor();
|
||
|
|
await page.evaluate(async target=>{const {navigateTo}=await import('/src/auth/navigation.ts');navigateTo(target);},target);await page.getByRole('heading',{name:'维护中心登录'}).waitFor();
|
||
|
|
checks.push('operator logout preserves customer session; switching portals reloads the correct identity');
|
||
|
|
await phone.fill('18941657635');await page.getByLabel('图片验证码',{exact:true}).fill('654321');await page.getByRole('button',{name:'获取验证码',exact:true}).click();await page.getByText(/验证码已发送/).waitFor();assert.equal(sends,1);
|
||
|
|
await page.getByLabel('短信验证码',{exact:true}).fill('654321');await page.locator('form.sms-login-form button[type=submit]').click();await page.getByRole('button',{name:'打开管理账户菜单'}).waitFor();
|
||
|
|
checks.push('operator SMS login requires image verification and returns to management');
|
||
|
|
operator=false;await page.evaluate(async()=>{const {platformApi}=await import('/src/api/client.ts');await platformApi.currentUser('').catch(()=>{});});await page.getByRole('heading',{name:'维护中心登录'}).waitFor();assert(customer);
|
||
|
|
for(const width of [375,320]){await page.setViewportSize({width,height:920});await page.waitForFunction(()=>!document.querySelector('#customer-phone').disabled);assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));}
|
||
|
|
await page.screenshot({path:resolve(output,'mobile.png'),fullPage:true});checks.push('expired management session returns to its login page; 320px and 375px layouts fit');
|
||
|
|
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();}
|