caterium-app/tests/developer-mfa.spec.mjs
pavlov346346-source 8b25918e30
Fix reopening incomplete developer MFA setup (#38)
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.
2026-09-20 20:08:46 +03:00

71 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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/);
});