generated from jric11/baseProject
39 lines
4.8 KiB
JavaScript
39 lines
4.8 KiB
JavaScript
|
|
const {chromium}=require('playwright');
|
||
|
|
const assert=require('node:assert/strict');
|
||
|
|
const jsQR=require('jsqr');
|
||
|
|
const {totp}=require('./browser_mfa');
|
||
|
|
(async()=>{
|
||
|
|
const browser=await chromium.launch({executablePath:process.env.HCR_TEST_CHROME||'C:/Program Files/Google/Chrome/Application/chrome.exe',headless:true});
|
||
|
|
const page=await browser.newPage({viewport:{width:390,height:844}}),errors=[],requests=[];let checks=0;
|
||
|
|
page.on('pageerror',e=>errors.push(e.message));page.on('request',r=>requests.push(r.url()));
|
||
|
|
const pass=message=>{checks++;console.log('PASS:',message);};
|
||
|
|
const click=async button=>{await Promise.all([page.waitForNavigation(),button.click()]);};
|
||
|
|
async function scan(){
|
||
|
|
const image=page.locator('.mfa-setup-qr');await image.waitFor({state:'visible'});
|
||
|
|
const pixels=await image.evaluate(async img=>{
|
||
|
|
await img.decode();const canvas=document.createElement('canvas');canvas.width=canvas.height=320;
|
||
|
|
const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,320,320);
|
||
|
|
return Array.from(ctx.getImageData(0,0,320,320).data);
|
||
|
|
});
|
||
|
|
const decoded=jsQR(new Uint8ClampedArray(pixels),320,320,{inversionAttempts:'dontInvert'});
|
||
|
|
assert(decoded,'QR image did not decode');return new URL(decoded.data);
|
||
|
|
}
|
||
|
|
try{
|
||
|
|
await page.goto(process.env.HCR_TEST_URL+'/main.php');await page.locator('#user_email').fill('admin@example.test');await page.locator('#user_password').fill('Admin test password 123!');await click(page.locator('#login button[type=submit]'));
|
||
|
|
const image=page.locator('.mfa-setup-qr');assert.match(await image.getAttribute('src'),/^data:image\/svg\+xml;base64,/);pass('Setup renders a locally generated QR image under the security policy');
|
||
|
|
const uri=await scan(),secret=uri.searchParams.get('secret');assert.equal(uri.protocol,'otpauth:');assert.equal(uri.host,'totp');assert.equal(uri.searchParams.get('issuer'),'My Hunting Club Roster');assert(decodeURIComponent(uri.pathname).includes('admin@example.test'));pass('Independent decoder reads the correct authenticator type, issuer and account');
|
||
|
|
assert.equal(secret,(await page.locator('pre').textContent()).trim());assert.equal(uri.searchParams.get('period')||'30','30');assert.equal(uri.searchParams.get('digits')||'6','6');assert.equal((uri.searchParams.get('algorithm')||'SHA1').toUpperCase(),'SHA1');pass('QR and manual key configure the same secret and verification settings');
|
||
|
|
await page.reload();assert((await scan()).href===uri.href,'Refreshing setup changed its provisioning details');pass('Refreshing pending setup preserves its QR details and manual key');
|
||
|
|
const box=await image.boundingBox();assert(box.width<=390&&box.height===box.width);pass('QR image fits a phone-width browser without distortion');
|
||
|
|
await page.locator('[name=current_password]').fill('Admin test password 123!');await page.locator('[name=mfa_code]').fill(totp(secret));await click(page.getByRole('button',{name:'Verify',exact:true}));
|
||
|
|
await page.getByRole('heading',{name:'Save your recovery codes'}).waitFor();const codes=(await page.locator('pre').textContent()).trim().split(/\s+/);assert.equal(codes.length,10);pass('A code generated from the scanned QR completes MFA enrollment');
|
||
|
|
assert.equal(await page.locator('.mfa-setup-qr').count(),0);await click(page.getByRole('link',{name:'I have saved my recovery codes'}));await page.goto(process.env.HCR_TEST_URL+'/main.php?Xaction=security');assert.equal(await page.locator('.mfa-setup-qr').count(),0);pass('Enrolled accounts and recovery-code pages do not display the setup QR');
|
||
|
|
await page.locator('[name=current_password]').fill('Admin test password 123!');await page.locator('[name=mfa_code]').fill(codes[0]);await click(page.getByRole('button',{name:'Replace lost authenticator',exact:true}));
|
||
|
|
const replacement=await scan();assert.notEqual(replacement.searchParams.get('secret'),secret);assert.equal(replacement.searchParams.get('secret'),(await page.locator('pre').textContent()).trim());pass('Authenticator replacement gets a fresh QR matching the new manual key');
|
||
|
|
await page.locator('[name=current_password]').fill('Admin test password 123!');await page.locator('[name=mfa_code]').fill(totp(replacement.searchParams.get('secret')));await click(page.getByRole('button',{name:'Confirm replacement',exact:true}));await page.getByRole('heading',{name:'Save your recovery codes'}).waitFor();pass('Scanned replacement code confirms the atomic factor swap');
|
||
|
|
assert(requests.every(url=>url.startsWith(process.env.HCR_TEST_URL)||url.startsWith('data:')));assert(!requests.some(url=>url.includes(secret)));pass('Setup makes no external QR requests and puts no secret in request URLs');
|
||
|
|
assert.deepEqual(errors,[]);pass('QR enrollment and replacement produce no browser script errors');
|
||
|
|
console.log(checks,'MFA QR browser checks passed.');
|
||
|
|
}finally{await browser.close();}
|
||
|
|
})().catch(e=>{console.error(e);process.exit(1);});
|