feat(portal): reuse unlock code for protected downloads

This commit is contained in:
DeCentN2Madness authored and AIAMarketing committed 2026-11-05 19:21:26 -07:00
1 parent 475dd3379b
commit 596246f20a
9 files changed
+268 -13

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@
packages = developmentPackages ++ [ pkgs.playwright-driver ]; packages = developmentPackages ++ [ pkgs.playwright-driver ];
PLAYWRIGHT_MODULE = "${pkgs.playwright-driver}/index.mjs"; PLAYWRIGHT_MODULE = "${pkgs.playwright-driver}/index.mjs";
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.selectBrowsers { PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.selectBrowsers {
withFirefox = false; withFirefox = true;
withWebkit = false; withWebkit = false;
withFfmpeg = false; withFfmpeg = false;
}; };
+7 -1
View File
@@ -619,7 +619,13 @@ dry-run, rollback, the encrypted production portal input and the separate
one-time employee-resource installer. The server-side installer is a root-only one-time employee-resource installer. The server-side installer is a root-only
POSIX shell script using NixOS base tools; it does not require Node.js on POSIX shell script using NixOS base tools; it does not require Node.js on
Contabo. Ordinary releases never copy employee documents into `dist`; Contabo. Ordinary releases never copy employee documents into `dist`;
protected staff and management resources remain under server-controlled Basic Auth namespaces. The accepted interim portal is protected staff and management resources remain under server-controlled Basic
Auth namespaces. After a successful unlock, the page keeps that role's code
only in memory and uses it for same-origin authenticated document fetches. The
response is downloaded as a local Blob, so employees do not receive a second
browser authentication prompt. Cross-role requests are stopped before
credentials are sent; direct resource URLs still require nginx authentication.
Codes are never put in URLs or browser storage. The accepted interim portal is
explicitly non-PHI. explicitly non-PHI.
### Google Ads consultation-form conversion handoff ### Google Ads consultation-form conversion handoff
@@ -5,6 +5,16 @@ staff and management portal content and the interim shared-code presentation.
Browser-side decryption remains access friction rather than individual Browser-side decryption remains access friction rather than individual
authentication, revocation, a secure session, or a HIPAA determination. authentication, revocation, a secure session, or a HIPAA determination.
The same successfully validated code authorizes protected document downloads
for that role during the current page session. The browser keeps it only in a
closure, performs a same-origin authenticated fetch, and saves the returned
document without opening nginx's native Basic Auth dialog. Staff uses the
`staff` username and stream `b`; management uses the `management` username and
stream `a`. Direct URLs remain nginx-protected, cross-role fetches are rejected
before credentials are sent, and failed authenticated downloads show an inline
error without direct-navigation fallback. No code enters a URL, browser
storage, public artifact, or log.
## Approved production ciphertext ## Approved production ciphertext
An indexing-enabled production build may include one owner-approved encrypted An indexing-enabled production build may include one owner-approved encrypted
+10
View File
@@ -92,3 +92,13 @@ resource namespaces, denial cases, redirects, Form A, IPv4 and IPv6. Portal
shared-code rotation is coordinated: update VPS SOPS inputs and privately shared-code rotation is coordinated: update VPS SOPS inputs and privately
re-encrypt the browser payload together. This interim portal is non-PHI and is re-encrypt the browser payload together. This interim portal is non-PHI and is
not an employee identity system. not an employee identity system.
The portal uses one code entry per page session. A successful decrypt retains
the code only in page memory and sends the matching fixed username (`staff` or
`management`) in an explicit same-origin Basic Authorization header when a
protected link is clicked. It downloads the authenticated response as a Blob
and never navigates directly to the protected URL after an error. Verify that
ordinary clicks download without a native credential dialog, inline errors
appear for denied or unavailable files, direct unauthenticated requests remain
401, and neither role can request the other namespace. Reloading, leaving the
page, or an Astro page swap discards the in-memory credential.
@@ -25,6 +25,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
<noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript> <noscript><p>JavaScript is required to unlock the employee portal locally. Please contact AIA for access assistance.</p></noscript>
</div> </div>
<div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div> <div class="employee-portal__content" tabindex="-1" role="region" aria-label="Unlocked employee portal" hidden data-portal-content></div>
<p class="employee-portal__download-feedback" role="status" aria-live="polite" hidden data-portal-download-feedback></p>
<script is:inline type="application/json" data-portal-payload set:html={serialized}></script> <script is:inline type="application/json" data-portal-payload set:html={serialized}></script>
</section> </section>
@@ -35,6 +36,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c')
input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; } input { background: #fcf9f5; border: 1px solid #edeef3; border-radius: 4px; font: inherit; padding: .65rem; max-width: 100%; min-width: 0; }
input:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; } input:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
.employee-portal__content:focus { outline: none; } .employee-portal__content:focus { outline: none; }
.employee-portal__download-feedback { margin-block: 1rem; font-weight: 600; }
[hidden] { display: none; } [hidden] { display: none; }
.employee-portal__content :global(a) { color: var(--color-primary); } .employee-portal__content :global(a) { color: var(--color-primary); }
.employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; } .employee-portal__content :global(a:focus-visible) { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
+52 -2
View File
@@ -1,4 +1,5 @@
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs'; import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
import { fetchProtectedPortalResource, portalDownloadErrorMessage, portalRoleForStreamId, protectedPortalResource } from '../utils/portal-download.mjs';
import { portalLinkPresentation } from '../utils/portal-presentation'; import { portalLinkPresentation } from '../utils/portal-presentation';
/** Build a passive DOM, never inject executable decrypted HTML. Remote media, /** Build a passive DOM, never inject executable decrypted HTML. Remote media,
@@ -24,6 +25,8 @@ export function safePortalContent(html: string): DocumentFragment {
if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank'); if (url.origin !== location.origin && url.protocol === 'https:') element.setAttribute('target', '_blank');
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? ''); const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? '');
if (presentation) element.className = presentation; if (presentation) element.className = presentation;
const resource = protectedPortalResource(url.href, location.origin);
if (resource) element.dataset.portalResourceRole = resource.role;
} }
} catch { /* Invalid destination remains visible text without navigation. */ } } catch { /* Invalid destination remains visible text without navigation. */ }
} }
@@ -99,6 +102,9 @@ function initialize() {
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!; const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!; const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
const content = root.querySelector<HTMLElement>('[data-portal-content]')!; const content = root.querySelector<HTMLElement>('[data-portal-content]')!;
const downloadFeedback = root.querySelector<HTMLElement>('[data-portal-download-feedback]')!;
let credential: { role: 'staff' | 'management'; code: string } | null = null;
let downloading = false;
let data: ReturnType<typeof validatePortalPayload>; let data: ReturnType<typeof validatePortalPayload>;
try { try {
if (!crypto.subtle) throw new Error(); if (!crypto.subtle) throw new Error();
@@ -106,6 +112,45 @@ function initialize() {
input.disabled = false; button.disabled = false; input.disabled = false; button.disabled = false;
feedback.textContent = ''; feedback.textContent = '';
} catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; } } catch { feedback.textContent = 'Portal access is unavailable in this preview. Please contact AIA.'; return; }
const clearSession = () => {
credential = null;
input.value = '';
content.replaceChildren();
content.hidden = true;
prompt.hidden = false;
downloadFeedback.hidden = true;
downloadFeedback.textContent = '';
};
const saveDownload = (blob: Blob, filename: string) => {
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl; link.download = filename; link.hidden = true;
document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
};
content.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[data-portal-resource-role]') : null;
if (!target) return;
event.preventDefault();
if (downloading) return;
const resource = protectedPortalResource(target.href, location.origin);
if (!resource || !credential) {
downloadFeedback.textContent = portalDownloadErrorMessage(undefined);
downloadFeedback.hidden = false;
return;
}
downloading = true;
target.setAttribute('aria-disabled', 'true');
downloadFeedback.textContent = 'Downloading document…';
downloadFeedback.hidden = false;
void fetchProtectedPortalResource(resource, credential)
.then(({ blob, filename }) => {
saveDownload(blob, filename);
downloadFeedback.textContent = `Downloaded ${filename}.`;
})
.catch((error) => { downloadFeedback.textContent = portalDownloadErrorMessage(error); })
.finally(() => { downloading = false; target.removeAttribute('aria-disabled'); });
});
const unlock = async () => { const unlock = async () => {
if (button.disabled) return; if (button.disabled) return;
button.disabled = true; button.disabled = true;
@@ -116,8 +161,12 @@ function initialize() {
for (const stream of data.streams) { for (const stream of data.streams) {
const plain = await decryptPortalStream(stream, pass); const plain = await decryptPortalStream(stream, pass);
if (plain !== null) { if (plain !== null) {
const role = portalRoleForStreamId(stream.id);
if (!role) throw new Error('Unknown portal stream');
credential = { role, code: pass };
content.replaceChildren(safePortalContent(plain)); content.replaceChildren(safePortalContent(plain));
prompt.hidden = true; content.hidden = false; prompt.hidden = true; content.hidden = false;
downloadFeedback.hidden = true; downloadFeedback.textContent = '';
input.value = ''; content.focus(); return; input.value = ''; content.focus(); return;
} }
} }
@@ -127,8 +176,9 @@ function initialize() {
}; };
button.addEventListener('click', unlock); button.addEventListener('click', unlock);
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } }); input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } });
// BFCache must not restore an already unlocked view. // Credentials exist only in this page closure and are discarded on exit/swap.
addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; }); addEventListener('pagehide', clearSession, { once: true });
document.addEventListener('astro:before-swap', clearSession, { once: true });
} }
initialize(); initialize();
document.addEventListener('astro:page-load', initialize); document.addEventListener('astro:page-load', initialize);
+70
View File
@@ -0,0 +1,70 @@
const roles = new Set(['staff', 'management']);
const resourcePath = /^\/(staff|management)-resources\/([A-Za-z0-9][A-Za-z0-9._-]{0,240})$/;
export function portalRoleForStreamId(id) {
if (id === 'b') return 'staff';
if (id === 'a') return 'management';
return undefined;
}
export function protectedPortalResource(href, origin) {
let url;
try { url = new URL(href, origin); } catch { return undefined; }
if (url.origin !== origin || url.username || url.password || url.search || url.hash) return undefined;
const match = resourcePath.exec(url.pathname);
if (!match) return undefined;
return { role: match[1], filename: match[2], url: url.href };
}
function basicAuthorization(role, code) {
if (!roles.has(role) || typeof code !== 'string' || !code || code.length > 1024 || /[\r\n\0]/.test(code)) {
throw new PortalDownloadError('session');
}
const bytes = new TextEncoder().encode(`${role}:${code}`);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return `Basic ${btoa(binary)}`;
}
function safeFilename(value, fallback) {
if (typeof value !== 'string') return fallback;
const name = value.trim();
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,240}$/.test(name) ? name : fallback;
}
function responseFilename(response, fallback) {
const disposition = response.headers.get('content-disposition') ?? '';
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
if (encoded) {
try { return safeFilename(decodeURIComponent(encoded), fallback); } catch { /* Use URL filename. */ }
}
const quoted = disposition.match(/filename="([^"]+)"/i)?.[1];
return safeFilename(quoted, fallback);
}
export class PortalDownloadError extends Error {
constructor(code) { super(code); this.name = 'PortalDownloadError'; this.code = code; }
}
export async function fetchProtectedPortalResource(resource, credential, fetcher = fetch) {
if (!resource || !credential || resource.role !== credential.role) throw new PortalDownloadError('role');
const response = await fetcher(resource.url, {
method: 'GET',
headers: { Authorization: basicAuthorization(credential.role, credential.code) },
credentials: 'omit',
cache: 'no-store',
redirect: 'error',
referrerPolicy: 'no-referrer',
});
if (response.status === 401 || response.status === 403) throw new PortalDownloadError('unauthorized');
if (!response.ok) throw new PortalDownloadError('unavailable');
return { blob: await response.blob(), filename: responseFilename(response, resource.filename) };
}
export function portalDownloadErrorMessage(error) {
if (error instanceof PortalDownloadError && error.code === 'role') return 'This document is not available from this portal.';
if (error instanceof PortalDownloadError && (error.code === 'unauthorized' || error.code === 'session')) {
return 'Your portal session could not authorize this download. Reload the page and enter the access code again.';
}
return 'The document could not be downloaded. Please try again or contact AIA.';
}
+56 -9
View File
@@ -16,6 +16,8 @@ import { setTimeout as delay } from 'node:timers/promises';
const forms = process.argv.find(a => a.startsWith('--forms='))?.split('=')[1] ?? 'live'; const forms = process.argv.find(a => a.startsWith('--forms='))?.split('=')[1] ?? 'live';
assert.ok(['live', 'off', 'mock'].includes(forms)); assert.ok(['live', 'off', 'mock'].includes(forms));
const browserName = process.argv.find(a => a.startsWith('--browser='))?.split('=')[1] ?? 'chromium';
assert.ok(['chromium', 'firefox'].includes(browserName));
const root = fileURLToPath(new URL('../', import.meta.url)); const root = fileURLToPath(new URL('../', import.meta.url));
const moduleName = process.env.PLAYWRIGHT_MODULE const moduleName = process.env.PLAYWRIGHT_MODULE
? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href
@@ -33,7 +35,7 @@ const routes = [
{ route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' }, { route: '/es/learner-social-club', locale: 'es', drawer: true, screenshot: 'es-learner-social-club' },
]; ];
const widths = [320, 390, 768, 1440]; const widths = [320, 390, 768, 1440];
const evidence = { forms, routes, widths, checks: 0, blocked: [], jotformAttempts: [], screenshots: [], fallbackChecks: [], status: 'failed' }; const evidence = { forms, browserName, routes, widths, checks: 0, blocked: [], jotformAttempts: [], resourceRequests: [], screenshots: [], fallbackChecks: [], status: 'failed' };
const reserve = net.createServer(); const reserve = net.createServer();
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); }); await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
const port = reserve.address().port; const port = reserve.address().port;
@@ -47,9 +49,11 @@ function expectedUrl(locale) {
} }
try { try {
const { chromium } = await import(moduleName); const drivers = await import(moduleName);
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE const driver = drivers[browserName];
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE } const executablePath = browserName === 'chromium' ? process.env.CHROMIUM_EXECUTABLE : process.env.FIREFOX_EXECUTABLE;
browser = await driver.launch(executablePath
? { headless: true, executablePath }
: { headless: true }); : { headless: true });
evidence.browser = browser.version(); evidence.browser = browser.version();
preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], { preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], {
@@ -73,14 +77,31 @@ try {
const styledFixture = '<section><h2>Synthetic Directory</h2><h3>Operational reports</h3><p><a class="hs-button arbitrary-legacy-class" style="position:fixed" onclick="window.portalUnsafe=true" href="https://example.invalid/report">Synthetic report</a></p><ul><li>Synthetic list item</li></ul><table><caption>Fixture directory</caption><thead><tr><th scope="col">Role</th><th scope="col">Contact</th></tr></thead><tbody><tr><td>Fixture role</td><td><a href="mailto:fixture@example.invalid">Fixture email</a></td></tr></tbody></table><p><a style="background-color:#ffbd59" href="https://example.invalid/second">Second fixture report</a></p><p><a class="unrecognized-report-legacy" href="https://example.invalid/third">Operations Monthly Lineup Report - PM</a></p><a href="javascript:alert(1)">Unsafe destination</a><iframe src="https://example.invalid/frame"></iframe><form action="https://example.invalid/post"><input></form></section>'; const styledFixture = '<section><h2>Synthetic Directory</h2><h3>Operational reports</h3><p><a class="hs-button arbitrary-legacy-class" style="position:fixed" onclick="window.portalUnsafe=true" href="https://example.invalid/report">Synthetic report</a></p><ul><li>Synthetic list item</li></ul><table><caption>Fixture directory</caption><thead><tr><th scope="col">Role</th><th scope="col">Contact</th></tr></thead><tbody><tr><td>Fixture role</td><td><a href="mailto:fixture@example.invalid">Fixture email</a></td></tr></tbody></table><p><a style="background-color:#ffbd59" href="https://example.invalid/second">Second fixture report</a></p><p><a class="unrecognized-report-legacy" href="https://example.invalid/third">Operations Monthly Lineup Report - PM</a></p><a href="javascript:alert(1)">Unsafe destination</a><iframe src="https://example.invalid/frame"></iframe><form action="https://example.invalid/post"><input></form></section>';
const staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In &amp; Check-Out</a></div></li><li><div><a href="https://example.invalid/employee-performance">Employee Performance Template</a></div></li><li><div><a href="https://example.invalid/employee-availability">Employee Availability Template</a></div></li></ul></section><section><h2>Other Staff Resources</h2><p><a href="https://example.invalid/resource">General staff resource</a></p><p><a href="mailto:staff@example.invalid">Staff email</a></p></section>'; const staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In &amp; Check-Out</a></div></li><li><div><a href="https://example.invalid/employee-performance">Employee Performance Template</a></div></li><li><div><a href="https://example.invalid/employee-availability">Employee Availability Template</a></div></li></ul></section><section><h2>Other Staff Resources</h2><p><a href="https://example.invalid/resource">General staff resource</a></p><p><a href="mailto:staff@example.invalid">Staff email</a></p></section>';
const payload = { alg: portalAlgorithm, streams: [ 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">' + styledFixture + staffTemplates, staffCode), await encryptPortalStream('b', '<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="/staff-resources/staff-guide.pdf">Download staff guide</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">' + styledFixture + staffTemplates, staffCode),
await encryptPortalStream('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>' + styledFixture, managementCode), await encryptPortalStream('a', '<h2>Synthetic Management</h2><p>Management fixture only</p><a class="hs-button" href="/management-resources/management-guide.pdf">Download management guide</a>' + styledFixture, managementCode),
] }; ] };
const expectedAuthorization = {
staff: 'Basic ' + Buffer.from('staff:' + staffCode, 'utf8').toString('base64'),
management: 'Basic ' + Buffer.from('management:' + managementCode, 'utf8').toString('base64'),
};
const guard = async (context, inject = false) => { const guard = async (context, inject = false) => {
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); }); await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
await context.route('**/*', async (route) => { await context.route('**/*', async (route) => {
const req = route.request(), url = new URL(req.url()); const req = route.request(), url = new URL(req.url());
if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) { if (url.origin === origin && ['GET', 'HEAD'].includes(req.method())) {
const resource = /^\/(staff|management)-resources\/(staff|management)-guide\.pdf$/.exec(url.pathname);
if (resource) {
const role = resource[1];
const authorized = req.headers().authorization === expectedAuthorization[role];
evidence.resourceRequests.push({ role, authorized });
if (!authorized) return route.fulfill({ status: 401, headers: { 'www-authenticate': `Basic realm="AIA ${role} resources"`, 'cache-control': 'private, no-store' } });
return route.fulfill({
status: 200,
contentType: 'application/pdf',
headers: { 'content-disposition': `attachment; filename="${role}-guide.pdf"`, 'cache-control': 'private, no-store' },
body: `%PDF-1.7\nSynthetic ${role} document\n`,
});
}
if (inject && url.pathname === '/employee-portal') { if (inject && url.pathname === '/employee-portal') {
const response = await route.fetch(); const response = await route.fetch();
const html = (await response.text()).replace(/(<script[^>]*data-portal-payload[^>]*>)[\s\S]*?(<\/script>)/, '$1' + JSON.stringify(payload) + '$2'); const html = (await response.text()).replace(/(<script[^>]*data-portal-payload[^>]*>)[\s\S]*?(<\/script>)/, '$1' + JSON.stringify(payload) + '$2');
@@ -327,14 +348,14 @@ try {
await input.fill(staffCode); await input.press('Enter'); await input.fill(staffCode); await input.press('Enter');
await p.locator('[data-portal-content]').waitFor({ state: 'visible' }); await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Staff/); assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Staff/);
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Management/); assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Synthetic Management|Management fixture only/);
assert.equal(await p.evaluate(() => window.portalUnsafe), undefined); 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.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 p.evaluate(() => document.activeElement?.hasAttribute('data-portal-content')), true);
assert.equal(await input.inputValue(), ''); assert.equal(await input.inputValue(), '');
const checkPresentation = async (staff = false) => { const checkPresentation = async (staff = false) => {
const content = p.locator('[data-portal-content]'); const content = p.locator('[data-portal-content]');
assert.equal(await content.locator('.portal-resource-button').count(), staff ? 8 : 3); assert.equal(await content.locator('.portal-resource-button').count(), staff ? 8 : 4);
assert.equal(await content.locator('[style], [onclick], .arbitrary-legacy-class, .unrecognized-report-legacy, iframe, form, input').count(), 0); assert.equal(await content.locator('[style], [onclick], .arbitrary-legacy-class, .unrecognized-report-legacy, iframe, form, input').count(), 0);
assert.equal(await content.locator('a[href^="javascript:"]').count(), 0); assert.equal(await content.locator('a[href^="javascript:"]').count(), 0);
assert.equal(await content.locator('th[scope="col"]').count(), 2); assert.equal(await content.locator('th[scope="col"]').count(), 2);
@@ -346,7 +367,7 @@ try {
assert.equal(await content.locator('li').first().evaluate(e => getComputedStyle(e, '::marker').color), 'rgb(255, 189, 89)'); assert.equal(await content.locator('li').first().evaluate(e => getComputedStyle(e, '::marker').color), 'rgb(255, 189, 89)');
assert.equal(await content.locator('h2').first().evaluate(e => getComputedStyle(e).color), 'rgb(37, 64, 128)'); assert.equal(await content.locator('h2').first().evaluate(e => getComputedStyle(e).color), 'rgb(37, 64, 128)');
assert.ok(await content.locator('h2').first().evaluate(e => parseFloat(getComputedStyle(e).fontSize) >= 28)); assert.ok(await content.locator('h2').first().evaluate(e => parseFloat(getComputedStyle(e).fontSize) >= 28));
assert.equal(await content.locator('.portal-contact-link').evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)'); assert.equal(await content.locator('.portal-contact-link').first().evaluate(e => getComputedStyle(e).color), 'rgb(169, 70, 0)');
await button.focus(); await button.focus();
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid'); assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid');
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px'); assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px');
@@ -361,6 +382,20 @@ try {
assert.equal(columns, width >= 768 ? 2 : 1, 'staff templates follow responsive column count'); assert.equal(columns, width >= 768 ? 2 : 1, 'staff templates follow responsive column count');
assert.equal(await staffContent.getByRole('link', { name: 'General staff resource' }).getAttribute('class'), 'portal-resource-button'); assert.equal(await staffContent.getByRole('link', { name: 'General staff resource' }).getAttribute('class'), 'portal-resource-button');
assert.equal(await staffContent.getByRole('link', { name: 'Staff email' }).getAttribute('class'), 'portal-contact-link'); assert.equal(await staffContent.getByRole('link', { name: 'Staff email' }).getAttribute('class'), 'portal-contact-link');
assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + '/staff-resources/staff-guide.pdf'), 401);
const staffLink = staffContent.getByRole('link', { name: 'Download staff guide' });
const staffDownload = p.waitForEvent('download');
await staffLink.click();
assert.equal((await staffDownload).suggestedFilename(), 'staff-guide.pdf');
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded staff-guide.pdf.');
assert.equal(p.url(), origin + '/employee-portal');
const beforeCrossRole = evidence.resourceRequests.length;
await staffLink.evaluate((link) => { link.href = '/management-resources/management-guide.pdf'; });
await staffLink.click();
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal'));
assert.equal(evidence.resourceRequests.length, beforeCrossRole, 'staff credential is not sent to management resources');
assert.deepEqual(await p.evaluate(() => ({ local: { ...localStorage }, session: { ...sessionStorage } })), { local: {}, session: {} });
assert.equal(p.url().includes(staffCode), false);
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true }); await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true });
evidence.screenshots.push('portal-synthetic-' + width + '.png'); evidence.screenshots.push('portal-synthetic-' + width + '.png');
@@ -370,6 +405,18 @@ try {
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/); assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/); assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
await checkPresentation(); await checkPresentation();
assert.equal(await p.evaluate(async (url) => (await fetch(url)).status, origin + '/management-resources/management-guide.pdf'), 401);
const managementLink = p.getByRole('link', { name: 'Download management guide' });
const managementDownload = p.waitForEvent('download');
await managementLink.click();
assert.equal((await managementDownload).suggestedFilename(), 'management-guide.pdf');
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent === 'Downloaded management-guide.pdf.');
const beforeManagementCrossRole = evidence.resourceRequests.length;
await managementLink.evaluate((link) => { link.href = '/staff-resources/staff-guide.pdf'; });
await managementLink.click();
await p.waitForFunction(() => document.querySelector('[data-portal-download-feedback]')?.textContent?.includes('not available from this portal'));
assert.equal(evidence.resourceRequests.length, beforeManagementCrossRole, 'management credential is not sent to staff resources');
assert.equal(p.url().includes(managementCode), false);
assert.equal(await p.locator('[data-portal-content] ul.portal-template-grid').count(), 0, 'management view retains original layout'); assert.equal(await p.locator('[data-portal-content] ul.portal-template-grid').count(), 0, 'management view retains original layout');
await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true }); await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true });
evidence.screenshots.push('portal-management-synthetic-' + width + '.png'); evidence.screenshots.push('portal-management-synthetic-' + width + '.png');
+60
View File
@@ -10,6 +10,13 @@ import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portal
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs'; import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
import { isFormACompletion } from '../src/utils/jotform-completion.ts'; import { isFormACompletion } from '../src/utils/jotform-completion.ts';
import { portalLinkPresentation } from '../src/utils/portal-presentation.ts'; import { portalLinkPresentation } from '../src/utils/portal-presentation.ts';
import {
PortalDownloadError,
fetchProtectedPortalResource,
portalDownloadErrorMessage,
portalRoleForStreamId,
protectedPortalResource,
} from '../src/utils/portal-download.mjs';
import { applyRoleMappings, approvedMapping, summarizeMappings } from './portal-production.mjs'; import { applyRoleMappings, approvedMapping, summarizeMappings } from './portal-production.mjs';
test('completion envelope requires an exact approved origin, active frame window and string Form A ID', () => { test('completion envelope requires an exact approved origin, active frame window and string Form A ID', () => {
@@ -48,6 +55,59 @@ test('portal presentation translates only explicit button tokens/brand fills and
assert.equal(portalLinkPresentation([], '', 'tel:'), 'portal-contact-link'); assert.equal(portalLinkPresentation([], '', 'tel:'), 'portal-contact-link');
}); });
test('portal downloads use only the validated role and an in-memory authorization header', async () => {
assert.equal(portalRoleForStreamId('b'), 'staff');
assert.equal(portalRoleForStreamId('a'), 'management');
assert.equal(portalRoleForStreamId('fixture-staff'), undefined);
const origin = 'https://www.azinstitute4autism.com';
const staff = protectedPortalResource('/staff-resources/staff-guide.pdf', origin);
const management = protectedPortalResource('/management-resources/management-guide.pdf', origin);
assert.deepEqual(staff, {
role: 'staff', filename: 'staff-guide.pdf',
url: origin + '/staff-resources/staff-guide.pdf',
});
assert.equal(protectedPortalResource('/staff-resources/nested/file.pdf', origin), undefined);
assert.equal(protectedPortalResource('/staff-resources/file.pdf?code=forbidden', origin), undefined);
assert.equal(protectedPortalResource('https://other.invalid/staff-resources/file.pdf', origin), undefined);
const code = 'synthetic browser memory only';
let captured;
const result = await fetchProtectedPortalResource(staff, { role: 'staff', code }, async (url, options) => {
captured = { url, options };
return new Response('synthetic document', {
headers: { 'content-disposition': 'attachment; filename="approved-name.pdf"' },
});
});
assert.equal(captured.url, staff.url);
assert.equal(captured.url.includes(code), false);
assert.equal(captured.options.method, 'GET');
assert.equal(captured.options.credentials, 'omit');
assert.equal(captured.options.cache, 'no-store');
assert.equal(captured.options.redirect, 'error');
assert.equal(captured.options.referrerPolicy, 'no-referrer');
assert.match(captured.options.headers.Authorization, /^Basic /);
assert.equal(result.filename, 'approved-name.pdf');
assert.equal(await result.blob.text(), 'synthetic document');
for (const [resource, credential] of [
[management, { role: 'staff', code }],
[staff, { role: 'management', code }],
]) {
let called = false;
await assert.rejects(
fetchProtectedPortalResource(resource, credential, async () => { called = true; return new Response(); }),
(error) => error instanceof PortalDownloadError && error.code === 'role',
);
assert.equal(called, false, 'cross-role credentials are never transmitted');
}
await assert.rejects(
fetchProtectedPortalResource(staff, { role: 'staff', code }, async () => new Response('', { status: 401 })),
(error) => error instanceof PortalDownloadError && error.code === 'unauthorized',
);
assert.match(portalDownloadErrorMessage(new PortalDownloadError('unauthorized')), /session could not authorize/);
assert.match(portalDownloadErrorMessage(new PortalDownloadError('role')), /not available from this portal/);
});
test('staff template presentation follows sanitized headings, not original HubSpot classes', async () => { test('staff template presentation follows sanitized headings, not original HubSpot classes', async () => {
const renderer = await fs.readFile('src/scripts/employee-portal.ts', 'utf8'); const renderer = await fs.readFile('src/scripts/employee-portal.ts', 'utf8');
const css = await fs.readFile('src/components/pages/EmployeePortal.astro', 'utf8'); const css = await fs.readFile('src/components/pages/EmployeePortal.astro', 'utf8');