import fs from 'node:fs'; import {test,expect} from '@playwright/test'; const runtime=fs.readFileSync(new URL('../public/app-runtime.js',import.meta.url),'utf8'); const start=runtime.indexOf(' let developerMfaPending=null'); if(start<0)throw new Error('Developer MFA recovery is not integrated'); const implementation=runtime.slice(start,runtime.indexOf('\n async function renderOverview',start)); const pending={id:'abandoned',factor_type:'totp',friendly_name:'Caterium Developer',status:'unverified'}; async function fixture(page,options={}){ await page.setContent(''); await page.evaluate(options=>{ window.testUid='developer';window.mfaServer={rows:options.rows||[],aal:'aal1',enrolls:0,deletes:[],lists:0,verifies:0,results:[],errors:[],...options}; const server=window.mfaServer; window.testClient={auth:{refreshSession:async()=>({error:null}),mfa:{ getAuthenticatorAssuranceLevel:async()=>({data:{currentLevel:server.aal}}), listFactors:async()=>{server.lists++;if(server.verifyOnList===server.lists)server.rows[0].status='verified';return {data:{all:structuredClone(server.rows),totp:structuredClone(server.rows.filter(f=>f.factor_type==='totp'&&f.status==='verified'))}};}, unenroll:async({factorId})=>{server.deletes.push(factorId);if(server.resetError)return {error:{message:'Network failure'}};const factor=server.rows.find(f=>f.id===factorId);if(factor?.status==='verified')throw new Error('Must never remove verified MFA');server.rows=server.rows.filter(f=>f.id!==factorId);return {error:null};}, enroll:async()=>{server.enrolls++;if(server.rows.some(f=>f.friendly_name==='Caterium Developer'))return {error:{message:'A factor with this friendly name already exists'}};const f={id:'new-'+server.enrolls,factor_type:'totp',friendly_name:'Caterium Developer',status:'unverified'};server.rows.push(f);return {data:{...f,totp:{qr_code:'',secret:'SYNTHETIC-TEST-KEY'}}};}, challengeAndVerify:async({factorId,code})=>{server.verifies++;if(code!=='123456')return {error:{message:'Неверный код'}};if(server.delayVerify)await new Promise(r=>window.finishVerification=r);server.rows.find(f=>f.id===factorId).status='verified';server.aal='aal2';return {error:null};} }}}; },options); await page.addScriptTag({content:`(()=>{ const client=()=>window.testClient,session=()=>({user:{id:window.testUid}}),toast=()=>{}; const esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); function modal(title,html){document.getElementById('sunDevModalV22')?.remove();const m=document.createElement('div');m.id='sunDevModalV22';m.innerHTML='

'+esc(title)+'

'+html;document.body.append(m);const close=()=>m.remove();m.addEventListener('click',e=>{if(e.target===m||e.target.closest('[data-close]'))close();});return {m,close};} ${implementation} window.openTestMfa=()=>ensureDeveloperMfa().then(v=>mfaServer.results.push(v),e=>mfaServer.errors.push(e.message)); })();`}); } async function open(page){await page.evaluate(()=>{void openTestMfa();});await expect(page.locator('#sunDevMfaCode')).toBeVisible();} const counts=page=>page.evaluate(()=>({enrolls:mfaServer.enrolls,deletes:mfaServer.deletes,verifies:mfaServer.verifies})); test('abandoned enrollment in all is recovered without touching unrelated factors',async({page})=>{ await fixture(page,{rows:[pending,{...pending,id:'other-app',friendly_name:'Other app'},{...pending,id:'phone',factor_type:'phone',friendly_name:'My phone'}]});await open(page); expect(await counts(page)).toEqual({enrolls:1,deletes:['abandoned'],verifies:0}); await expect(page.locator('.sun-dev-mfa-qr')).toHaveAttribute('src',/^data:image\/svg\+xml/); }); test('closing and reopening keeps the QR and concurrent opens share one enrollment',async({page})=>{ await fixture(page);await open(page);const qr=await page.locator('.sun-dev-mfa-qr').getAttribute('src'); await page.locator('[data-close]').click();await expect(page.locator('#sunDevMfaCode')).toHaveCount(0); await page.evaluate(()=>{for(let i=0;i<5;i++)void openTestMfa();});await expect(page.locator('#sunDevMfaCode')).toHaveCount(1); await expect(page.locator('.sun-dev-mfa-qr')).toHaveAttribute('src',qr);expect((await counts(page)).enrolls).toBe(1); await page.locator('#sunDevMfaCode').fill('123456');await page.locator('#sunDevMfaVerify').click(); await expect.poll(()=>page.evaluate(()=>mfaServer.results)).toEqual([false,true,true,true,true,true]);expect((await counts(page)).verifies).toBe(1); }); test('verified MFA stays enrolled and access still requires a correct code and AAL2',async({page})=>{ await fixture(page,{rows:[{...pending,status:'verified'}]});await open(page);await expect(page.locator('.sun-dev-mfa-qr')).toHaveCount(0); await page.locator('#sunDevMfaCode').fill('999999');await page.locator('#sunDevMfaVerify').click();await expect(page.locator('#sunDevMfaError')).toContainText('Неверный код'); expect(await page.evaluate(()=>mfaServer.results)).toEqual([]); await page.locator('#sunDevMfaCode').fill('123456');await page.locator('#sunDevMfaVerify').click();await expect.poll(()=>page.evaluate(()=>mfaServer.results)).toEqual([true]); expect(await counts(page)).toEqual({enrolls:0,deletes:[],verifies:2}); }); test('failed cleanup stops enrollment and never reports developer access',async({page})=>{ await fixture(page,{rows:[pending],resetError:true});await page.evaluate(()=>{void openTestMfa();}); await expect.poll(()=>page.evaluate(()=>mfaServer.errors.length)).toBe(1); expect((await counts(page)).enrolls).toBe(0);expect(await page.evaluate(()=>mfaServer.results)).toEqual([]);await expect(page.locator('#sunDevMfaCode')).toHaveCount(0); }); test('a factor verified during recovery is not removed',async({page})=>{ await fixture(page,{rows:[pending],verifyOnList:2});await open(page); expect(await counts(page)).toEqual({enrolls:0,deletes:[],verifies:0});await expect(page.locator('.sun-dev-mfa-qr')).toHaveCount(0); }); test('closing during verification cannot orphan the modal or issue another request',async({page})=>{ await fixture(page,{delayVerify:true});await open(page);await page.locator('#sunDevMfaCode').fill('123456');await page.locator('#sunDevMfaVerify').click(); await page.locator('[data-close]').click();await expect(page.locator('#sunDevMfaCode')).toBeVisible();await expect(page.locator('#sunDevMfaVerify')).toBeDisabled(); await page.evaluate(()=>finishVerification());await expect.poll(()=>page.evaluate(()=>mfaServer.results)).toEqual([true]);await expect(page.locator('#sunDevMfaCode')).toHaveCount(0); }); test('account change closes the sensitive QR and cannot grant access to another account',async({page})=>{ await fixture(page);await open(page);await page.evaluate(()=>{testUid='another-user';window.dispatchEvent(new Event('sun:cloud-permissions-changed'));}); await expect.poll(()=>page.evaluate(()=>mfaServer.results)).toEqual([false]);await expect(page.locator('.sun-dev-mfa-qr')).toHaveCount(0); expect(implementation).not.toMatch(/localStorage|sessionStorage/); });