// Exercise the real portal logout button, without detouring through the dashboard. const crypto = require('node:crypto'); const { start, BASE, DIR, path, fs, assert, sanitized, } = require('./portal-visual-qa-lib.cjs'); void (async () => { const q = await start('portal-logout-chromium-20261010'); let ctx, page; const activeHash = () => crypto .createHash('sha256') .update( fs.readFileSync( path.resolve(__dirname, '../public/build/manifest.json'), ), ) .digest('hex'); const activeBefore = activeHash(); const dossier = 'aca8dd16-f9c2-4722-81a1-835191b483fe'; const snapshot = (data) => crypto .createHash('sha256') .update( JSON.stringify({ case: data.case, results: data.results, steps: data.steps, tasks: data.tasks, }), ) .digest('hex'); async function inspect(route, width) { await page.setViewportSize({ width, height: 1000 }); await q.open(page, route); const action = page .getByRole('navigation', { name: 'Sessão da conta', exact: true }) .getByRole('button', { name: 'Terminar sessão', exact: true }); await action.waitFor({ state: 'visible' }); assert.equal(await action.count(), 1); await action.focus(); assert(await action.evaluate((el) => el === document.activeElement)); const layout = await page.evaluate(() => ({ viewport: innerWidth, scrollWidth: document.documentElement.scrollWidth, })); assert( layout.scrollWidth <= width, 'Portal logout must not cause horizontal overflow', ); await page.addScriptTag({ path: path.join(DIR, 'qa-a11y-toolkit/package/axe.min.js'), }); const scan = await page.evaluate(async () => { const r = await axe.run(document.querySelector('header'), { runOnly: { type: 'tag', values: [ 'wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice', ], }, }); return { passes: r.passes.length, violations: r.violations.map((v) => ({ id: v.id, impact: v.impact, targets: v.nodes.map((n) => n.target), })), }; }); assert.equal(scan.violations.length, 0); (q.proof.presentation ||= []).push({ route, width, button_visible: true, keyboard_focus: true, ...layout, ...scan, }); await q.shot( page, 'portal-logout-' + width + '-' + route.replace(/\W/g, '_'), ); } async function logoutHere() { const from = new URL(page.url()).pathname; const response = page.waitForResponse( (r) => new URL(r.url()).pathname === '/logout' && r.request().method() === 'POST', ); await page.locator('[data-test="portal-logout-button"]').click(); const r = await response; assert.equal(r.status(), 302); await page.waitForURL(BASE + '/'); assert.equal( await page.locator('[data-test="portal-logout-button"]').count(), 0, ); const denied = await q.json(page, '/investimentos/' + dossier); assert.equal(denied.status, 401); const privatePage = await q.open(page, '/investimentos/' + dossier); assert.equal(new URL(page.url()).pathname, '/login'); assert.equal(privatePage.status(), 200); (q.proof.logout ||= []).push({ from, method: 'POST', status: 302, public_destination: '/', button_absent_anonymous: true, private_api_status: 401, private_page_destination: '/login', }); q.save(); } try { q.phase( 'Confirm isolated QA, then normal MFA of QA21; do not consume QA18 MFA codes', ); ({ ctx, page } = await q.context('promoter', 1440)); await q.login(page, 'promoter'); const before = await q.json(page, '/investimentos/' + dossier); assert.equal(before.status, 200); const beforeHash = snapshot(before.data); await inspect('/investimentos', 1440); await inspect('/investimentos/' + dossier, 1440); await inspect('/agronegocio', 320); await inspect('/simulador-fiscal?context=' + dossier, 390); await inspect('/investimentos/' + dossier, 1440); q.phase( 'Desktop button: POST logout directly from dossier; reject private API and redirect private page', ); await logoutHere(); await ctx.close(); // Preserve the normal anti-replay control by waiting for a fresh TOTP window. await new Promise((resolve) => setTimeout(resolve, 30000 - (Date.now() % 30000) + 1200), ); q.phase( 'Fresh browser session and normal MFA, recover same dossier, then mobile logout', ); ({ ctx, page } = await q.context('promoter-new-session', 320)); await q.login(page, 'promoter'); await inspect('/investimentos/' + dossier, 320); const recovered = await q.json(page, '/investimentos/' + dossier); assert.equal(recovered.status, 200); assert.equal( snapshot(recovered.data), beforeHash, 'Saved dossier data must survive logout and a fresh session', ); await page.reload({ waitUntil: 'domcontentloaded' }); await page .locator('[data-test="portal-logout-button"]') .waitFor({ state: 'visible' }); await logoutHere(); q.proof.checks = { direct_desktop_logout: true, direct_mobile_logout: true, anonymous_private_api_denied: true, anonymous_private_page_redirect: true, new_session_normal_mfa: true, saved_dossier_unchanged: true, visible_keyboard_accessible_action: true, active_bundle_unchanged: activeHash() === activeBefore, no_qa18_mfa_consumed: true, }; assert(Object.values(q.proof.checks).every(Boolean)); q.proof.active_build_sha256 = activeBefore; q.proof.manual_qa18_logout_confirmed = false; q.proof.result = 'passed'; } catch (error) { q.proof.result = 'failed'; q.proof.error = sanitized(error); console.log(JSON.stringify({ error: q.proof.error })); process.exitCode = 1; } finally { q.proof.finished_at = new Date().toISOString(); q.save(); await q.browser.close(); console.log( JSON.stringify({ result: q.proof.result, checks: q.proof.checks, receipt: path.basename(q.receipt), }), ); } })();