feat(portal): reuse unlock code for protected downloads
This commit is contained in:
1 parent
475dd3379b
commit
596246f20a
9 files changed
+268
-13
No files matched your search
@@ -39,7 +39,7 @@
|
||||
packages = developmentPackages ++ [ pkgs.playwright-driver ];
|
||||
PLAYWRIGHT_MODULE = "${pkgs.playwright-driver}/index.mjs";
|
||||
PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.selectBrowsers {
|
||||
withFirefox = false;
|
||||
withFirefox = true;
|
||||
withWebkit = false;
|
||||
withFfmpeg = false;
|
||||
};
|
||||
|
||||
+7
-1
@@ -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
|
||||
POSIX shell script using NixOS base tools; it does not require Node.js on
|
||||
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.
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
An indexing-enabled production build may include one owner-approved encrypted
|
||||
|
||||
@@ -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
|
||||
re-encrypt the browser payload together. This interim portal is non-PHI and is
|
||||
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>
|
||||
</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>
|
||||
</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:focus-visible { outline: 3px solid var(--color-focus, #115bbd); outline-offset: 3px; }
|
||||
.employee-portal__content:focus { outline: none; }
|
||||
.employee-portal__download-feedback { margin-block: 1rem; font-weight: 600; }
|
||||
[hidden] { display: none; }
|
||||
.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; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { decryptPortalStream, validatePortalPayload } from '../utils/portal-crypto.mjs';
|
||||
import { fetchProtectedPortalResource, portalDownloadErrorMessage, portalRoleForStreamId, protectedPortalResource } from '../utils/portal-download.mjs';
|
||||
import { portalLinkPresentation } from '../utils/portal-presentation';
|
||||
|
||||
/** 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');
|
||||
const presentation = portalLinkPresentation([...source.classList], source.style.backgroundColor, url.protocol, source.textContent ?? '');
|
||||
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. */ }
|
||||
}
|
||||
@@ -99,6 +102,9 @@ function initialize() {
|
||||
const feedback = root.querySelector<HTMLElement>('[data-portal-feedback]')!;
|
||||
const prompt = root.querySelector<HTMLElement>('[data-portal-prompt]')!;
|
||||
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>;
|
||||
try {
|
||||
if (!crypto.subtle) throw new Error();
|
||||
@@ -106,6 +112,45 @@ function initialize() {
|
||||
input.disabled = false; button.disabled = false;
|
||||
feedback.textContent = '';
|
||||
} 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 () => {
|
||||
if (button.disabled) return;
|
||||
button.disabled = true;
|
||||
@@ -116,8 +161,12 @@ function initialize() {
|
||||
for (const stream of data.streams) {
|
||||
const plain = await decryptPortalStream(stream, pass);
|
||||
if (plain !== null) {
|
||||
const role = portalRoleForStreamId(stream.id);
|
||||
if (!role) throw new Error('Unknown portal stream');
|
||||
credential = { role, code: pass };
|
||||
content.replaceChildren(safePortalContent(plain));
|
||||
prompt.hidden = true; content.hidden = false;
|
||||
downloadFeedback.hidden = true; downloadFeedback.textContent = '';
|
||||
input.value = ''; content.focus(); return;
|
||||
}
|
||||
}
|
||||
@@ -127,8 +176,9 @@ function initialize() {
|
||||
};
|
||||
button.addEventListener('click', unlock);
|
||||
input.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); void unlock(); } });
|
||||
// BFCache must not restore an already unlocked view.
|
||||
addEventListener('pagehide', () => { input.value = ''; content.replaceChildren(); content.hidden = true; prompt.hidden = false; });
|
||||
// Credentials exist only in this page closure and are discarded on exit/swap.
|
||||
addEventListener('pagehide', clearSession, { once: true });
|
||||
document.addEventListener('astro:before-swap', clearSession, { once: true });
|
||||
}
|
||||
initialize();
|
||||
document.addEventListener('astro:page-load', initialize);
|
||||
@@ -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.';
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
const forms = process.argv.find(a => a.startsWith('--forms='))?.split('=')[1] ?? 'live';
|
||||
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 moduleName = process.env.PLAYWRIGHT_MODULE
|
||||
? 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' },
|
||||
];
|
||||
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();
|
||||
await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); });
|
||||
const port = reserve.address().port;
|
||||
@@ -47,9 +49,11 @@ function expectedUrl(locale) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { chromium } = await import(moduleName);
|
||||
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE
|
||||
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE }
|
||||
const drivers = await import(moduleName);
|
||||
const driver = drivers[browserName];
|
||||
const executablePath = browserName === 'chromium' ? process.env.CHROMIUM_EXECUTABLE : process.env.FIREFOX_EXECUTABLE;
|
||||
browser = await driver.launch(executablePath
|
||||
? { headless: true, executablePath }
|
||||
: { headless: true });
|
||||
evidence.browser = browser.version();
|
||||
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 staffTemplates = '<section><h2>Management Templates</h2><ul><li><div><a href="https://example.invalid/check-in">Client Check-In & 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: [
|
||||
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('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>' + styledFixture, managementCode),
|
||||
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('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) => {
|
||||
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())) {
|
||||
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') {
|
||||
const response = await route.fetch();
|
||||
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 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.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.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(), '');
|
||||
const checkPresentation = async (staff = false) => {
|
||||
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('a[href^="javascript:"]').count(), 0);
|
||||
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('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.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();
|
||||
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid');
|
||||
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(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 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));
|
||||
await p.screenshot({ path: path.join(output, 'portal-synthetic-' + width + '.png'), fullPage: true });
|
||||
evidence.screenshots.push('portal-synthetic-' + width + '.png');
|
||||
@@ -370,6 +405,18 @@ try {
|
||||
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
|
||||
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
|
||||
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');
|
||||
await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true });
|
||||
evidence.screenshots.push('portal-management-synthetic-' + width + '.png');
|
||||
|
||||
@@ -10,6 +10,13 @@ import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portal
|
||||
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
|
||||
import { isFormACompletion } from '../src/utils/jotform-completion.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';
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const renderer = await fs.readFile('src/scripts/employee-portal.ts', 'utf8');
|
||||
const css = await fs.readFile('src/components/pages/EmployeePortal.astro', 'utf8');
|
||||
|
||||
Reference in new issue
Block a user