476 lines
17 KiB
JavaScript
476 lines
17 KiB
JavaScript
// Actual desktop Chromium UI on guarded QA. No QA18 credentials, cookies, or MFA values in evidence.
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const crypto = require('node:crypto');
|
|
const {
|
|
chromium,
|
|
} = require('C:/Users/macbook/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
|
const ROOT = path.resolve(__dirname, '..'),
|
|
PRIVATE = path.join(ROOT, 'storage/app/private/investmoz-v2/portal-audit');
|
|
const BASE = 'http://127.0.0.1:18097',
|
|
TARGET = 'desk_restore_20261009_174726_test:55441';
|
|
const actors = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(PRIVATE, 'profiles-v1-actors-private.json'),
|
|
'utf8',
|
|
),
|
|
);
|
|
assert.equal(actors.promoter.id, 21);
|
|
const receipt = path.join(PRIVATE, 'legacy-sectors-browser-qa.json');
|
|
const old = fs.existsSync(receipt)
|
|
? JSON.parse(fs.readFileSync(receipt, 'utf8'))
|
|
: {};
|
|
if (old.started_at)
|
|
fs.copyFileSync(
|
|
receipt,
|
|
path.join(
|
|
PRIVATE,
|
|
'legacy-sectors-browser-attempt-' +
|
|
old.started_at.replaceAll(':', '-') +
|
|
'.json',
|
|
),
|
|
);
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-'),
|
|
shots = path.join(PRIVATE, 'legacy-sectors-browser-' + stamp);
|
|
fs.mkdirSync(shots, { recursive: true });
|
|
const proof = {
|
|
started_at: new Date().toISOString(),
|
|
base: BASE,
|
|
target: TARGET,
|
|
browser: 'Chromium 1243, isolated headless desktop contexts',
|
|
interface_observed: true,
|
|
actor_id: 21,
|
|
active_writes: 0,
|
|
dossiers: old.dossiers || {},
|
|
checks: {},
|
|
screenshots: [],
|
|
events: [],
|
|
phase: 'initialization',
|
|
};
|
|
let browser;
|
|
function save() {
|
|
fs.writeFileSync(receipt, JSON.stringify(proof, null, 2));
|
|
}
|
|
function phase(value) {
|
|
proof.phase = value;
|
|
save();
|
|
console.log(JSON.stringify({ phase: value }));
|
|
}
|
|
function otp(secret) {
|
|
let bits = 0,
|
|
value = 0;
|
|
const bytes = [],
|
|
alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
for (const char of secret.toUpperCase().replace(/=+$/, '')) {
|
|
const digit = alphabet.indexOf(char);
|
|
assert(digit >= 0);
|
|
value = (value << 5) | digit;
|
|
bits += 5;
|
|
if (bits >= 8) {
|
|
bytes.push((value >>> (bits - 8)) & 255);
|
|
bits -= 8;
|
|
}
|
|
}
|
|
const counter = Buffer.alloc(8);
|
|
counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30000)));
|
|
const digest = crypto
|
|
.createHmac('sha1', Buffer.from(bytes))
|
|
.update(counter)
|
|
.digest(),
|
|
offset = digest[19] & 15;
|
|
return String(
|
|
(digest.readUInt32BE(offset) & 0x7fffffff) % 1000000,
|
|
).padStart(6, '0');
|
|
}
|
|
async function context() {
|
|
const ctx = await browser.newContext({
|
|
viewport: { width: 1440, height: 1000 },
|
|
locale: 'pt-MZ',
|
|
reducedMotion: 'reduce',
|
|
});
|
|
ctx.qaConfirmed = false;
|
|
await ctx.route('**/*', async (route) => {
|
|
const request = route.request(),
|
|
url = new URL(request.url());
|
|
if (url.origin !== BASE) return route.abort();
|
|
if (!['GET', 'HEAD'].includes(request.method()))
|
|
assert(ctx.qaConfirmed, 'Actual QA guard precedes writes');
|
|
await route.continue();
|
|
});
|
|
ctx.on('response', (response) => {
|
|
const url = new URL(response.url());
|
|
if (url.origin === BASE && !url.pathname.startsWith('/build-qa-')) {
|
|
const headers = response.headers();
|
|
proof.events.push({
|
|
method: response.request().method(),
|
|
path: url.pathname,
|
|
status: response.status(),
|
|
target: headers['x-investmoz-qa-target'] || null,
|
|
redirect_path: headers.location
|
|
? new URL(headers.location, BASE).pathname
|
|
: null,
|
|
});
|
|
save();
|
|
}
|
|
});
|
|
ctx.on('requestfailed', (request) => {
|
|
const url = new URL(request.url());
|
|
proof.events.push({
|
|
request_failed: url.pathname,
|
|
reason: request.failure()?.errorText,
|
|
});
|
|
save();
|
|
});
|
|
return ctx;
|
|
}
|
|
async function open(page, url) {
|
|
const response = await page.goto(BASE + url, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 45000,
|
|
});
|
|
assert.equal(response.headers()['x-investmoz-qa-target'], TARGET);
|
|
page.context().qaConfirmed = true;
|
|
return response;
|
|
}
|
|
async function shot(page, name, fullPage = true) {
|
|
const file = path.join(shots, name + '.png');
|
|
await page.screenshot({ path: file, fullPage });
|
|
proof.screenshots.push(path.relative(ROOT, file).replaceAll('\\', '/'));
|
|
}
|
|
async function login(page, role) {
|
|
const account = actors[role];
|
|
if (new URL(page.url()).pathname !== '/login') await open(page, '/login');
|
|
await page.locator('#email').fill(account.email);
|
|
await page.locator('#password').fill(account.password);
|
|
await page.locator('[data-test="login-button"]').click();
|
|
await page.waitForURL(BASE + '/two-factor-challenge');
|
|
await page.locator('input[name="code"]').fill(otp(account.totp_secret));
|
|
await page.getByRole('button', { name: 'Continue', exact: true }).click();
|
|
await page.waitForURL(
|
|
(url) => !['/login', '/two-factor-challenge'].includes(url.pathname),
|
|
);
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
async function logout(page) {
|
|
await open(page, '/dashboard');
|
|
const triggers = page.locator('button[aria-haspopup="menu"]');
|
|
for (let i = (await triggers.count()) - 1; i >= 0; i--) {
|
|
await triggers.nth(i).click();
|
|
const action = page.locator('[data-test="logout-button"]');
|
|
if (await action.isVisible()) {
|
|
await action.click();
|
|
await page.waitForURL(BASE + '/');
|
|
return;
|
|
}
|
|
await page.keyboard.press('Escape');
|
|
}
|
|
throw new Error('Visible logout missing');
|
|
}
|
|
async function dossier(page, entry) {
|
|
await page.waitForURL(BASE + '/investimentos/' + entry.id);
|
|
await page
|
|
.getByRole('heading', { name: entry.title, exact: true })
|
|
.waitFor();
|
|
assert.equal(
|
|
await page.getByRole('heading', { name: /^Fase \d+ ·/ }).count(),
|
|
5,
|
|
);
|
|
assert.equal(await page.locator('article[id^="S"]').count(), 19);
|
|
const refs = page.getByRole('region', { name: 'Fontes da ideia' });
|
|
assert.equal(await refs.locator('li').count(), 1);
|
|
assert.equal(
|
|
new URL(await refs.locator('a').first().getAttribute('href'), BASE)
|
|
.pathname,
|
|
entry.href,
|
|
);
|
|
await page
|
|
.getByRole('heading', { name: 'Próxima acção', exact: true })
|
|
.waitFor();
|
|
}
|
|
(async () => {
|
|
browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath:
|
|
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
|
});
|
|
phase('public sector pages, images and search');
|
|
const ctx = await context(),
|
|
page = await ctx.newPage();
|
|
page.setDefaultTimeout(15000);
|
|
for (const [group, expected] of [
|
|
['energia-mineracao', 3],
|
|
['turismo', 12],
|
|
['infraestruturas', 1],
|
|
]) {
|
|
assert.equal((await open(page, '/explorar/' + group)).status(), 200);
|
|
assert.equal(
|
|
await page
|
|
.getByRole('link', { name: 'Ler o artigo', exact: true })
|
|
.count(),
|
|
expected,
|
|
);
|
|
const image = page.locator('main img');
|
|
await image.waitFor();
|
|
await image.evaluate((img) => img.decode());
|
|
assert(
|
|
await image.evaluate((img) => img.complete && img.naturalWidth > 0),
|
|
);
|
|
assert(
|
|
await page.evaluate(
|
|
() => document.documentElement.scrollWidth <= innerWidth,
|
|
),
|
|
);
|
|
await shot(page, 'public-' + group);
|
|
const articleLinks = await page
|
|
.getByRole('link', { name: 'Ler o artigo', exact: true })
|
|
.evaluateAll((links) => links.map((a) => a.getAttribute('href')));
|
|
for (const href of articleLinks) {
|
|
await open(page, href);
|
|
const body = page.getByRole('region', { name: 'Artigo educativo' });
|
|
await body.waitFor();
|
|
assert((await body.innerText()).trim().length > 0);
|
|
await page.locator('main img').evaluate((img) => img.decode());
|
|
assert(
|
|
await page
|
|
.locator('main img')
|
|
.evaluate((img) => img.complete && img.naturalWidth > 0),
|
|
);
|
|
}
|
|
await open(page, '/explorar/' + group);
|
|
await page.getByRole('searchbox').fill('zzzz-no-content-qa');
|
|
await page
|
|
.getByRole('button', { name: 'Pesquisar', exact: true })
|
|
.click();
|
|
await page
|
|
.getByText('Nenhum conteúdo corresponde à pesquisa.', {
|
|
exact: true,
|
|
})
|
|
.waitFor();
|
|
assert.equal(
|
|
await page
|
|
.getByRole('link', { name: 'Ler o artigo', exact: true })
|
|
.count(),
|
|
0,
|
|
);
|
|
}
|
|
await open(page, '/explorar/energia-mineracao');
|
|
await page.getByRole('searchbox').fill('Energia');
|
|
await page.getByRole('button', { name: 'Pesquisar', exact: true }).click();
|
|
await page.locator('h3 a[href="/energia"]').waitFor();
|
|
await open(page, '/energia-mineracao');
|
|
const body = page.getByRole('region', { name: 'Artigo educativo' });
|
|
await body.locator('a[href="/energia"]').click();
|
|
await page.waitForURL(BASE + '/energia');
|
|
await open(page, '/energia-mineracao');
|
|
await body.locator('a[href="/mineracao"]').click();
|
|
await page.waitForURL(BASE + '/mineracao');
|
|
await open(page, '/infraestruturas');
|
|
await page
|
|
.getByRole('region', { name: 'Artigo educativo' })
|
|
.getByRole('heading', {
|
|
name: '2. Sectores Prioritários de Investimento',
|
|
exact: true,
|
|
})
|
|
.waitFor();
|
|
await shot(page, 'infra-original-heading');
|
|
assert.equal((await open(page, '/mercado-financeiro')).status(), 404);
|
|
proof.checks.public_read_search_images_distinct_articles = true;
|
|
save();
|
|
phase('public CTA, QA21 normal MFA, UI creation or continuation');
|
|
for (const href of [
|
|
'/energia',
|
|
'/mineracao',
|
|
'/turismo',
|
|
'/infraestruturas',
|
|
]) {
|
|
await open(page, href);
|
|
await page
|
|
.getByRole('link', {
|
|
name: 'Iniciar ou continuar uma ideia',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await page.waitForURL((url) =>
|
|
['/login', '/investimentos/a-partir-de' + href].includes(
|
|
url.pathname,
|
|
),
|
|
);
|
|
if (new URL(page.url()).pathname === '/login')
|
|
await login(page, 'promoter');
|
|
await page.waitForURL(BASE + '/investimentos/a-partir-de' + href);
|
|
const entry = (proof.dossiers[href] ||= {
|
|
title: 'QA visual — ideia ' + href.slice(1) + ' ' + stamp,
|
|
href,
|
|
});
|
|
if (!entry.id) {
|
|
await page.getByLabel('Título', { exact: true }).fill(entry.title);
|
|
await page
|
|
.getByLabel(
|
|
'Autorizo reutilizar o meu perfil neste dossier privado.',
|
|
)
|
|
.check();
|
|
await page
|
|
.getByRole('button', {
|
|
name: 'Criar e guardar dossier',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await page.waitForURL(/\/investimentos\/[0-9a-f-]{36}$/);
|
|
entry.id = new URL(page.url()).pathname.split('/').pop();
|
|
entry.created_via_interface_at = new Date().toISOString();
|
|
save();
|
|
} else {
|
|
const card = page.getByRole('listitem').filter({
|
|
has: page.getByRole('link', {
|
|
name: entry.title,
|
|
exact: true,
|
|
}),
|
|
});
|
|
await card
|
|
.getByRole('button', {
|
|
name: 'Continuar neste dossier com o artigo',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
}
|
|
await dossier(page, entry);
|
|
await shot(page, 'dossier-' + href.slice(1), false);
|
|
await page
|
|
.getByRole('region', { name: 'Fontes da ideia' })
|
|
.locator('a')
|
|
.first()
|
|
.click();
|
|
await page.waitForURL(
|
|
(url) =>
|
|
url.pathname === href &&
|
|
url.searchParams.get('context') === entry.id,
|
|
);
|
|
await page
|
|
.getByRole('link', {
|
|
name: 'Continuar: ' + entry.title,
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await dossier(page, entry);
|
|
entry.source_return_to_same_dossier = true;
|
|
save();
|
|
}
|
|
phase('visible logout, new MFA browser session, recovery and refresh');
|
|
await logout(page);
|
|
await ctx.close();
|
|
const freshCtx = await context(),
|
|
fresh = await freshCtx.newPage();
|
|
const first = Object.values(proof.dossiers)[0];
|
|
await open(fresh, '/investimentos/' + first.id);
|
|
await fresh.waitForURL(BASE + '/login');
|
|
await login(fresh, 'promoter');
|
|
for (const entry of Object.values(proof.dossiers)) {
|
|
await open(fresh, '/investimentos/' + entry.id);
|
|
await dossier(fresh, entry);
|
|
await fresh.reload({ waitUntil: 'networkidle' });
|
|
await dossier(fresh, entry);
|
|
await open(fresh, '/investimentos/a-partir-de' + entry.href);
|
|
const card = fresh.getByRole('listitem').filter({
|
|
has: fresh.getByRole('link', {
|
|
name: entry.title,
|
|
exact: true,
|
|
}),
|
|
});
|
|
await card
|
|
.getByRole('button', {
|
|
name: 'Continuar neste dossier com o artigo',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await dossier(fresh, entry);
|
|
entry.new_session_recovered_and_refreshed = true;
|
|
entry.single_reference_after_repetition = true;
|
|
save();
|
|
}
|
|
phase('other profile denies dossiers, public context remains private');
|
|
const otherCtx = await context(),
|
|
other = await otherCtx.newPage();
|
|
await open(other, '/login');
|
|
await login(other, 'outsider');
|
|
const publicCtx = await context(),
|
|
publicPage = await publicCtx.newPage();
|
|
for (const entry of Object.values(proof.dossiers)) {
|
|
assert.equal(
|
|
(await open(other, '/investimentos/' + entry.id)).status(),
|
|
403,
|
|
);
|
|
for (const reader of [other, publicPage]) {
|
|
await open(reader, entry.href + '?context=' + entry.id);
|
|
await reader
|
|
.getByRole('region', { name: 'Artigo educativo' })
|
|
.waitFor();
|
|
assert.equal(
|
|
await reader.getByText(entry.title, { exact: false }).count(),
|
|
0,
|
|
);
|
|
}
|
|
entry.other_actor_id = actors.outsider.id;
|
|
entry.denied_status = 403;
|
|
entry.private_context_hidden = true;
|
|
}
|
|
await logout(fresh);
|
|
await freshCtx.close();
|
|
await otherCtx.close();
|
|
await publicCtx.close();
|
|
proof.result = 'passed';
|
|
proof.completed_at = new Date().toISOString();
|
|
save();
|
|
console.log(
|
|
JSON.stringify({
|
|
result: proof.result,
|
|
dossiers: Object.keys(proof.dossiers).length,
|
|
screenshots: proof.screenshots.length,
|
|
interface_observed: true,
|
|
active_writes: 0,
|
|
}),
|
|
);
|
|
})()
|
|
.catch(async (error) => {
|
|
proof.result = 'failed';
|
|
proof.error_type = error.name;
|
|
let message = error.message;
|
|
for (const actor of Object.values(actors))
|
|
for (const key of ['password', 'totp_secret', 'secret'])
|
|
if (actor[key])
|
|
message = message.replaceAll(actor[key], '[REDACTED]');
|
|
proof.error = message
|
|
.replace(/\b\d{6}\b/g, '[MFA OMITIDO]')
|
|
.slice(0, 1600);
|
|
proof.page_states = browser
|
|
? await Promise.all(
|
|
browser
|
|
.contexts()
|
|
.flatMap((ctx) => ctx.pages())
|
|
.map(async (page) => ({
|
|
path: new URL(page.url()).pathname,
|
|
headings: await page
|
|
.locator('h1')
|
|
.allTextContents()
|
|
.catch(() => []),
|
|
alerts: await page
|
|
.getByRole('alert')
|
|
.count()
|
|
.catch(() => 0),
|
|
})),
|
|
)
|
|
: [];
|
|
save();
|
|
console.error(
|
|
JSON.stringify({
|
|
result: 'failed',
|
|
phase: proof.phase,
|
|
type: error.name,
|
|
}),
|
|
);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(async () => {
|
|
if (browser) await browser.close();
|
|
});
|