From 596246f20a93568f5c745e8425a482ea0a427816 Mon Sep 17 00:00:00 2001 From: Jeffrey Hales Date: Thu, 5 Nov 2026 17:52:55 -0700 Subject: [PATCH] feat(portal): reuse unlock code for protected downloads --- flake.nix | 2 +- www/README.md | 8 ++- www/integrations/employee-portal/README.md | 10 +++ www/integrations/nixos/deploy.md | 10 +++ www/src/components/pages/EmployeePortal.astro | 2 + www/src/scripts/employee-portal.ts | 54 +++++++++++++- www/src/utils/portal-download.mjs | 70 +++++++++++++++++++ www/tools/service-portal-browser.mjs | 65 ++++++++++++++--- www/tools/service-portal.test.mjs | 60 ++++++++++++++++ 9 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 www/src/utils/portal-download.mjs diff --git a/flake.nix b/flake.nix index 6b46d5e..bdf225a 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; diff --git a/www/README.md b/www/README.md index 50bbe6d..8277ff3 100644 --- a/www/README.md +++ b/www/README.md @@ -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 diff --git a/www/integrations/employee-portal/README.md b/www/integrations/employee-portal/README.md index 3b9af83..b446654 100644 --- a/www/integrations/employee-portal/README.md +++ b/www/integrations/employee-portal/README.md @@ -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 diff --git a/www/integrations/nixos/deploy.md b/www/integrations/nixos/deploy.md index bcec0a7..46a136d 100644 --- a/www/integrations/nixos/deploy.md +++ b/www/integrations/nixos/deploy.md @@ -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. diff --git a/www/src/components/pages/EmployeePortal.astro b/www/src/components/pages/EmployeePortal.astro index d3a28de..a3f23f0 100644 --- a/www/src/components/pages/EmployeePortal.astro +++ b/www/src/components/pages/EmployeePortal.astro @@ -25,6 +25,7 @@ const serialized = payload ? JSON.stringify(payload).replaceAll('<', '\\u003c') + @@ -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; } diff --git a/www/src/scripts/employee-portal.ts b/www/src/scripts/employee-portal.ts index 3d9832a..78ab3d3 100644 --- a/www/src/scripts/employee-portal.ts +++ b/www/src/scripts/employee-portal.ts @@ -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('[data-portal-feedback]')!; const prompt = root.querySelector('[data-portal-prompt]')!; const content = root.querySelector('[data-portal-content]')!; + const downloadFeedback = root.querySelector('[data-portal-download-feedback]')!; + let credential: { role: 'staff' | 'management'; code: string } | null = null; + let downloading = false; let data: ReturnType; 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('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); diff --git a/www/src/utils/portal-download.mjs b/www/src/utils/portal-download.mjs new file mode 100644 index 0000000..28dada5 --- /dev/null +++ b/www/src/utils/portal-download.mjs @@ -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.'; +} diff --git a/www/tools/service-portal-browser.mjs b/www/tools/service-portal-browser.mjs index 34b27d4..e9066ad 100644 --- a/www/tools/service-portal-browser.mjs +++ b/www/tools/service-portal-browser.mjs @@ -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 = '

Synthetic Directory

Operational reports

Synthetic report

  • Synthetic list item
Fixture directory
RoleContact
Fixture roleFixture email

Second fixture report

Operations Monthly Lineup Report - PM

Unsafe destination
'; const staffTemplates = '

Management Templates

Other Staff Resources

General staff resource

Staff email

'; const payload = { alg: portalAlgorithm, streams: [ - await encryptPortalStream('fixture-staff', '

Synthetic Staff

Staff fixture only

Synthetic resource' + styledFixture + staffTemplates, staffCode), - await encryptPortalStream('fixture-management', '

Synthetic Management

Management fixture only

' + styledFixture, managementCode), + await encryptPortalStream('b', '

Synthetic Staff

Staff fixture only

Download staff guide' + styledFixture + staffTemplates, staffCode), + await encryptPortalStream('a', '

Synthetic Management

Management fixture only

Download management guide' + 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(/(]*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'); diff --git a/www/tools/service-portal.test.mjs b/www/tools/service-portal.test.mjs index c1f03d7..2b8f44a 100644 --- a/www/tools/service-portal.test.mjs +++ b/www/tools/service-portal.test.mjs @@ -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');