import type { BrowserContext } from '@playwright/test'; import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const ledgerDirectory = fileURLToPath(new URL('../reports/', import.meta.url)); export function recordOrder(event: Record) { mkdirSync(ledgerDirectory, { recursive: true }); appendFileSync(ledgerDirectory + 'created-orders.jsonl', JSON.stringify({ time: new Date().toISOString(), ...event }) + '\n', { mode: 0o600 }); } // The old form uses ISO-8859-1, including superscript station delimiters. function formData(body: string) { const utf8 = new URLSearchParams(body); if (![...utf8.values()].some(value => value.includes('\uFFFD'))) return utf8; const decode = (value: string) => new TextDecoder('windows-1252').decode(Buffer.from(value.replace(/\+/g, ' ').replace(/%([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))), 'latin1')); return new URLSearchParams(body.split('&').map(pair => { const index = pair.indexOf('='); return [decode(pair.slice(0, index)), decode(pair.slice(index + 1))]; })); } function validateCreation(data: URLSearchParams, marker: string): string[] { const problems: string[] = []; const required = { toursubmit: 'Senden', getPriceOnly: '', jb_id: '', csc_id_payer: '878914', jb_orderer_hidden: marker, jb_status_manual: '1', jb_incomplete: '1', jb_multi_factor: '1', sendmail: '0', jb_fixprice: '10,00' }; for (const [key, expected] of Object.entries(required)) { if ((data.get(key) ?? '') !== expected) problems.push(key); } for (const key of ['cr_id_order', 'cr_id_order_hidden', 'cr_id_orders', 'jb_permanent', 'jb_multi', 'jb_globaljob', 'afterwards', 'jb_offer', 'jb_insurance', 'jb_id_parent', 'jb_id_org', 'jb_tourname', 'jb_tourname_hidden', 'tmp_add_jb_ids', 'tmp_add_sids', 'do_delete_suborder', 'jb_offer_mail_hidden', 'jb_cashpayer_mail_hidden']) { if (!['', '0'].includes(data.get(key) ?? '')) problems.push(key); } if (!data.get('request_token')) problems.push('request_token missing'); const stations = (data.get('jobList') ?? '').split('¹').map(row => row.split('²')).filter(row => [0, 1, 3, 4, 6, 7, 8].some(index => row[index])); if (stations.length !== 2 || stations[0][0] !== '878914' || stations[1][0] !== '878915' || !stations.every(row => row.some(cell => cell.includes(marker)))) problems.push('exactly two marked test stations'); return problems; } const origin = 'https://test.sb.assecutor.de'; const pages = new Set([ '/', '/index.php', '/admin/login.php', '/admin/start.php', '/jobs/job_edit.php', '/jobs/job_header.php', '/jobs/job_tour.php', '/jobs/job_options.php', '/jobs/job_tour_list.php', '/admin/cs_csc_list.php', '/css/phoenix.css.php', '/css/navigation.css.php', '/include/maps.googleapis.com_maps_api_js_places.php', ]); export async function installGuard(context: BrowserContext, login = false) { const denied: string[] = []; const allowed: string[] = []; const errors: string[] = []; const knownErrors: string[] = []; const suppressed: string[] = []; const consoleErrors: string[] = []; let creationMarker: string | undefined; let saveAttempts = 0; const readableOrders = new Set(); context.on('page', page => { page.on('console', message => { if (['error', 'warning'].includes(message.type())) consoleErrors.push(message.text()); }); page.on('pageerror', error => { // Still fails the dedicated regression test; do not mask unrelated exceptions. if (["Cannot read properties of null (reading 'top')", 'can\'t access property "top", top.opener is null'].includes(error.message) && /\/jobs\/job_edit\.php[^\n]*:13:\d+/.test(error.stack ?? '')) knownErrors.push(error.stack!); else errors.push(error.stack ?? error.message); }); }); await context.route('**/*', async route => { const request = route.request(); const url = new URL(request.url()); const method = request.method(); const description = `${method} ${url.origin}${url.pathname} [${[...url.searchParams.keys()].join(',')}]`; if (url.origin === origin && url.pathname === '/include/ajaxReqLib.php' && url.searchParams.get('mode') === '500' && url.searchParams.get('value_03') === 'usr_state_newsticker') { suppressed.push('News ticker acknowledgement'); return route.fulfill({ status: 200, contentType: 'application/javascript', body: "retValue = '1';" }); } if (url.pathname === '/include/ajaxJsError.php' && url.origin === origin) { errors.push(url.searchParams.get('message') ?? 'Legacy JavaScript error'); return route.fulfill({ status: 204 }); } if (url.hostname === 'maps.googleapis.com' && url.pathname.endsWith('/gen_204')) { suppressed.push('Google Maps telemetry'); return route.fulfill({ status: 204 }); } let ok = false; if (url.origin === origin) { if (method === 'GET') { ok = /\.(?:js|css|png|gif|jpe?g|svg|ico|woff2?|ttf)$/i.test(url.pathname) || pages.has(url.pathname); // Never permit action parameters on otherwise readable pages. for (const key of ['f_act', 'action', 'save', 'delete', 'sendmail', 'toursubmit']) { if (url.searchParams.get(key) && !(url.pathname === '/admin/cs_csc_list.php' && key === 'f_act' && url.searchParams.get(key) === 'search')) ok = false; } if (url.pathname === '/include/ajaxReqJob.php') { ok = [...url.searchParams.keys()].every(key => ['cs_id', 'csc_id', 'cs_id_photo', 'csc_id_ask_mail', 'cs_id_ask_mail', 'hq_id', 'vht_id', 'csc_id_payer_cash', '_'].includes(key)) && ['cs_id', 'csc_id', 'cs_id_photo', 'csc_id_ask_mail'].some(key => url.searchParams.has(key)); } if (url.pathname === '/include/ajaxReqDocument.php') ok = url.searchParams.get('mode') === '1'; if (url.pathname === '/jobs/get_tournames.php') ok = [...url.searchParams.keys()].every(key => key === 'csc_id') && /^\d+$/.test(url.searchParams.get('csc_id') ?? ''); if (url.pathname === '/admin/jb_detail.php') ok = [...url.searchParams.keys()].every(key => key === 'job_id') && readableOrders.has(url.searchParams.get('job_id') ?? ''); if (url.pathname === '/include/ajaxReqGeneric.php') ok = [...url.searchParams.keys()].every(key => ['incFile', 'fun', 'par_01', 'retIdx', 'retParName', '_'].includes(key)) && url.searchParams.get('incFile') === 'inc_job.inc' && url.searchParams.get('fun') === 'getJobInternalRemarkItems' && readableOrders.has(url.searchParams.get('par_01') ?? '') && url.searchParams.get('retIdx') === '0' && url.searchParams.get('retParName') === '_RAW_TEXT_'; if (url.pathname === '/include/ajaxReqLib.php') { ok = ['2', '6'].includes(url.searchParams.get('mode') ?? ''); if (!ok) denied.push('Library mode=' + url.searchParams.get('mode') + ' field=' + url.searchParams.get('value_03')); } } else if (method === 'POST' && url.pathname === '/jobs/job_options.php') { const data = formData(request.postData() ?? ''); ok = data.get('getPriceOnly') === '1' && data.get('sendmail') !== '1'; if (!ok && creationMarker && saveAttempts === 0) { const problems = validateCreation(data, creationMarker); if (problems.length) denied.push('Order creation rejected: ' + problems.join(', ')); else { saveAttempts++; recordOrder({ state: 'submit-attempt', marker: creationMarker }); ok = true; } } } else if (method === 'POST' && url.pathname === '/admin/cs_csc_list.php') { const data = new URLSearchParams(request.postData() ?? ''); ok = data.get('f_act') === 'search'; } else if (login && method === 'POST' && url.pathname === '/admin/login.php') { const data = new URLSearchParams(request.postData() ?? ''); ok = data.get('f_act') === '' && data.has('f_chk_account') && data.has('f_chk_password'); } } else if (method === 'GET' && ['maps.googleapis.com', 'maps.gstatic.com', 'fonts.googleapis.com', 'fonts.gstatic.com'].includes(url.hostname)) { ok = ['script', 'stylesheet', 'font', 'image'].includes(request.resourceType()); } if (ok) { allowed.push(description); await route.continue(); } else { denied.push(description); await route.abort('blockedbyclient'); } }); return { denied, allowed, errors, knownErrors, suppressed, consoleErrors, armCreation(marker: string) { if (process.env.VOTIAN_UI_CREATE_ORDER !== '1' || !/^UI-TEST-\d{8}T\d{6}-[a-f0-9]{6}$/.test(marker) || creationMarker) throw new Error('Creation is not enabled or already armed'); const ledger = ledgerDirectory + 'created-orders.jsonl'; if (existsSync(ledger)) { const events = readFileSync(ledger, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); const unresolved = events.find(event => event.state === 'submit-attempt' && !events.some(later => later.marker === event.marker && later.state === 'server-confirmed')); if (unresolved) throw new Error('Unconfirmed previous save for ' + unresolved.marker + '. Inspect the server before another creation; see reports/created-orders.jsonl.'); } creationMarker = marker; }, allowOrderRead(id: string) { if (!/^\d+$/.test(id)) throw new Error('Invalid order ID'); readableOrders.add(id); }, get saveAttempts() { return saveAttempts; }, }; }