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.
This commit is contained in:
parent
1e644e6dfb
commit
8b25918e30
@ -5475,47 +5475,108 @@ window.SUN_LEGACY_CATALOG_V175=[];
|
||||
function modal(title,html){$('sunDevModalV22')?.remove();const m=document.createElement('div');m.className='sun-dev-modal';m.id='sunDevModalV22';m.innerHTML=`<div class="sun-dev-modal-card"><div class="sun-dev-modal-head"><h2>${esc(title)}</h2><button class="sun-dev-modal-close" type="button" data-close>×</button></div><div id="sunDevModalBody" style="margin-top:14px">${html}</div></div>`;document.body.appendChild(m);const close=()=>m.remove();m.addEventListener('click',e=>{if(e.target===m||e.target.closest('[data-close]'))close()});return {m,body:$('#sunDevModalBody'),close}}
|
||||
function confirmDanger(text){return confirm(text)}
|
||||
|
||||
async function ensureDeveloperMfa(){
|
||||
const c=client();if(!c?.auth?.mfa)throw new Error('Supabase MFA недоступна.');
|
||||
const readAal=async()=>{const a=await c.auth.mfa.getAuthenticatorAssuranceLevel();if(a.error)throw a.error;return a.data||{}};
|
||||
const waitAal2=async()=>{for(let i=0;i<12;i++){const a=await readAal();if(a.currentLevel==='aal2')return true;await new Promise(r=>setTimeout(r,180))}return false};
|
||||
const initial=await readAal();if(initial.currentLevel==='aal2')return true;
|
||||
const listed=await c.auth.mfa.listFactors();if(listed.error)throw listed.error;
|
||||
let factor=(listed.data?.totp||[]).find(f=>f.status==='verified');let enrollment=null;
|
||||
let developerMfaPending=null,developerMfaEnrollment=null;
|
||||
function ensureDeveloperMfa(){
|
||||
const c=client(),uid=session()?.user?.id;
|
||||
if(!c?.auth?.mfa||!uid)return Promise.reject(new Error('Войдите в аккаунт разработчика.'));
|
||||
if(developerMfaPending?.client===c&&developerMfaPending.uid===uid)return developerMfaPending.promise;
|
||||
const task={client:c,uid,promise:null};developerMfaPending=task;
|
||||
const run=()=>runDeveloperMfa(c,uid);
|
||||
task.promise=(navigator.locks?.request?navigator.locks.request(`caterium-developer-mfa:${uid}`,{ifAvailable:true},lock=>{
|
||||
if(!lock){toast('Подтверждение разработчика уже открыто в другой вкладке. Завершите его там или закройте то окно.','info');return false;}
|
||||
return run();
|
||||
}):run()).finally(()=>{if(developerMfaPending===task)developerMfaPending=null;});
|
||||
return task.promise;
|
||||
}
|
||||
async function runDeveloperMfa(c,uid){
|
||||
const current=()=>client()===c&&session()?.user?.id===uid;
|
||||
const check=()=>{if(!current())throw new Error('Аккаунт изменился. Откройте кабинет разработчика заново.');};
|
||||
const readAal=async()=>{check();const a=await c.auth.mfa.getAuthenticatorAssuranceLevel();check();if(a.error)throw a.error;return a.data||{};};
|
||||
const list=async()=>{
|
||||
check();const r=await c.auth.mfa.listFactors();check();if(r.error)throw r.error;
|
||||
// Supabase .totp contains VERIFIED factors only. Abandoned enrollments
|
||||
// are in .all, and must not be confused with another authenticator.
|
||||
return [...new Map([...(r.data?.all||[]),...(r.data?.totp||[])].map(f=>[f.id,f])).values()];
|
||||
};
|
||||
const totp=f=>f.factor_type==='totp'||f.type==='totp';
|
||||
const verified=rows=>rows.find(f=>totp(f)&&f.status==='verified');
|
||||
const abandoned=f=>totp(f)&&f.status==='unverified'&&f.friendly_name==='Caterium Developer';
|
||||
const waitAal2=async()=>{for(let i=0;i<12;i++){if((await readAal()).currentLevel==='aal2')return true;await new Promise(r=>setTimeout(r,180));}return false;};
|
||||
if((await readAal()).currentLevel==='aal2')return true;
|
||||
let rows=await list(),factor=verified(rows),enrollment=null;
|
||||
const remembered=developerMfaEnrollment;
|
||||
if(factor)developerMfaEnrollment=null;
|
||||
else if(remembered?.client===c&&remembered.uid===uid&&rows.some(f=>f.id===remembered.data.id&&abandoned(f))){
|
||||
enrollment=remembered.data;factor=enrollment;
|
||||
}else{
|
||||
developerMfaEnrollment=null;
|
||||
for(const old of rows.filter(abandoned)){
|
||||
// Refresh immediately before deleting: never remove a factor that was
|
||||
// confirmed in another session, nor a phone/passkey/other app factor.
|
||||
rows=await list();factor=verified(rows);if(factor)break;
|
||||
if(!rows.some(f=>f.id===old.id&&abandoned(f)))continue;
|
||||
const reset=await c.auth.mfa.unenroll({factorId:old.id});check();
|
||||
if(reset.error&&reset.error.code!=='mfa_factor_not_found'&&!/factor.*not found/i.test(reset.error.message||'')){
|
||||
throw new Error('Не удалось восстановить незавершённую настройку 2FA. Проверьте соединение и откройте раздел ещё раз.');
|
||||
}
|
||||
}
|
||||
if(!factor){
|
||||
for(const f of (listed.data?.totp||[]).filter(x=>x.status!=='verified')){
|
||||
try{const u=await c.auth.mfa.unenroll({factorId:f.id});if(u.error&&!/not found/i.test(String(u.error.message||'')))throw u.error}catch(e){console.warn('[Developer MFA] reset factor',e)}
|
||||
rows=await list();factor=verified(rows);
|
||||
if(!factor){
|
||||
const enr=await c.auth.mfa.enroll({factorType:'totp',friendlyName:'Caterium Developer'});check();
|
||||
if(enr.error){
|
||||
// A second device may have completed enrollment while this one
|
||||
// was waiting. Reuse only its verified factor; never bypass MFA.
|
||||
rows=await list();factor=verified(rows);
|
||||
if(!factor)throw new Error(/already exists|friendly name/i.test(enr.error.message||'')?'Настройка 2FA начата в другом окне. Закройте его и повторно откройте кабинет разработчика.':enr.error.message||'Не удалось создать QR-код. Повторите попытку.');
|
||||
}else{
|
||||
if(!enr.data?.id||!enr.data?.totp?.qr_code)throw new Error('Сервер не вернул QR-код. Повторно откройте кабинет разработчика.');
|
||||
enrollment=enr.data;factor=enrollment;
|
||||
developerMfaEnrollment={client:c,uid,data:enrollment};
|
||||
}
|
||||
const enr=await c.auth.mfa.enroll({factorType:'totp',friendlyName:'Caterium Developer'});if(enr.error)throw enr.error;factor=enr.data;enrollment=enr.data;
|
||||
}
|
||||
const firstSetup=Boolean(enrollment),qr=enrollment?.totp?.qr_code||'',secret=enrollment?.totp?.secret||'';
|
||||
const ui=modal(firstSetup?'Подключение 2FA разработчика':'Подтверждение разработчика',firstSetup?`<p>Отсканируйте этот QR-код приложением-аутентификатором. Затем введите текущие 6 цифр и нажмите <b>Подтвердить и открыть</b>.</p>${qr?`<img class="sun-dev-mfa-qr" src="${esc(qr)}" alt="QR для 2FA">`:''}${secret?`<p class="sun-dev-muted">Если QR не сканируется, добавьте ключ вручную:</p><div class="sun-dev-code">${esc(secret)}</div>`:''}<label style="display:block;margin-top:14px">6-значный код<input id="sunDevMfaCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000"></label><div class="actions"><button class="primary" id="sunDevMfaVerify" type="button">Подтвердить и открыть</button></div><div id="sunDevMfaError" class="sun-dev-muted" style="margin-top:8px"></div>`:`<p>Введите текущий 6-значный код из приложения-аутентификатора.</p><label>Код<input id="sunDevMfaCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000"></label><div class="actions"><button class="primary" id="sunDevMfaVerify" type="button">Подтвердить и открыть</button></div><div id="sunDevMfaError" class="sun-dev-muted" style="margin-top:8px"></div>`);
|
||||
return await new Promise(resolve=>{
|
||||
let settled=false,busy=false;
|
||||
const finish=v=>{if(settled)return;settled=true;try{ui.close()}catch(_){}resolve(v)};
|
||||
const showError=(e,notify=true)=>{const msg=e?.message||String(e||'Не удалось подтвердить код.');const box=$('sunDevMfaError');if(box)box.textContent=msg;if(notify)toast(msg,'error',6500)};
|
||||
}
|
||||
}
|
||||
check();
|
||||
const firstSetup=Boolean(enrollment),rawQr=enrollment?.totp?.qr_code||'';
|
||||
const qr=rawQr.trim().startsWith('<svg')?'data:image/svg+xml;charset=utf-8,'+encodeURIComponent(rawQr):rawQr;
|
||||
const secret=enrollment?.totp?.secret||'';
|
||||
const intro=firstSetup?`<p>Отсканируйте QR-код приложением-аутентификатором, затем введите 6 цифр из этого приложения.</p><p class="sun-dev-muted">Закрыли окно случайно? Откройте его снова — настройку можно продолжить. После перезагрузки используйте новый QR-код, показанный здесь.</p>${qr?`<img class="sun-dev-mfa-qr" src="${esc(qr)}" alt="QR для 2FA">`:''}${secret?`<p class="sun-dev-muted">Или добавьте ключ вручную:</p><div class="sun-dev-code">${esc(secret)}</div>`:''}`:'<p>Введите текущий 6-значный код из приложения-аутентификатора. Подтверждённая защита уже подключена.</p>';
|
||||
const ui=modal(firstSetup?'Подключение 2FA разработчика':'Подтверждение разработчика',`${intro}<label style="display:block;margin-top:14px">6-значный код<input id="sunDevMfaCode" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000"></label><div class="actions"><button class="primary" id="sunDevMfaVerify" type="button">Подтвердить и открыть</button></div><div id="sunDevMfaError" role="alert" class="sun-dev-muted" style="margin-top:8px"></div>`);
|
||||
return new Promise(resolve=>{
|
||||
let settled=false,busy=false,focusTimer=0;
|
||||
const inp=ui.m.querySelector('#sunDevMfaCode'),btn=ui.m.querySelector('#sunDevMfaVerify'),box=ui.m.querySelector('#sunDevMfaError');
|
||||
const finish=value=>{if(settled)return;settled=true;clearTimeout(focusTimer);observer.disconnect();window.removeEventListener('sun:cloud-permissions-changed',accountChanged);ui.close();resolve(value);};
|
||||
const accountChanged=()=>{if(!current()){developerMfaEnrollment=null;finish(false);}};
|
||||
const observer=new MutationObserver(()=>{if(!ui.m.isConnected)finish(false);});observer.observe(document.body,{childList:true});
|
||||
window.addEventListener('sun:cloud-permissions-changed',accountChanged);
|
||||
const showError=e=>{box.textContent=e?.message||String(e||'Не удалось подтвердить код.');};
|
||||
const verify=async()=>{
|
||||
if(busy)return;
|
||||
const code=String(document.getElementById('sunDevMfaCode')?.value||'').replace(/\D/g,'');
|
||||
if(code.length!==6){showError(new Error('Введите ровно 6 цифр из приложения-аутентификатора.'),false);return}
|
||||
busy=true;const btn=$('sunDevMfaVerify');if(btn){btn.disabled=true;btn.textContent='Проверяю…'};const box=$('sunDevMfaError');if(box)box.textContent='Проверяю код…';
|
||||
if(busy||settled)return;
|
||||
if(!current()){accountChanged();return;}
|
||||
const code=String(inp.value||'').replace(/\D/g,'');
|
||||
if(code.length!==6){showError(new Error('Введите ровно 6 цифр из приложения-аутентификатора.'));return;}
|
||||
busy=true;btn.disabled=true;btn.textContent='Проверяю…';box.textContent='Проверяю код…';
|
||||
try{
|
||||
let vr;
|
||||
if(typeof c.auth.mfa.challengeAndVerify==='function') vr=await c.auth.mfa.challengeAndVerify({factorId:factor.id,code});
|
||||
else {const ch=await c.auth.mfa.challenge({factorId:factor.id});if(ch.error)throw ch.error;vr=await c.auth.mfa.verify({factorId:factor.id,challengeId:ch.data.id,code})}
|
||||
if(vr?.error)throw vr.error;
|
||||
if(typeof c.auth.mfa.challengeAndVerify==='function')vr=await c.auth.mfa.challengeAndVerify({factorId:factor.id,code});
|
||||
else{const ch=await c.auth.mfa.challenge({factorId:factor.id});check();if(ch.error)throw ch.error;vr=await c.auth.mfa.verify({factorId:factor.id,challengeId:ch.data.id,code});}
|
||||
check();if(settled)return;if(vr?.error)throw vr.error;
|
||||
developerMfaEnrollment=null;
|
||||
if(!await waitAal2()){
|
||||
try{await c.auth.refreshSession()}catch(_){}
|
||||
if(!await waitAal2())throw new Error('Код принят, но сессия разработчика не получила AAL2. Выйдите из аккаунта и войдите снова, затем повторите код.');
|
||||
check();const refreshed=await c.auth.refreshSession();check();if(refreshed?.error)throw refreshed.error;
|
||||
if(!await waitAal2())throw new Error('Код принят, но сессия ещё не подтверждена. Войдите в аккаунт заново и введите текущий код.');
|
||||
}
|
||||
toast('2FA подтверждена. Открываю кабинет разработчика…','success',2800);
|
||||
finish(true);
|
||||
}catch(e){busy=false;if(btn){btn.disabled=false;btn.textContent='Подтвердить и открыть'};showError(e)}
|
||||
if(!settled){toast('2FA подтверждена. Открываю кабинет разработчика…','success',2800);finish(true);}
|
||||
}catch(e){if(!current()){accountChanged();return;}if(!settled){busy=false;btn.disabled=false;btn.textContent='Подтвердить и открыть';showError(e);}}
|
||||
};
|
||||
const b=document.getElementById('sunDevMfaVerify'),inp=document.getElementById('sunDevMfaCode');if(b)b.addEventListener('click',verify);if(inp)inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();verify()}});
|
||||
ui.m.addEventListener('click',e=>{if((e.target===ui.m||e.target.closest('[data-close]'))&&!busy)finish(false)});
|
||||
setTimeout(()=>inp?.focus(),60);
|
||||
})
|
||||
btn.addEventListener('click',verify);inp.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();verify();}});
|
||||
// Capture before the generic modal's close handler. Closing during a
|
||||
// verification request must not remove the form or leave a pending lock.
|
||||
ui.m.addEventListener('click',e=>{if(e.target===ui.m||e.target.closest('[data-close]')){e.preventDefault();e.stopImmediatePropagation();if(!busy)finish(false);}},true);
|
||||
ui.m.addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopImmediatePropagation();if(!busy)finish(false);}},true);
|
||||
focusTimer=setTimeout(()=>{if(!settled)inp.focus();},60);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderOverview(body){const d=await rpc('sun_dev_dashboard');body.innerHTML=`<div class="sun-dev-grid"><div class="sun-dev-kpi"><small>Компании</small><b>${Number(d.companies||0)}</b></div><div class="sun-dev-kpi"><small>Аккаунты</small><b>${Number(d.accounts||0)}</b></div><div class="sun-dev-kpi"><small>Активные подписки</small><b>${Number(d.active_subscriptions||0)}</b></div><div class="sun-dev-kpi"><small>Ограниченные компании</small><b>${Number(d.restricted_companies||0)}</b></div><div class="sun-dev-kpi"><small>Ошибки за 24 ч.</small><b>${Number(d.errors_24h||0)}</b></div><div class="sun-dev-kpi"><small>Копии за 24 ч.</small><b>${Number(d.backups_24h||0)}</b></div><div class="sun-dev-kpi"><small>Участники компаний</small><b>${Number(d.memberships||0)}</b></div><div class="sun-dev-kpi"><small>Размер БД</small><b style="font-size:19px">${esc(d.database_size||'—')}</b></div></div><div class="sun-dev-section-grid" style="margin-top:12px"><div class="sun-dev-card"><h3>Платформа</h3><p><b>Версия приложения:</b> <span id="sunDevReleaseVersion">17.6.0</span></p><p><b>PostgreSQL:</b> ${esc(d.postgres_version||'—')}</p><p><b>Последнее изменение рабочей базы:</b> ${fmtDate(d.last_state_at)}</p></div><div class="sun-dev-card"><h3>Безопасность</h3><p><span class="sun-dev-pill ok">MFA AAL2</span> Кабинет разработчика защищён двухфакторной авторизацией.</p><p class="sun-dev-muted">Права разработчика проверяются сервером через sun_platform_admins.</p></div></div>`;try{const r=await fetch('release-manifest.json',{cache:'no-store'});if(r.ok){const m=await r.json();$('#sunDevReleaseVersion').textContent=m.version||'17.6.0'}}catch(_){} }
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session';
|
||||
const CACHE='sun-catering-pwa-v110-20260918-ui-stability-20260919-client-menu-support-bot-training-catalog-banquet-onepage-employee-session-mfa-recovery';
|
||||
const VERSION='20260918-ui-stability';
|
||||
const CORE=[
|
||||
'./core/banquet-client-menu.js?v=20260920-onepage',
|
||||
|
||||
70
tests/developer-mfa.spec.mjs
Normal file
70
tests/developer-mfa.spec.mjs
Normal file
@ -0,0 +1,70 @@
|
||||
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/);
|
||||
});
|
||||
@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
export default defineConfig({
|
||||
testDir:'.',
|
||||
testMatch:['employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'],
|
||||
testMatch:['developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'],
|
||||
timeout:30000,
|
||||
use:{baseURL:'http://127.0.0.1:4173'},
|
||||
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
|
||||
projects:[
|
||||
{name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}},
|
||||
{name:'iphone-webkit',testMatch:['employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
|
||||
{name:'iphone-webkit',testMatch:['developer-mfa.spec.mjs','employee-session.spec.mjs','banquet-client-menu.spec.mjs','training-catalog.spec.mjs','mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
|
||||
{name:'desktop',use:{...devices['Desktop Chrome']}},
|
||||
{name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}
|
||||
]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user