Recover only unverified Caterium Developer TOTP factors from the full factor list. Preserve verified MFA, reuse the in-memory QR after closing the dialog, serialize enrollment and retain correct-code/AAL2 checks. Complete pull-request QA passed in run 35518429693. Standard main QA and publication gates remain unchanged.
71 lines
7.1 KiB
JavaScript
71 lines
7.1 KiB
JavaScript
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('<!doctype html><html><body><button id="entry">Developer</button></body></html>');
|
||
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:'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="20" height="20"/></svg>',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='<h2>'+esc(title)+'</h2><button type="button" data-close>×</button>'+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/);
|
||
});
|