299 lines
10 KiB
JavaScript
299 lines
10 KiB
JavaScript
// Browser QA helpers: local credentials are read only; receipts omit cookies and auth bodies.
|
|
const fs = require('node:fs'),
|
|
path = require('node:path'),
|
|
assert = require('node:assert/strict'),
|
|
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, '..'),
|
|
DIR = 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(DIR, 'profiles-v1-actors-private.json'), 'utf8'),
|
|
);
|
|
actors.investor18 = JSON.parse(
|
|
fs.readFileSync(path.join(DIR, 'investor-mfa-qa-private.json'), 'utf8'),
|
|
);
|
|
actors.manager = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(DIR, 'resource-http-actors-private.json'),
|
|
'utf8',
|
|
),
|
|
).manager;
|
|
actors.geo = JSON.parse(
|
|
fs.readFileSync(path.join(DIR, 'i0-qa-actor-private.json'), 'utf8'),
|
|
);
|
|
if (fs.existsSync(path.join(DIR, 'revision-geo-actor-qa.json')))
|
|
actors.geo.actor_id = JSON.parse(
|
|
fs.readFileSync(path.join(DIR, 'revision-geo-actor-qa.json'), 'utf8'),
|
|
).actor_id;
|
|
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');
|
|
}
|
|
function sanitized(error) {
|
|
let message = String(error.message || error);
|
|
for (const a of Object.values(actors))
|
|
for (const k of ['password', 'totp_secret', 'secret'])
|
|
if (a[k]) message = message.replaceAll(a[k], '[REDACTED]');
|
|
return message
|
|
.replace(
|
|
/^.*(?:cookie:|x-xsrf-token:|authorization:).*$/gim,
|
|
'[AUTH HEADER OMITTED]',
|
|
)
|
|
.replace(/\b\d{6}\b/g, '[OTP OMITIDO]')
|
|
.slice(0, 1800);
|
|
}
|
|
async function start(name) {
|
|
const receipt = path.join(DIR, name + '.json'),
|
|
stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const prior = fs.existsSync(receipt)
|
|
? JSON.parse(fs.readFileSync(receipt, 'utf8'))
|
|
: {};
|
|
if (fs.existsSync(receipt))
|
|
fs.copyFileSync(
|
|
receipt,
|
|
path.join(DIR, name + '-attempt-' + stamp + '.json'),
|
|
);
|
|
const proof = {
|
|
started_at: new Date().toISOString(),
|
|
base: BASE,
|
|
target: TARGET,
|
|
browser: 'Chromium 1243, isolated contexts',
|
|
active_writes: 0,
|
|
checks: {},
|
|
events: [],
|
|
screenshots: [],
|
|
};
|
|
const shots = path.join(DIR, name + '-' + stamp);
|
|
fs.mkdirSync(shots, { recursive: true });
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
executablePath:
|
|
'C:/Users/macbook/AppData/Local/ms-playwright/chromium-1243/chrome-win64/chrome.exe',
|
|
});
|
|
const assets = new Map(); // Only immutable public QA build assets; never cache authenticated pages/API.
|
|
function save() {
|
|
fs.writeFileSync(receipt, JSON.stringify(proof, null, 2));
|
|
}
|
|
function phase(value) {
|
|
proof.phase = value;
|
|
save();
|
|
console.log(JSON.stringify({ phase: value }));
|
|
}
|
|
async function context(role, width = 1440) {
|
|
const ctx = await browser.newContext({
|
|
viewport: { width, height: 1000 },
|
|
locale: 'pt-MZ',
|
|
reducedMotion: 'reduce',
|
|
});
|
|
ctx.qaConfirmed = false;
|
|
await ctx.route('**/*', async (route) => {
|
|
try {
|
|
const req = route.request(),
|
|
u = new URL(req.url());
|
|
if (u.origin !== BASE) return route.abort();
|
|
if (!['GET', 'HEAD'].includes(req.method()))
|
|
assert(
|
|
ctx.qaConfirmed,
|
|
'QA target must be confirmed before mutation',
|
|
);
|
|
if (
|
|
req.method() === 'GET' &&
|
|
u.pathname.startsWith('/build-qa-opportunities/assets/')
|
|
) {
|
|
if (!assets.has(u.href)) {
|
|
const r = await route.fetch();
|
|
assets.set(u.href, {
|
|
status: r.status(),
|
|
headers: r.headers(),
|
|
body: await r.body(),
|
|
});
|
|
}
|
|
return route.fulfill(assets.get(u.href));
|
|
}
|
|
return await route.continue();
|
|
} catch (error) {
|
|
if (ctx.qaClosing || page.isClosed()) return;
|
|
(proof.harness_errors ||= []).push(sanitized(error));
|
|
save();
|
|
try {
|
|
await route.abort();
|
|
} catch {}
|
|
}
|
|
});
|
|
ctx.on('response', (r) => {
|
|
const u = new URL(r.url());
|
|
if (u.origin === BASE && !u.pathname.startsWith('/build-qa-')) {
|
|
const h = r.headers();
|
|
proof.events.push({
|
|
actor: role,
|
|
method: r.request().method(),
|
|
path: u.pathname,
|
|
status: r.status(),
|
|
target: h['x-investmoz-qa-target'] || null,
|
|
redirect: h.location
|
|
? new URL(h.location, BASE).pathname
|
|
: null,
|
|
});
|
|
save();
|
|
}
|
|
});
|
|
const page = await ctx.newPage();
|
|
ctx.on('close', () => {
|
|
ctx.qaClosing = true;
|
|
});
|
|
page.setDefaultTimeout(25000);
|
|
page.setDefaultNavigationTimeout(45000);
|
|
page.on('pageerror', (e) => {
|
|
(proof.javascript_errors ||= []).push(sanitized(e));
|
|
save();
|
|
});
|
|
return { ctx, page };
|
|
}
|
|
async function open(page, url) {
|
|
const r = await page.goto(BASE + url, {
|
|
waitUntil: 'domcontentloaded',
|
|
});
|
|
assert.equal(r.headers()['x-investmoz-qa-target'], TARGET);
|
|
page.context().qaConfirmed = true;
|
|
return r;
|
|
}
|
|
async function login(page, role) {
|
|
const a = actors[role];
|
|
assert(a, 'Known local QA actor');
|
|
await open(page, '/login');
|
|
await page.locator('#email').fill(a.email);
|
|
await page.locator('#password').fill(a.password);
|
|
await page.locator('[data-test="login-button"]').click();
|
|
await page.waitForURL((u) => u.pathname !== '/login');
|
|
if (new URL(page.url()).pathname === '/two-factor-challenge') {
|
|
await page.locator('input[name="code"]').fill(otp(a.totp_secret));
|
|
await page
|
|
.getByRole('button', { name: 'Continue', exact: true })
|
|
.click();
|
|
await page.waitForURL(
|
|
(u) =>
|
|
!['/login', '/two-factor-challenge'].includes(u.pathname),
|
|
);
|
|
}
|
|
await page.locator('main').first().waitFor();
|
|
const response = await open(page, '/dashboard');
|
|
const html = await response.text();
|
|
const encoded = html.match(/data-page="([^"]+)"/);
|
|
let body;
|
|
if (encoded && encoded[1].startsWith('{')) {
|
|
body = JSON.parse(
|
|
encoded[1]
|
|
.replace(/"/g, '"')
|
|
.replace(/&/g, '&')
|
|
.replace(/'/g, "'"),
|
|
);
|
|
} else {
|
|
const script = html.match(
|
|
/<script[^>]*type="application\/json"[^>]*>([\s\S]*?)<\/script>/,
|
|
);
|
|
assert(
|
|
script,
|
|
'Inertia bootstrap must identify authenticated actor',
|
|
);
|
|
body = JSON.parse(script[1]);
|
|
}
|
|
const actual = body.props.auth.user.id;
|
|
assert.equal(actual, a.id || a.actor_id);
|
|
(proof.authenticated_actors ||= []).push({ role, id: actual });
|
|
save();
|
|
}
|
|
async function logout(page) {
|
|
await open(page, '/dashboard');
|
|
if (page.viewportSize().width < 768) {
|
|
const toggle = page.getByRole('button', {
|
|
name: /Toggle sidebar/i,
|
|
});
|
|
if (await toggle.isVisible()) await toggle.click();
|
|
}
|
|
const buttons = page.locator('button[aria-haspopup="menu"]');
|
|
for (let i = (await buttons.count()) - 1; i >= 0; i--) {
|
|
if (!(await buttons.nth(i).isVisible())) continue;
|
|
await buttons.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 Error('Visible logout missing');
|
|
}
|
|
async function shot(page, name, fullPage = true) {
|
|
assert(
|
|
!['/login', '/two-factor-challenge'].includes(
|
|
new URL(page.url()).pathname,
|
|
),
|
|
'No authentication screenshots',
|
|
);
|
|
const file = path.join(shots, name + '.png');
|
|
await page.screenshot({ path: file, fullPage });
|
|
proof.screenshots.push(path.relative(ROOT, file).replaceAll('\\', '/'));
|
|
save();
|
|
}
|
|
async function json(page, url) {
|
|
const r = await page.request.get(BASE + url, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
assert.equal(r.headers()['x-investmoz-qa-target'], TARGET);
|
|
return { status: r.status(), data: await r.json() };
|
|
}
|
|
return {
|
|
proof,
|
|
prior,
|
|
save,
|
|
phase,
|
|
browser,
|
|
context,
|
|
open,
|
|
login,
|
|
logout,
|
|
shot,
|
|
json,
|
|
stamp,
|
|
receipt,
|
|
};
|
|
}
|
|
module.exports = {
|
|
start,
|
|
BASE,
|
|
TARGET,
|
|
DIR,
|
|
actors,
|
|
assert,
|
|
fs,
|
|
path,
|
|
sanitized,
|
|
};
|