Files
Investidor-V2/tools/portal-logout-visual-qa.cjs
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

201 lines
7.2 KiB
JavaScript

// 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),
}),
);
}
})();