feat(portal): add service drawers and employee portal preview
This commit is contained in:
39 files changed
+1446
-130
No files matched your search
@@ -0,0 +1,90 @@
|
||||
/** Owner-run only. No credential arguments, environment passwords or plaintext output. */
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decryptPortalStream, encryptPortalStream, validatePortalPayload } from '../src/utils/portal-crypto.mjs';
|
||||
import { collectResource, extractPayload, resourceReferences, rewriteResources } from './portal-resources.mjs';
|
||||
|
||||
const root = fileURLToPath(new URL('../private/employee-portal/', import.meta.url));
|
||||
async function hiddenPrompt(label) {
|
||||
if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error('An interactive private terminal is required');
|
||||
process.stderr.write(label);
|
||||
process.stdin.setRawMode(true); process.stdin.resume();
|
||||
return new Promise((resolve, reject) => {
|
||||
let value = '';
|
||||
const finish = () => { process.stdin.off('data', onData); process.stdin.setRawMode(false); process.stdin.pause(); process.stderr.write('\n'); };
|
||||
const onData = (data) => {
|
||||
for (const char of data.toString('utf8')) {
|
||||
if (char === '\u0003' || char === '\u0004') { finish(); reject(new Error('Canceled')); return; }
|
||||
if (char === '\r' || char === '\n') { finish(); resolve(value); return; }
|
||||
if (char === '\u007f' || char === '\b') value = value.slice(0, -1);
|
||||
else if (char >= ' ') value += char;
|
||||
}
|
||||
};
|
||||
process.stdin.on('data', onData);
|
||||
});
|
||||
}
|
||||
async function main() {
|
||||
const [command, source, ...extra] = process.argv.slice(2);
|
||||
if (!['collect', 'rewrite'].includes(command) || !source || extra.length) throw new Error('Usage: node tools/employee-portal-migrate.mjs collect|rewrite /absolute/private/footer-source.txt');
|
||||
// No scanning for credentials. The original is read only; never copied into Git.
|
||||
const payload = validatePortalPayload(extractPayload(await fs.readFile(source, 'utf8')));
|
||||
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(root, 0o700);
|
||||
const manifestPath = path.join(root, 'manifest.json');
|
||||
let previous = { resources: [] };
|
||||
try { previous = JSON.parse(await fs.readFile(manifestPath, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') throw new Error('Private manifest could not be read'); }
|
||||
const resources = new Map(previous.resources.map((r) => [r.sourceUrl, { ...r, references: [] }]));
|
||||
const nextStreams = [], unlocked = [];
|
||||
let rewritten = 0;
|
||||
for (let index = 0; index < payload.streams.length; index++) {
|
||||
let pass = await hiddenPrompt(`Access code for portal view ${index + 1} (not echoed): `);
|
||||
const stream = payload.streams[index];
|
||||
let html = await decryptPortalStream(stream, pass);
|
||||
if (html === null) throw new Error('Code did not authenticate the requested view; no content was written');
|
||||
// Reject identical credentials across views to preserve stream separation.
|
||||
for (let other = 0; other < payload.streams.length; other++) if (other !== index && await decryptPortalStream(payload.streams[other], pass) !== null) throw new Error('View codes must be distinct');
|
||||
for (const ref of resourceReferences(html, stream.id)) {
|
||||
const record = resources.get(ref.sourceUrl) ?? { sourceUrl: ref.sourceUrl, originalPath: ref.originalPath, classification: 'UNRESOLVED', references: [], downloadStatus: 'pending', deliveryPath: null, rewriteApproved: false, destinationImplemented: false, destinationVerified: false, destinationAccessControlled: false };
|
||||
record.references.push(ref); resources.set(ref.sourceUrl, record);
|
||||
}
|
||||
if (command === 'rewrite') {
|
||||
const result = rewriteResources(html, previous.resources.filter((r) => r.references.some((ref) => ref.view === stream.id)));
|
||||
html = result.html; rewritten += result.count;
|
||||
const next = await encryptPortalStream(stream.id, html, pass);
|
||||
if (await decryptPortalStream(next, pass) !== html) throw new Error('Re-encryption verification failed');
|
||||
for (const other of nextStreams) if (await decryptPortalStream(other, pass) !== null) throw new Error('View separation verification failed');
|
||||
nextStreams.push(next);
|
||||
}
|
||||
unlocked.push(stream.id); html = ''; pass = '';
|
||||
}
|
||||
const stamp = new Date().toISOString().replaceAll(':', '-');
|
||||
if (command === 'collect') {
|
||||
const approvedOrigins = Array.isArray(previous.approvedOrigins)
|
||||
? previous.approvedOrigins.filter((origin) => {
|
||||
try { return new URL(origin).origin === origin && new URL(origin).protocol === 'https:'; }
|
||||
catch { return false; }
|
||||
})
|
||||
: undefined;
|
||||
for (const [url, record] of resources) {
|
||||
try { resources.set(url, await collectResource(record, path.join(root, 'hubfs'), approvedOrigins)); }
|
||||
catch { resources.set(url, { ...record, downloadStatus: 'failed-or-unapproved-redirect', verificationStatus: 'owner-review-required' }); }
|
||||
}
|
||||
const manifest = {
|
||||
version: 1,
|
||||
updated: stamp,
|
||||
approvedOrigins: approvedOrigins ?? ['https://www.azinstitute4autism.com'],
|
||||
views: unlocked,
|
||||
resources: [...resources.values()],
|
||||
};
|
||||
// Retain each previous manifest; no original evidence is overwritten.
|
||||
try { await fs.copyFile(manifestPath, path.join(root, `manifest-before-${stamp}.json`), fs.constants.COPYFILE_EXCL); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 });
|
||||
await fs.writeFile(path.join(root, `ciphertext-${stamp}.json`), JSON.stringify(payload), { mode: 0o600, flag: 'wx' });
|
||||
} else {
|
||||
await fs.writeFile(path.join(root, `rewritten-ciphertext-${stamp}.json`), JSON.stringify(validatePortalPayload({ ...payload, streams: nextStreams })), { mode: 0o600, flag: 'wx' });
|
||||
}
|
||||
const list = [...resources.values()];
|
||||
console.log(JSON.stringify({ command, distinctResources: list.length, totalReferences: list.reduce((n, r) => n + r.references.length, 0), collected: list.filter((r) => ['downloaded','verified-existing'].includes(r.downloadStatus)).length, unresolved: list.filter((r) => r.classification === 'UNRESOLVED').length, rewritten }, null, 2));
|
||||
}
|
||||
main().catch(() => { console.error('Private migration stopped. Check access codes, source format, private manifest and file permissions locally. No credential or content details are logged.'); process.exitCode = 1; });
|
||||
@@ -65,6 +65,7 @@ try {
|
||||
viewport: { width, height: width < 500 ? 844 : 960 },
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
await context.addInitScript(() => localStorage.setItem('aia:service-inquiry:auto-suppressed-until', String(Date.now() + 86400000)));
|
||||
await context.routeWebSocket('**/*', (socket) => socket.close());
|
||||
await context.route('**/*', async (route) => {
|
||||
const request = route.request();
|
||||
@@ -103,7 +104,7 @@ try {
|
||||
|
||||
const attemptsBefore = evidence.jotformAttempts.length;
|
||||
if (expected.drawer) {
|
||||
const opener = page.locator('[data-service-inquiry-open]');
|
||||
const opener = page.locator('[data-service-inquiry-trigger]');
|
||||
assert.equal(await opener.count(), 1, expected.route);
|
||||
assert.equal(await iframe.getAttribute('src'), null, expected.route + ' must defer the iframe source');
|
||||
assert.equal(await iframe.getAttribute('data-jotform-iframe-url'), url, expected.route);
|
||||
@@ -121,7 +122,7 @@ try {
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => !document.querySelector('[data-service-inquiry-drawer]')?.open);
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute('data-service-inquiry-open')), '', expected.route + ' restores focus');
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute('data-service-inquiry-trigger')), expected.route.split('/').at(-1), expected.route + ' restores focus');
|
||||
await opener.click();
|
||||
await page.waitForTimeout(20);
|
||||
assert.equal(await page.locator('[data-jotform-form-a] iframe').count(), 1, expected.route + ' must retain one iframe');
|
||||
@@ -193,7 +194,7 @@ try {
|
||||
window.setTimeout = (callback, timeout, ...args) => original(callback, timeout === 15000 ? 80 : timeout, ...args);
|
||||
});
|
||||
await fallbackPage.goto(origin + '/aba-therapy', { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
await fallbackPage.locator('[data-service-inquiry-open]').click();
|
||||
await fallbackPage.locator('[data-service-inquiry-trigger]').click();
|
||||
const fallback = fallbackPage.locator('[data-jotform-iframe-fallback]');
|
||||
await fallbackPage.waitForFunction(() => document.querySelector('[data-jotform-iframe-fallback]')?.hidden === false);
|
||||
assert.equal(await fallback.isVisible(), true, 'blocked drawer request exposes the hosted fallback after timeout');
|
||||
|
||||
@@ -155,6 +155,7 @@ test('consultation is isolated-test-only and requires its additional explicit ap
|
||||
assert.equal(resolveFormAIframe(consultation({ isolatedTestApproved: 'false' })).reason, 'isolated-test-not-approved');
|
||||
assert.equal(resolveFormAIframe(consultation({ environmentRole: 'staging' })).reason, 'invalid-environment-role');
|
||||
assert.equal(resolveFormAIframe(consultation({ allowIndexing: 'true' })).reason, 'indexing-enabled');
|
||||
for (const value of [undefined, '', 'invalid']) assert.equal(resolveFormAIframe(consultation({ allowIndexing: value })).enabled, false);
|
||||
assert.equal(resolveFormAIframe(consultation({ route: '/es/aba-therapy', locale: 'en' })).reason, 'locale-not-authorized');
|
||||
assert.equal(resolveFormAIframe(production({ route: '/client-consultation' })).reason, 'route-not-authorized');
|
||||
assert.equal(resolveFormAIframe(production({ route: '/client-consultation', consultationTestApproved: 'true' })).reason, 'route-not-authorized');
|
||||
@@ -192,10 +193,10 @@ test('the adapter preserves seven production routes and supports the separate bi
|
||||
assert.match(shell, /PUBLIC_AIA_FORMS_CONSULTATION_TEST_APPROVED/);
|
||||
assert.match(shell, /JotformIframe/);
|
||||
assert.match(drawer, /lang: SupportedLocale/);
|
||||
assert.match(drawer, /data-service-inquiry-open/);
|
||||
assert.match(drawer, /data-explicit-activation/);
|
||||
assert.match(drawer, /data-consultation-test/);
|
||||
assert.match(drawer, /serviceCopy.heading/);
|
||||
assert.match(drawer, /deferUntilActivated/);
|
||||
assert.match(drawer, /aia:jotform-activate/);
|
||||
assert.match(fs.readFileSync('src/scripts/service-inquiry.ts', 'utf8'), /aia:jotform-activate/);
|
||||
assert.match(drawer, /Cerrar el formulario de consulta/);
|
||||
assert.doesNotMatch(drawer, /No WAITLIST!|guarantee your child|now open for enrollment/i);
|
||||
assert.match(layout, /entry\.data\.lang === 'en' \|\| entry\.data\.lang === 'es'/);
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function verifyArtifact(root, mode = 'staging') {
|
||||
}
|
||||
});
|
||||
const adapters = formAAdapters(html);
|
||||
if (/<script[^>]*data-portal-payload[^>]*>\s*(?!null\s*<)[\s\S]*?\"streams\"[\s\S]*?<\/script>/i.test(html)) errors.push(`${route}: private portal ciphertext is not an approved release artifact`);
|
||||
if (!title) errors.push(`${route}: missing title`);
|
||||
if (!canonical) errors.push(`${route}: missing canonical`);
|
||||
if (!robots) errors.push(`${route}: missing robots directive`);
|
||||
@@ -191,6 +192,7 @@ export async function verifyArtifact(root, mode = 'staging') {
|
||||
const digest = createHash('sha256');
|
||||
for (const file of files) {
|
||||
const relative = path.relative(root, file).replaceAll(path.sep, '/');
|
||||
if (/(?:^|\/)(?:private|hubfs)(?:\/|$)/i.test(relative)) errors.push('private/legacy portal resource directory in artifact');
|
||||
if (/\.(?:html?|js|mjs|css|json|xml|txt)$/i.test(relative)) {
|
||||
const contents = await fs.readFile(file, 'utf8');
|
||||
if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative);
|
||||
|
||||
@@ -129,3 +129,14 @@ test('artifact digest is deterministic for unchanged files', async () => {
|
||||
assert.equal(first.artifactSha256, second.artifactSha256);
|
||||
assert.equal(first.artifactFiles, second.artifactFiles);
|
||||
});
|
||||
|
||||
test('release verification rejects private portal resources and encrypted payloads', async () => {
|
||||
const root = await fixture('staging');
|
||||
await fs.mkdir(path.join(root, 'private/employee-portal'), { recursive: true });
|
||||
await fs.writeFile(path.join(root, 'private/employee-portal/manifest.json'), '{}');
|
||||
await fs.appendFile(path.join(root, 'index.html'), '<script type="application/json" data-portal-payload>{"streams":[]}</script>');
|
||||
const result = await verifyArtifact(root, 'staging');
|
||||
assert.equal(result.passed, false);
|
||||
assert.ok(result.errors.some((error) => error.includes('private portal ciphertext')));
|
||||
assert.ok(result.errors.some((error) => error.includes('private/legacy')));
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import * as cheerio from 'cheerio';
|
||||
export const sourceOrigin = 'https://www.azinstitute4autism.com';
|
||||
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
export function extractPayload(source) {
|
||||
const $ = cheerio.load(source);
|
||||
return JSON.parse($('script[type="application/json"]').filter((_, el) => {
|
||||
try { return Array.isArray(JSON.parse($(el).text()).streams); } catch { return false; }
|
||||
}).first().text());
|
||||
}
|
||||
export function resourceReferences(html, view) {
|
||||
const $ = cheerio.load(html, {}, false), references = [];
|
||||
const record = (value, label, attribute) => {
|
||||
let url;
|
||||
try { url = new URL(value, sourceOrigin); } catch { return; }
|
||||
if (url.origin !== sourceOrigin || !url.pathname.startsWith('/hubfs/')) return;
|
||||
url.hash = '';
|
||||
references.push({ original: value, sourceUrl: url.href, originalPath: url.pathname, label, attribute, view });
|
||||
};
|
||||
$('[href], [src], [srcset]').each((_, el) => {
|
||||
for (const attr of ['href', 'src']) if ($(el).attr(attr)) record($(el).attr(attr), $(el).text().trim() || $(el).attr('alt') || '', attr);
|
||||
for (const value of ($(el).attr('srcset') || '').split(',').filter(Boolean)) record(value.trim().split(/\s+/)[0], $(el).attr('alt') || '', 'srcset');
|
||||
});
|
||||
// Unknown CSS resource dependencies must not be silently considered migrated.
|
||||
for (const match of html.matchAll(/url\(\s*['"]?([^)'"\s]+)['"]?\s*\)/g)) record(match[1], '', 'css-url');
|
||||
return references;
|
||||
}
|
||||
export function rewriteResources(html, records) {
|
||||
const mappings = new Map();
|
||||
for (const record of records) {
|
||||
if (!record.rewriteApproved) continue;
|
||||
if (!record.destinationImplemented || !record.destinationVerified || !record.deliveryPath?.startsWith('/') || record.deliveryPath.startsWith('//') || /[?<>"'\s]/.test(record.deliveryPath)) throw new Error('Unverified resource mapping');
|
||||
if (!['PUBLIC-APPROVED', 'STAFF-INTERNAL', 'MANAGEMENT-INTERNAL'].includes(record.classification)) throw new Error('Unclassified resource mapping');
|
||||
if (record.classification !== 'PUBLIC-APPROVED' && !record.destinationAccessControlled) throw new Error('Internal destination lacks access control');
|
||||
for (const ref of record.references) {
|
||||
if (ref.attribute === 'css-url' || ref.attribute === 'srcset') throw new Error('CSS/srcset rewriting requires separate review');
|
||||
mappings.set(ref.original, record.deliveryPath);
|
||||
}
|
||||
}
|
||||
let count = 0;
|
||||
// Attribute-only replacement preserves all unrelated original bytes and labels.
|
||||
const rewritten = html.replace(/\b(href|src)\s*=\s*(["'])(.*?)\2/gi, (whole, attr, quote, value) => {
|
||||
const decoded = cheerio.load(`<a href=${quote}${value}${quote}></a>`)('a').attr('href');
|
||||
const target = mappings.get(decoded);
|
||||
if (!target) return whole;
|
||||
count++; return `${attr}=${quote}${target}${quote}`;
|
||||
});
|
||||
return { html: rewritten, count };
|
||||
}
|
||||
export async function collectResource(record, directory, approvedOrigins = [sourceOrigin], fetcher = fetch) {
|
||||
const limit = 50 * 1024 * 1024;
|
||||
const name = sha256(record.sourceUrl);
|
||||
const destination = path.join(directory, name);
|
||||
if (record.sha256 && record.localFile === name) {
|
||||
try {
|
||||
const bytes = await fs.readFile(destination);
|
||||
if (sha256(bytes) === record.sha256 && bytes.length === record.size) return { ...record, downloadStatus: 'verified-existing' };
|
||||
} catch { /* Retry missing/invalid local copy without overwriting it. */ }
|
||||
}
|
||||
let url = new URL(record.sourceUrl), response;
|
||||
for (let redirects = 0; redirects <= 4; redirects++) {
|
||||
if (url.protocol !== 'https:' || url.username || url.password || !approvedOrigins.includes(url.origin) || !url.pathname.startsWith('/hubfs/')) throw new Error('Unapproved resource destination');
|
||||
response = await fetcher(url, { redirect: 'manual', signal: AbortSignal.timeout(20000), headers: { Accept: '*/*' } });
|
||||
if ([301,302,303,307,308].includes(response.status)) {
|
||||
await response.body?.cancel();
|
||||
const location = response.headers.get('location');
|
||||
if (!location || redirects === 4) throw new Error('Resource redirect limit');
|
||||
url = new URL(location, url); continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!response?.ok || Number(response.headers.get('content-length') || 0) > limit) { await response?.body?.cancel(); throw new Error('Resource HTTP/size failure'); }
|
||||
const mime = (response.headers.get('content-type') || '').split(';')[0].toLowerCase();
|
||||
const chunks = []; let size = 0;
|
||||
const reader = response.body.getReader();
|
||||
// Fetch timeout remains active through body streaming.
|
||||
try { while (true) { const next = await reader.read(); if (next.done) break; size += next.value.length; if (size > limit) throw new Error('Resource size limit'); chunks.push(next.value); } }
|
||||
catch (error) { await reader.cancel(); throw error; }
|
||||
const bytes = Buffer.concat(chunks);
|
||||
if (!size || /(?:html|javascript)/i.test(mime) || /^\s*(?:<!doctype html|<html|<script)/i.test(bytes.subarray(0, 512).toString())) throw new Error('Resource is empty or executable/error HTML');
|
||||
if ((mime === 'application/pdf' || /\.pdf$/i.test(url.pathname)) && !bytes.subarray(0, 5).equals(Buffer.from('%PDF-'))) throw new Error('Invalid PDF signature');
|
||||
if (mime === 'image/png' && bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') throw new Error('Invalid PNG signature');
|
||||
if (mime === 'image/jpeg' && bytes.subarray(0, 3).toString('hex') !== 'ffd8ff') throw new Error('Invalid JPEG signature');
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(destination, bytes, { flag: 'wx', mode: 0o600 });
|
||||
return { ...record, localFile: name, sha256: sha256(bytes), size, mime, downloadStatus: 'downloaded', verificationStatus: 'size-hash-and-applicable-signature-checked' };
|
||||
}
|
||||
@@ -542,12 +542,12 @@ test('Phase 5A retains independently supported program ages, ratios and payment
|
||||
|
||||
test('Phase 5A conversion CTAs resolve to the intended published local route or form anchor', () => {
|
||||
const cases = [
|
||||
['/aba-therapy', 'Make an Appointment', '/client-consultation'],
|
||||
['/autism-evaluations', 'Make an Appointment', '/client-consultation'],
|
||||
['/learner-social-club', 'Enroll Now', '/client-consultation'],
|
||||
['/es/aba-therapy', 'Hacer una cita', '/es/client-consultation'],
|
||||
['/es/autism-evaluations', 'Programar una cita', '/es/client-consultation'],
|
||||
['/es/learner-social-club', 'Enroll Now', '/es/client-consultation'],
|
||||
['/aba-therapy', 'Request a Consultation', '/client-consultation'],
|
||||
['/autism-evaluations', 'Request a Consultation', '/client-consultation'],
|
||||
['/learner-social-club', 'Request a Consultation', '/client-consultation'],
|
||||
['/es/aba-therapy', 'Solicitar una consulta', '/es/client-consultation'],
|
||||
['/es/autism-evaluations', 'Solicitar una consulta', '/es/client-consultation'],
|
||||
['/es/learner-social-club', 'Solicitar una consulta', '/es/client-consultation'],
|
||||
['/referrals', 'Refer a Client', '#form-title'],
|
||||
['/aba-therapy-intake-process', 'Take the First Step', '/client-consultation'],
|
||||
['/es/aba-therapy-intake-process', 'Take the First Step', '/es/client-consultation'],
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Guarded consultation-only Form A browser regression.
|
||||
* Run after an explicitly gated isolated-test build. Every Jotform request is
|
||||
* fulfilled or aborted locally; no Jotform request is transmitted.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { encryptPortalStream, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
const moduleName = process.env.PLAYWRIGHT_MODULE
|
||||
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
|
||||
: 'playwright';
|
||||
const output = await fs.mkdtemp('/tmp/aia-service-portal-');
|
||||
const routes = [
|
||||
{ route: '/client-consultation', locale: 'en', drawer: false, screenshot: 'client-consultation' },
|
||||
{ route: '/es/client-consultation', locale: 'es', drawer: false, screenshot: 'es-client-consultation' },
|
||||
{ route: '/schedule-consultation', locale: 'en', drawer: false, screenshot: 'schedule-consultation' },
|
||||
{ route: '/aba-therapy', locale: 'en', drawer: true, screenshot: 'aba-therapy' },
|
||||
{ route: '/autism-evaluations', locale: 'en', drawer: true, screenshot: 'autism-evaluations' },
|
||||
{ route: '/learner-social-club', locale: 'en', drawer: true, screenshot: 'learner-social-club' },
|
||||
{ route: '/es/aba-therapy', locale: 'es', drawer: true, screenshot: 'es-aba-therapy' },
|
||||
{ route: '/es/autism-evaluations', locale: 'es', drawer: true, screenshot: 'es-autism-evaluations' },
|
||||
{ route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' },
|
||||
];
|
||||
const widths = [320, 390, 768, 1440];
|
||||
const evidence = { routes, widths, checks: 0, blocked: [], jotformAttempts: [], screenshots: [], fallbackChecks: [], status: 'failed' };
|
||||
const reserve = net.createServer();
|
||||
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
|
||||
const port = reserve.address().port;
|
||||
await new Promise((resolve) => reserve.close(resolve));
|
||||
const origin = 'http://127.0.0.1:' + port;
|
||||
let browser;
|
||||
let preview;
|
||||
|
||||
function expectedUrl(locale) {
|
||||
return 'https://form.jotform.com/262946078314059?formContext=consultation&language=' + locale;
|
||||
}
|
||||
|
||||
try {
|
||||
const { chromium } = await import(moduleName);
|
||||
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE
|
||||
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE }
|
||||
: { headless: true });
|
||||
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
|
||||
cwd: root,
|
||||
env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
const reachable = () => new Promise((resolve) => {
|
||||
const request = http.get(origin + '/', (response) => { response.resume(); resolve(response.statusCode === 200); });
|
||||
request.setTimeout(500, () => request.destroy());
|
||||
request.on('error', () => resolve(false));
|
||||
});
|
||||
for (let i = 0; i < 60 && !(await reachable()); i++) await delay(250);
|
||||
assert.equal(await reachable(), true, 'Loopback preview did not become reachable');
|
||||
|
||||
|
||||
const key = 'aia:service-inquiry:auto-suppressed-until';
|
||||
const duration = 30 * 86400000;
|
||||
const staffCode = randomBytes(24).toString('hex');
|
||||
const managementCode = randomBytes(24).toString('hex');
|
||||
const payload = { alg: portalAlgorithm, streams: [
|
||||
await encryptPortalStream('fixture-staff', '<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="https://example.invalid/document">Synthetic resource</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">', staffCode),
|
||||
await encryptPortalStream('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>', managementCode),
|
||||
] };
|
||||
const guard = async (context, inject = false) => {
|
||||
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
|
||||
await context.route('**/*', async (route) => {
|
||||
const req = route.request(), url = new URL(req.url());
|
||||
if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) {
|
||||
if (inject && url.pathname === '/employee-portal') {
|
||||
const response = await route.fetch();
|
||||
const html = (await response.text()).replace(/(<script[^>]*data-portal-payload[^>]*>)[\s\S]*?(<\/script>)/, '$1' + JSON.stringify(payload) + '$2');
|
||||
return route.fulfill({ response, body: html });
|
||||
}
|
||||
return route.continue();
|
||||
}
|
||||
evidence.blocked.push({ method: req.method(), origin: url.origin, path: url.pathname });
|
||||
if (url.origin === 'https://form.jotform.com') {
|
||||
evidence.jotformAttempts.push({ method: req.method(), url: url.href });
|
||||
return route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Local fixture</title><label>Fixture contact <input></label><button type="button">Fixture Submit</button>' });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
};
|
||||
if (!process.argv.includes('--portal-only')) {
|
||||
for (const width of widths) {
|
||||
for (const target of routes.filter((r) => r.drawer)) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block', reducedMotion: 'reduce' });
|
||||
await guard(context);
|
||||
const page = await context.newPage();
|
||||
await page.clock.install({ time: new Date('2026-10-29T12:00:00Z') }); await page.clock.pauseAt(new Date('2026-10-29T12:00:01Z'));
|
||||
await page.goto(origin + target.route, { waitUntil: 'domcontentloaded' });
|
||||
const dialog = page.locator('[data-service-inquiry-drawer]');
|
||||
const frame = dialog.locator('iframe');
|
||||
const before = evidence.jotformAttempts.length;
|
||||
assert.equal(await frame.getAttribute('src'), null);
|
||||
await page.clock.runFor(3999);
|
||||
assert.equal(await dialog.evaluate((d) => d.open), false);
|
||||
await page.clock.runFor(100);
|
||||
assert.equal(await dialog.evaluate((d) => d.open), true);
|
||||
assert.equal(await frame.getAttribute('src'), expectedUrl(target.locale));
|
||||
await page.waitForFunction(() => document.querySelector('iframe[data-jotform-iframe-frame]')?.src.includes('consultation'));
|
||||
assert.equal(await page.locator('[data-service-inquiry-trigger]').count(), 1);
|
||||
const heading = await dialog.locator('h2').textContent();
|
||||
assert.ok(target.locale === 'es' ? /Conozca|preguntas/.test(heading) : /Explore|Questions/.test(heading));
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await page.screenshot({ path: path.join(output, target.screenshot + '-' + width + '.png'), fullPage: true });
|
||||
evidence.screenshots.push(target.screenshot + '-' + width + '.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.clock.runFor(100);
|
||||
assert.equal(await dialog.evaluate((d) => d.open), false);
|
||||
const expiry = await page.evaluate((k) => Number(localStorage.getItem(k)), key);
|
||||
const now = await page.evaluate(() => Date.now());
|
||||
assert.ok(expiry > now && expiry <= now + duration);
|
||||
await page.clock.runFor(10000);
|
||||
await page.locator('[data-service-inquiry-trigger]').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.clock.runFor(100);
|
||||
assert.equal(await dialog.evaluate((d) => d.open), true);
|
||||
assert.equal(await frame.count(), 1);
|
||||
await page.locator('[data-service-inquiry-close]').click();
|
||||
await page.clock.runFor(100);
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.hasAttribute('data-service-inquiry-trigger')), true);
|
||||
const renewed = await page.evaluate((k) => Number(localStorage.getItem(k)), key);
|
||||
assert.ok(renewed > expiry);
|
||||
assert.ok(evidence.jotformAttempts.length <= before + 1);
|
||||
evidence.checks++;
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
// Scroll wins, suppression crosses locale/service navigation, manual opening bypasses it.
|
||||
const context = await browser.newContext({ serviceWorkers: 'block', reducedMotion: 'reduce' });
|
||||
await guard(context);
|
||||
const page = await context.newPage(); await page.clock.install({ time: new Date('2026-10-29T12:00:00Z') }); await page.clock.pauseAt(new Date('2026-10-29T12:00:01Z'));
|
||||
await page.goto(origin + '/aba-therapy', { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => scrollTo({ top: (document.documentElement.scrollHeight - innerHeight) * .6, behavior: 'instant' }));
|
||||
await page.clock.runFor(100);
|
||||
assert.equal(await page.locator('dialog').evaluate((d) => d.open), true);
|
||||
await page.keyboard.press('Escape'); await page.clock.runFor(100);
|
||||
await page.goto(origin + '/es/learner-social-club', { waitUntil: 'domcontentloaded' });
|
||||
await page.clock.runFor(5000);
|
||||
await page.evaluate(() => scrollTo({ top: document.documentElement.scrollHeight, behavior: 'instant' })); await page.clock.runFor(100);
|
||||
assert.equal(await page.locator('dialog').evaluate((d) => d.open), false);
|
||||
assert.equal(await page.locator('dialog iframe').getAttribute('src'), null);
|
||||
await page.locator('[data-service-inquiry-trigger]').click(); await page.clock.runFor(100);
|
||||
assert.equal(await page.locator('dialog').evaluate((d) => d.open), true);
|
||||
await page.keyboard.press('Escape'); await page.clock.runFor(100);
|
||||
// A second page-load initialization disposes previous listeners without duplicating activation.
|
||||
await page.evaluate(() => document.dispatchEvent(new Event('astro:page-load')));
|
||||
await page.locator('[data-service-inquiry-trigger]').click(); await page.clock.runFor(100);
|
||||
assert.equal(await page.locator('dialog iframe').count(), 1);
|
||||
evidence.checks += 2;
|
||||
await context.close();
|
||||
|
||||
for (const preference of ['expired', 'invalid', 'denied', 'editing', 'short', 'other-modal']) {
|
||||
const ctx = await browser.newContext({ serviceWorkers: 'block', reducedMotion: 'reduce' }); await guard(ctx);
|
||||
await ctx.addInitScript(({ preference, key }) => {
|
||||
if (preference === 'expired') localStorage.setItem(key, '1');
|
||||
if (preference === 'invalid') localStorage.setItem(key, 'broken');
|
||||
if (preference === 'denied') Object.defineProperty(window, 'localStorage', { get() { throw new Error('Denied'); } });
|
||||
}, { preference, key });
|
||||
const p = await ctx.newPage(); await p.clock.install({ time: new Date('2026-10-29T12:00:00Z') }); await p.clock.pauseAt(new Date('2026-10-29T12:00:01Z')); await p.goto(origin + '/aba-therapy', { waitUntil: 'domcontentloaded' });
|
||||
if (preference === 'editing') await p.evaluate(() => { const field = document.createElement('input'); document.body.prepend(field); field.focus(); });
|
||||
if (preference === 'other-modal') await p.evaluate(() => { const d = document.createElement('dialog'); document.body.append(d); d.showModal(); });
|
||||
if (preference === 'short') {
|
||||
await p.evaluate(() => { document.querySelectorAll('main > :not([data-service-inquiry-drawer]), header, footer').forEach((e) => e instanceof HTMLElement && (e.style.display = 'none')); });
|
||||
await p.clock.runFor(100);
|
||||
assert.equal(await p.locator('[data-service-inquiry-drawer]').evaluate((d) => d.open), false);
|
||||
}
|
||||
await p.clock.runFor(4100);
|
||||
assert.equal(await p.locator('[data-service-inquiry-drawer]').evaluate((d) => d.open), !['editing', 'other-modal'].includes(preference), preference);
|
||||
evidence.checks++; await ctx.close();
|
||||
}
|
||||
const backContext = await browser.newContext({ serviceWorkers: 'block', reducedMotion: 'reduce' });
|
||||
await guard(backContext);
|
||||
const backPage = await backContext.newPage();
|
||||
await backPage.goto(origin + '/aba-therapy', { waitUntil: 'domcontentloaded' });
|
||||
await backPage.evaluate(() => {
|
||||
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }));
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
|
||||
});
|
||||
assert.equal(await backPage.evaluate((storageKey) => localStorage.getItem(storageKey), key), null);
|
||||
await backPage.locator('[data-service-inquiry-trigger]').click();
|
||||
assert.equal(await backPage.locator('dialog iframe').getAttribute('src'), expectedUrl('en'));
|
||||
evidence.checks++;
|
||||
await backContext.close();
|
||||
}
|
||||
// Actual portal UI with synthetic encrypted response fixtures, never real private source.
|
||||
for (const width of widths) {
|
||||
const ctx = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block' }); await guard(ctx, true);
|
||||
const p = await ctx.newPage(); await p.goto(origin + '/employee-portal');
|
||||
assert.equal(await p.locator('meta[name="robots"]').getAttribute('content'), 'noindex,nofollow');
|
||||
assert.equal(await p.locator('[data-portal-content]').isVisible(), false);
|
||||
const input = p.locator('[data-portal-code]');
|
||||
await input.fill('synthetic-incorrect'); await input.press('Enter');
|
||||
await p.waitForFunction(() => document.querySelector('[data-portal-feedback]')?.textContent === 'Invalid Code');
|
||||
assert.equal(await p.locator('[data-portal-content]').textContent(), '');
|
||||
await input.fill(staffCode); await input.press('Enter');
|
||||
await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
|
||||
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Staff/);
|
||||
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Management/);
|
||||
assert.equal(await p.evaluate(() => window.portalUnsafe), undefined);
|
||||
assert.equal(await p.locator('[data-portal-content] img, [data-portal-content] script').count(), 0);
|
||||
assert.equal(await p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true);
|
||||
assert.equal(await input.inputValue(), '');
|
||||
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true });
|
||||
evidence.screenshots.push('portal-synthetic-' + width + '.png');
|
||||
await p.reload(); assert.equal(await p.locator('[data-portal-content]').isVisible(), false);
|
||||
await input.fill(managementCode); await input.press('Enter');
|
||||
await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
|
||||
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
|
||||
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
|
||||
evidence.checks++; await ctx.close();
|
||||
}
|
||||
for (const js of [false, true]) {
|
||||
const ctx = await browser.newContext({ javaScriptEnabled: js, serviceWorkers: 'block' }); await guard(ctx);
|
||||
const p = await ctx.newPage(); await p.goto(origin + '/employee-portal');
|
||||
assert.equal(await p.locator('[data-portal-code]').isDisabled(), true);
|
||||
assert.equal(await p.locator('[data-portal-content]').isVisible(), false);
|
||||
if (!js) assert.ok(await p.locator('noscript').last().isVisible());
|
||||
evidence.checks++; await ctx.close();
|
||||
}
|
||||
assert.equal(evidence.blocked.filter((r) => !['GET','HEAD'].includes(r.method)).length, 0);
|
||||
evidence.status = 'passed';
|
||||
} catch (error) {
|
||||
evidence.error = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
} finally {
|
||||
await fs.writeFile(path.join(output, 'evidence.json'), JSON.stringify(evidence, null, 2));
|
||||
if (preview && preview.exitCode === null) {
|
||||
preview.kill('SIGTERM');
|
||||
await Promise.race([new Promise((resolve) => preview.once('exit', resolve)), delay(3000)]);
|
||||
if (preview.exitCode === null) preview.kill('SIGKILL');
|
||||
}
|
||||
await browser?.close();
|
||||
console.log(JSON.stringify({ ...evidence, artifactDirectory: output }, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { randomBytes, pbkdf2Sync, createCipheriv, createHmac } from 'node:crypto';
|
||||
import { isSuppressed, reachedHalf, suppressionDuration } from '../src/utils/drawer-preference.ts';
|
||||
import { serviceInquiryCopy } from '../src/data/service-inquiry.ts';
|
||||
import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
|
||||
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
|
||||
|
||||
test('global preference validates expiry, corrupted values and clock anomalies', () => {
|
||||
const now = 10000000000;
|
||||
for (const raw of [null, '', 'NaN', '-1', '1.5', 'Infinity', String(now), String(now - 1), String(now + suppressionDuration + 1)]) assert.equal(isSuppressed(raw, now), false);
|
||||
assert.equal(isSuppressed(String(now + suppressionDuration), now), true);
|
||||
assert.equal(isSuppressed(String(now + 1), now), true);
|
||||
});
|
||||
test('half-scroll handles short, restored, resized and overscrolled pages', () => {
|
||||
assert.equal(reachedHalf(0, 500, 800), false);
|
||||
assert.equal(reachedHalf(100, 800, 800), false);
|
||||
assert.equal(reachedHalf(499, 1800, 800), false);
|
||||
assert.equal(reachedHalf(500, 1800, 800), true);
|
||||
assert.equal(reachedHalf(-10, 1800, 800), false);
|
||||
assert.equal(reachedHalf(500, 2800, 800), false);
|
||||
});
|
||||
test('four service configurations are bilingual without adding an Academy route', async () => {
|
||||
assert.equal(Object.keys(serviceInquiryCopy).length, 4);
|
||||
for (const variants of Object.values(serviceInquiryCopy)) for (const copy of Object.values(variants)) {
|
||||
assert.ok(copy.heading && copy.introduction); assert.ok(!copy.introduction.includes('—'));
|
||||
}
|
||||
const { formAConsultationTestPlacements } = await import('../src/utils/form-integration.ts');
|
||||
assert.equal(formAConsultationTestPlacements.length, 9);
|
||||
assert.ok(!formAConsultationTestPlacements.some((p) => p.route.includes('preparatory')));
|
||||
});
|
||||
test('legacy independent crypto fixture and two-view separation round-trip', async () => {
|
||||
const pass = randomBytes(32).toString('base64'), other = randomBytes(32).toString('base64');
|
||||
const salt = randomBytes(16), iv = randomBytes(16), keys = pbkdf2Sync(pass, salt, 200000, 64, 'sha256');
|
||||
const cipher = createCipheriv('aes-256-cbc', keys.subarray(0, 32), iv);
|
||||
const ct = Buffer.concat([cipher.update('<h2>Synthetic staff</h2>'), cipher.final()]);
|
||||
const mac = createHmac('sha256', keys.subarray(32)).update(Buffer.concat([iv, ct])).digest();
|
||||
const stream = { id: 'synthetic-staff', iter: 200000, salt: salt.toString('base64'), iv: iv.toString('base64'), hmac: mac.toString('base64'), ct: ct.toString('base64') };
|
||||
const second = await encryptPortalStream('synthetic-management', '<h2>Synthetic management</h2>', other);
|
||||
validatePortalPayload({ alg: portalAlgorithm, streams: [stream, second] });
|
||||
assert.equal(await decryptPortalStream(stream, pass), '<h2>Synthetic staff</h2>');
|
||||
assert.equal(await decryptPortalStream(second, pass), null);
|
||||
assert.equal(await decryptPortalStream(stream, other), null);
|
||||
assert.equal(await decryptPortalStream(second, other), '<h2>Synthetic management</h2>');
|
||||
assert.equal(await decryptPortalStream({ ...stream, hmac: randomBytes(32).toString('base64') }, pass), null);
|
||||
assert.throws(() => validatePortalPayload({ alg: portalAlgorithm, streams: [stream, stream] }));
|
||||
});
|
||||
test('resource extraction reconciles duplicate views and rewrites only approved implemented links', () => {
|
||||
const html = '<h2>Synthetic</h2><a href="/hubfs/example.pdf">Example</a><a href="/hubfs/example.pdf">Again</a><a href="https://unapproved.invalid/form">Other</a>';
|
||||
const refs = resourceReferences(html, 'synthetic');
|
||||
assert.equal(refs.length, 2); assert.equal(new Set(refs.map((r) => r.sourceUrl)).size, 1);
|
||||
const record = { references: refs, rewriteApproved: true, destinationImplemented: true, destinationVerified: true, deliveryPath: '/staff-resources/example.pdf', classification: 'STAFF-INTERNAL' };
|
||||
assert.throws(() => rewriteResources(html, [record]));
|
||||
const result = rewriteResources(html, [{ ...record, destinationAccessControlled: true }]);
|
||||
assert.equal(result.count, 2); assert.ok(result.html.includes('>Again</a>')); assert.ok(result.html.includes('https://unapproved.invalid/form'));
|
||||
assert.equal(rewriteResources(html, []).html, html);
|
||||
});
|
||||
test('private collector validates binary, reuses hash, rejects errors and unapproved redirects', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-private-test-'));
|
||||
try {
|
||||
const resource = { sourceUrl: 'https://www.azinstitute4autism.com/hubfs/synthetic.pdf' };
|
||||
const good = await collectResource(resource, dir, undefined, async () => new Response('%PDF-1.7\nSynthetic', { headers: { 'content-type': 'application/pdf' } }));
|
||||
assert.equal(good.downloadStatus, 'downloaded');
|
||||
const again = await collectResource(good, dir, undefined, () => { throw new Error('must not fetch'); });
|
||||
assert.equal(again.downloadStatus, 'verified-existing');
|
||||
await assert.rejects(collectResource(resource, dir, undefined, async () => new Response('<html>Error</html>', { headers: { 'content-type': 'application/pdf' } })));
|
||||
await assert.rejects(collectResource(resource, dir, undefined, async () => new Response('', { status: 302, headers: { location: 'https://unapproved.invalid/hubfs/file' } })));
|
||||
} finally { await fs.rm(dir, { recursive: true }); }
|
||||
});
|
||||
|
||||
test('portal input requires every local gate and rejects missing or malformed ciphertext', async () => {
|
||||
const { readPortalInput } = await import('../src/utils/portal-input.mjs');
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'aia-portal-input-'));
|
||||
try {
|
||||
const pass = randomBytes(24).toString('base64');
|
||||
const streams = await Promise.all(['one','two'].map((id) => encryptPortalStream(id, '<p>Synthetic</p>', pass)));
|
||||
const file = path.join(dir, 'fixture.json'); await fs.writeFile(file, JSON.stringify({ alg: portalAlgorithm, streams }), { mode: 0o600 });
|
||||
const options = { approved: 'true', indexing: 'false', environment: 'isolated-test', file };
|
||||
assert.ok(readPortalInput(options));
|
||||
for (const changed of [{ approved: undefined }, { approved: 'false' }, { indexing: 'true' }, { indexing: undefined }, { environment: 'production' }, { file: '/missing-fixture' }]) assert.equal(readPortalInput({ ...options, ...changed }), undefined);
|
||||
await fs.writeFile(file, '<h1>Not encrypted</h1>'); assert.equal(readPortalInput(options), undefined);
|
||||
} finally { await fs.rm(dir, { recursive: true }); }
|
||||
});
|
||||
Reference in new issue
Block a user