fix(forms): close completed drawers and restore portal styling
This commit is contained in:
15 files changed
+598
-9
No files matched your search
@@ -49,6 +49,7 @@ try {
|
||||
browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE
|
||||
? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE }
|
||||
: { 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)], {
|
||||
cwd: root,
|
||||
env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' },
|
||||
@@ -67,9 +68,10 @@ try {
|
||||
const duration = 30 * 86400000;
|
||||
const staffCode = randomBytes(24).toString('hex');
|
||||
const managementCode = randomBytes(24).toString('hex');
|
||||
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><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 payload = { alg: portalAlgorithm, streams: [
|
||||
await encryptPortalStream('fixture-staff', '<h2>Synthetic Staff</h2><p>Staff fixture only</p><a href="https://example.invalid/document">Synthetic resource</a><script>window.portalUnsafe=true</script><img src="https://example.invalid/image">', staffCode),
|
||||
await encryptPortalStream('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>', managementCode),
|
||||
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, staffCode),
|
||||
await encryptPortalStream('fixture-management', '<h2>Synthetic Management</h2><p>Management fixture only</p>' + styledFixture, managementCode),
|
||||
] };
|
||||
const guard = async (context, inject = false) => {
|
||||
await context.routeWebSocket('**/*', (socket) => { evidence.blocked.push({ method: 'WEBSOCKET' }); socket.close(); });
|
||||
@@ -91,7 +93,7 @@ try {
|
||||
return route.abort();
|
||||
});
|
||||
};
|
||||
if (!process.argv.includes('--portal-only')) {
|
||||
if (!process.argv.includes('--portal-only') && !process.argv.includes('--correction-only')) {
|
||||
for (const width of widths) {
|
||||
for (const target of routes.filter((r) => r.drawer)) {
|
||||
const context = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block', reducedMotion: 'reduce' });
|
||||
@@ -194,6 +196,92 @@ try {
|
||||
evidence.checks++;
|
||||
await backContext.close();
|
||||
}
|
||||
if (!process.argv.includes('--portal-only')) {
|
||||
// Completion messages originate from a locally fulfilled cross-origin frame.
|
||||
// No fixture submits anything; malformed browser envelopes are injected only in tests.
|
||||
for (const locale of ['en', 'es']) {
|
||||
const ctx = await browser.newContext({ serviceWorkers: 'block', viewport: { width: locale === 'es' ? 390 : 1440, height: 900 } });
|
||||
await guard(ctx);
|
||||
await ctx.addInitScript((key) => localStorage.setItem(key, String(Date.now() + 86400000)), key);
|
||||
const p = await ctx.newPage();
|
||||
await p.clock.install({ time: new Date('2026-10-30T12:00:00Z') });
|
||||
await p.clock.pauseAt(new Date('2026-10-30T12:00:01Z'));
|
||||
await p.goto(origin + (locale === 'es' ? '/es/aba-therapy' : '/aba-therapy'));
|
||||
const d = p.locator('[data-service-inquiry-drawer]');
|
||||
const ack = d.locator('[data-service-inquiry-completed]');
|
||||
const trigger = p.locator('[data-service-inquiry-trigger]');
|
||||
const send = async (changes = {}) => p.evaluate((changes) => {
|
||||
const frame = document.querySelector('dialog iframe');
|
||||
const source = changes.wrongSource ? window : frame.contentWindow;
|
||||
dispatchEvent(new MessageEvent('message', { origin: changes.origin ?? 'https://form.jotform.com', source,
|
||||
data: { action: 'submission-completed', formID: '262946078314059', ...changes.data } }));
|
||||
}, changes);
|
||||
await send(); // Closed and unactivated.
|
||||
assert.equal(await d.getAttribute('data-submission-completed'), null);
|
||||
await trigger.click(); await p.clock.runFor(600);
|
||||
// CSS transition rendering uses browser time, independently of mocked JS timers.
|
||||
await delay(550);
|
||||
await p.waitForFunction(() => [...document.querySelectorAll('iframe')].some(f => f.src.includes('consultation')));
|
||||
for (const wrong of [{ origin: 'https://form.jotform.com.evil.invalid' }, { wrongSource: true },
|
||||
{ data: { formID: 'other' } }, { data: { action: 'setHeight' } }]) await send(wrong);
|
||||
await p.clock.runFor(6000);
|
||||
assert.equal(await d.evaluate(d => d.open), true);
|
||||
assert.equal(await ack.isVisible(), false);
|
||||
evidence.checks++;
|
||||
const child = p.frames().find(f => f.url() === expectedUrl(locale));
|
||||
assert.ok(child, 'authorized fixture frame loaded');
|
||||
await child.evaluate(() => parent.postMessage({ action: 'submission-completed', formID: '262946078314059' }, '*'));
|
||||
await p.waitForFunction(() => document.querySelector('dialog')?.dataset.submissionCompleted === 'true');
|
||||
assert.equal(await ack.isVisible(), true);
|
||||
assert.match(await ack.textContent(), locale === 'es' ? /¡Gracias!/ : /Thank you!/);
|
||||
const expiry = await p.evaluate(key => Number(localStorage.getItem(key)), key);
|
||||
const now = await p.evaluate(() => Date.now());
|
||||
assert.equal(expiry, now + duration);
|
||||
await p.clock.runFor(3000); await send(); // Duplicate must not restart countdown.
|
||||
await p.clock.runFor(1999);
|
||||
assert.equal(await d.evaluate(d => d.open && d.classList.contains('is-open')), true);
|
||||
const acknowledgmentBox = await ack.boundingBox();
|
||||
assert.ok(acknowledgmentBox && acknowledgmentBox.x >= 0 && acknowledgmentBox.y >= 0 &&
|
||||
acknowledgmentBox.y + acknowledgmentBox.height <= 900, 'acknowledgment is inside the visible viewport');
|
||||
await p.screenshot({ path: path.join(output, `completion-${locale}.png`) });
|
||||
evidence.screenshots.push(`completion-${locale}.png`);
|
||||
await p.clock.runFor(1);
|
||||
assert.equal(await d.evaluate(d => d.open && !d.classList.contains('is-open')), true, 'slide-out starts after full 5 seconds');
|
||||
await p.clock.runFor(500);
|
||||
assert.equal(await d.evaluate(d => d.open), false);
|
||||
assert.equal(await trigger.evaluate(e => e === document.activeElement), true);
|
||||
assert.equal(await p.evaluate(key => Number(localStorage.getItem(key)), key), expiry);
|
||||
evidence.checks++;
|
||||
await p.evaluate(() => window.oldFixtureWindow = document.querySelector('dialog iframe').contentWindow);
|
||||
const count = evidence.jotformAttempts.length;
|
||||
await trigger.click(); await p.clock.runFor(600);
|
||||
assert.equal(await ack.isVisible(), false);
|
||||
assert.equal(await d.locator('iframe').count(), 1);
|
||||
assert.equal(await p.evaluate(() => window.oldFixtureWindow === document.querySelector('dialog iframe').contentWindow), false);
|
||||
await p.waitForFunction(() => document.querySelector('[data-jotform-form-a]')?.dataset.jotformDocumentLoaded === 'true');
|
||||
assert.equal(evidence.jotformAttempts.length, count + 1);
|
||||
await p.evaluate(() => dispatchEvent(new MessageEvent('message', { origin: 'https://form.jotform.com', source: window.oldFixtureWindow,
|
||||
data: { action: 'submission-completed', formID: '262946078314059' } })));
|
||||
assert.equal(await ack.isVisible(), false);
|
||||
await send(); await p.clock.runFor(1000);
|
||||
await p.keyboard.press('Escape'); await p.clock.runFor(500);
|
||||
assert.equal(await d.evaluate(d => d.open), false);
|
||||
await trigger.click(); await p.clock.runFor(5000);
|
||||
assert.equal(await d.evaluate(d => d.open), true, 'manual close canceled prior countdown');
|
||||
assert.equal(await ack.isVisible(), false);
|
||||
evidence.checks++;
|
||||
await send();
|
||||
await p.evaluate(() => document.dispatchEvent(new Event('astro:before-swap')));
|
||||
await p.clock.runFor(6000);
|
||||
assert.equal(await d.evaluate(d => d.open), false);
|
||||
await p.evaluate(() => document.dispatchEvent(new Event('astro:page-load')));
|
||||
await trigger.click(); await p.clock.runFor(6000);
|
||||
assert.equal(await ack.isVisible(), false);
|
||||
assert.equal(await d.evaluate(d => d.open), true);
|
||||
evidence.checks++;
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
// Actual portal UI with synthetic encrypted response fixtures, never real private source.
|
||||
for (const width of widths) {
|
||||
const ctx = await browser.newContext({ viewport: { width, height: 844 }, serviceWorkers: 'block' }); await guard(ctx, true);
|
||||
@@ -212,6 +300,22 @@ try {
|
||||
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 () => {
|
||||
const content = p.locator('[data-portal-content]');
|
||||
assert.equal(await content.locator('.portal-resource-button').count(), 2);
|
||||
assert.equal(await content.locator('[style], [onclick], .arbitrary-legacy-class, 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);
|
||||
const button = content.locator('.portal-resource-button').first();
|
||||
assert.equal(await button.evaluate(e => getComputedStyle(e).backgroundColor), '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)');
|
||||
await button.focus();
|
||||
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineStyle), 'solid');
|
||||
assert.equal(await button.evaluate(e => getComputedStyle(e).outlineWidth), '3px');
|
||||
};
|
||||
await checkPresentation();
|
||||
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');
|
||||
@@ -220,6 +324,9 @@ try {
|
||||
await p.locator('[data-portal-content]').waitFor({ state: 'visible' });
|
||||
assert.match(await p.locator('[data-portal-content]').textContent(), /Synthetic Management/);
|
||||
assert.doesNotMatch(await p.locator('[data-portal-content]').textContent(), /Staff fixture/);
|
||||
await checkPresentation();
|
||||
await p.screenshot({ path: path.join(output, 'portal-management-synthetic-' + width + '.png'), fullPage: true });
|
||||
evidence.screenshots.push('portal-management-synthetic-' + width + '.png');
|
||||
evidence.checks++; await ctx.close();
|
||||
}
|
||||
for (const js of [false, true]) {
|
||||
|
||||
@@ -8,6 +8,36 @@ import { isSuppressed, reachedHalf, suppressionDuration } from '../src/utils/dra
|
||||
import { serviceInquiryCopy } from '../src/data/service-inquiry.ts';
|
||||
import { decryptPortalStream, encryptPortalStream, validatePortalPayload, portalAlgorithm } from '../src/utils/portal-crypto.mjs';
|
||||
import { collectResource, resourceReferences, rewriteResources } from './portal-resources.mjs';
|
||||
import { isFormACompletion } from '../src/utils/jotform-completion.ts';
|
||||
import { portalLinkPresentation } from '../src/utils/portal-presentation.ts';
|
||||
|
||||
test('completion envelope requires exact origin, active frame window and string Form A ID', () => {
|
||||
const source = {};
|
||||
const check = (event, frame, active) => isFormACompletion(event, frame, active,
|
||||
'https://form.jotform.com/262946078314059?formContext=consultation&language=en');
|
||||
const data = { action: 'submission-completed', formID: '262946078314059' };
|
||||
const event = { origin: 'https://form.jotform.com', source, data };
|
||||
assert.equal(check(event, source, true), true);
|
||||
assert.equal(check({ ...event, data: JSON.stringify(data) }, source, true), true);
|
||||
for (const change of [
|
||||
{ origin: 'https://form.jotform.com.evil.invalid' }, { origin: 'null' }, { origin: 'http://form.jotform.com' },
|
||||
{ source: {} }, { source: null }, { data: { ...data, formID: 'other' } },
|
||||
{ data: { ...data, formID: 262946078314059 } }, { data: { ...data, action: 'load' } },
|
||||
{ data: '{broken' }, { data: [] }, { data: null }, { data: 'x'.repeat(4097) },
|
||||
]) assert.equal(check({ ...event, ...change }, source, true), false);
|
||||
assert.equal(check(event, source, false), false);
|
||||
assert.equal(check(event, null, true), false);
|
||||
assert.equal(isFormACompletion(event, source, true, undefined), false);
|
||||
assert.equal(isFormACompletion(event, source, true, 'broken'), false);
|
||||
});
|
||||
|
||||
test('portal presentation translates only explicit button tokens/brand fills and contact protocols', () => {
|
||||
assert.equal(portalLinkPresentation(['hs-button', 'untrusted'], '', 'https:'), 'portal-resource-button');
|
||||
assert.equal(portalLinkPresentation([], 'rgb(255, 189, 89)', 'https:'), 'portal-resource-button');
|
||||
assert.equal(portalLinkPresentation(['arbitrary'], 'url(https://example.invalid)', 'https:'), undefined);
|
||||
assert.equal(portalLinkPresentation([], '', 'mailto:'), 'portal-contact-link');
|
||||
assert.equal(portalLinkPresentation([], '', 'tel:'), 'portal-contact-link');
|
||||
});
|
||||
|
||||
test('global preference validates expiry, corrupted values and clock anomalies', () => {
|
||||
const now = 10000000000;
|
||||
|
||||
Reference in new issue
Block a user