diff --git a/www/.env.example b/www/.env.example index 2942d30..d208c8a 100644 --- a/www/.env.example +++ b/www/.env.example @@ -5,6 +5,10 @@ PUBLIC_COUNTERS_MOCK_RESULT=success PUBLIC_AIA_FORMS_MODE=off PUBLIC_AIA_FORMS_LIVE_APPROVED=false PUBLIC_AIA_FORMS_MOCK_RESULT=success +# The real Form A adapter is test-only. All three gates below are required; +# indexing-enabled builds always fail closed even when they are set. +PUBLIC_AIA_FORMS_ENVIRONMENT=local +PUBLIC_AIA_FORMS_ISOLATED_TEST_APPROVED=false # Explicit, local-only Form A meeting preview; requires PUBLIC_AIA_FORMS_MODE=mock. PUBLIC_AIA_FORM_A_PREVIEW=false PUBLIC_ALLOW_INDEXING=false diff --git a/www/integrations/jotform/README.md b/www/integrations/jotform/README.md index d4c0920..08bf71c 100644 --- a/www/integrations/jotform/README.md +++ b/www/integrations/jotform/README.md @@ -6,6 +6,8 @@ The CSS translates the existing AIA form treatment: Rubik typography, blue label The selectors target Jotform Classic Forms identifiers such as `#form`, `.form-all`, `.form-line`, `.form-label`, `.form-sub-label`, `.form-required`, `.form-textbox`, `.form-dropdown`, `.form-textarea`, `.form-error-message`, and `.form-submit-button`. Jotform's final generated DOM must be checked in the account before relying on pixel-level parity. The stylesheet does not hide required indicators, error messages, honeypots, or focus outlines, and it does not request fonts or other external assets. +The current Builder theme reconciliation adds narrowly scoped `!important` declarations to the outer surface, labels, inputs, and submit-button treatments that Jotform's generated theme otherwise overrides. Error, required-indicator, and focus rules remain visible and are deliberately not reset. The account owner must still confirm the final generated DOM and rendered form after any Jotform theme or field change. + ## Installation and comparison 1. Create or open the approved blank Form A in AIA's HIPAA-enabled Jotform account. @@ -17,6 +19,29 @@ The selectors target Jotform Classic Forms identifiers such as `#form`, `.form-a The current Astro meeting preview remains `off` by default and can only be shown in a non-indexing mock build. It does not load Jotform and is not a live submission test. +## Isolated Form A iframe adapter + +The Astro adapter exists only for a separately approved, non-indexing isolated test. It can render Form A at the two contact routes and five explicitly allowlisted landing routes when **all** of the following are set at build time: + +```sh +PUBLIC_AIA_FORMS_MODE=isolated-test +PUBLIC_AIA_FORMS_ENVIRONMENT=isolated-test +PUBLIC_AIA_FORMS_ISOLATED_TEST_APPROVED=true +PUBLIC_ALLOW_INDEXING=false +``` + +It uses fixed allowlisted hosted URLs only: the contact routes use `formContext=contact` with their fixed English or Spanish language value, and the five landing routes use `formContext=landing&language=en`. `formContext` is the exact case-sensitive Jotform Unique Name supplied by the owner; it is presentation-only and does not accept visitor query values. A missing gate, any other environment role, an indexing-enabled build, a route outside that seven-route allowlist, or `live` mode renders the normal fail-closed Astro fallback instead. + +The adapter has a localized iframe title, a fixed-height responsive frame, a `no-referrer` policy, a minimal sandbox, and a direct hosted-form fallback. It loads no Jotform helper script, reads no iframe DOM, and treats no frame load as a submission or conversion. The disabled Form A must undergo owner-authorized synthetic EN/ES testing before the isolated adapter or its fixed height can be accepted for a live workflow. + +### Landing-route extension — 2026-10-25 / 2026-10-26 + +The owner has extended this same **isolated-test-only** adapter to `/lp/aba-therapy`, `/lp/aba-insurance`, `/lp/aba-ahcccs`, `/lp/aba-private-pay`, and `/lp/early-intervention`. Those five English landing routes use the fixed URL parameter pair `formContext=landing&language=en`; the contact routes continue to use `formContext=contact` with their fixed page locale. This supersedes their earlier Form B mapping. Form B remains for the other service-consultation journeys. + +The campaign adapter uses the existing campaign form surface rather than creating a second card. Its native Jotform Submit label is not overridden by Astro. The current sandbox is deliberately limited to `allow-forms allow-scripts allow-same-origin`, which is the minimum anticipated set for Jotform validation and in-frame confirmation. It intentionally does not grant popups, modals, top navigation, downloads, or a feature-policy `allow` list. Owner-authorized synthetic acceptance must confirm the real form's validation, confirmation, and any required in-frame navigation before this sandbox can be accepted for production. + +The versioned P9-03 contract and implementation plan must record this owner decision and reconcile the superseded campaign mapping without erasing the prior decision history. Keep any documentation PR review aligned with those versioned records. + ## Form context and button labels The current Form A design uses a hidden `formContext` value with fixed, approved values such as `contact` and `landing`. Jotform does not provide a native conditional rule that changes one Submit button's accessible label safely in every context. If owner-approved context-specific labels are required, use multiple native Jotform Submit buttons and Jotform's conditional visibility rules: @@ -28,7 +53,7 @@ The current Form A design uses a hidden `formContext` value with fixed, approved - Make the no-context and invalid-context states fail closed: no ambiguous extra button and no unintended submission path. - Verify keyboard order, focus visibility, screen-reader names, English/Spanish labels, and mobile layout for every variant. -The four campaign pages remain mapped to Form B in the production architecture. Their current Form A mock override is temporary meeting-preview behavior only. Do not add an external Astro button that attempts to submit a cross-origin iframe. +The owner-approved Form A landing extension applies only to `/lp/aba-therapy`, `/lp/aba-insurance`, `/lp/aba-ahcccs`, `/lp/aba-private-pay`, and `/lp/early-intervention`, using its fixed `landing` context under the isolated-test gate. Form B remains the planned form for the other service-consultation journeys. No Form A landing placement is approved for live production activation. Do not add an external Astro button that attempts to submit a cross-origin iframe. ## Verification checklist diff --git a/www/integrations/jotform/aia-form-a.css b/www/integrations/jotform/aia-form-a.css index a2d9ca1..bc4549b 100644 --- a/www/integrations/jotform/aia-form-a.css +++ b/www/integrations/jotform/aia-form-a.css @@ -1,192 +1,252 @@ -/* - * AIA Form A — Jotform Classic Forms custom CSS - * - * Paste into Jotform Form Designer > Styles > Inject Custom CSS after - * creating the approved Form A. These selectors are scoped to Jotform's - * Classic Forms structure and do not replace Jotform validation or controls. - * Validate the final generated form DOM before activation; no account access - * or pixel-perfect Jotform comparison was performed for this artifact. - */ +/* ========================================================== + AIA FORM A — JOTFORM CUSTOM CSS + Form ID: 262946078314059 + Maximum form width: 640px + ========================================================== */ -/* The Astro page supplies the surrounding heading, copy, privacy link and - card. Keep the iframe's own surface quiet so it does not create a second - card inside the page's existing form container. */ -#form, -#form .form-all { - background: #fff; - border: 0; - box-shadow: none; +/* Document and language selector */ + +html.supernova, +html.supernova body, +body.supernova { + background: #fff !important; + background-image: none !important; +} + +/* Avoid horizontal overflow, including on the thank-you page */ +body { + margin: 0 !important; +} + +/* Confirmation page styling */ +body.thankyou, +body.thankyou #stage { + background: #fff !important; + background-image: none !important; +} + +.jfLangDd.jfLangDd--match-form { + padding: 12px 0 !important; +} + +/* Form container */ + +form[id="262946078314059"] { + width: 100% !important; + max-width: 640px !important; + margin: 8px auto !important; + box-sizing: border-box; +} + +.form-all { + width: 100% !important; + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; + background: #fff !important; + border: 0 !important; + box-shadow: none !important; color: #254080; font-family: Rubik, Arial, sans-serif; +} + +.form-all .form-section { + margin: 0 !important; + padding: 0 !important; +} + +/* Field layout */ + +.form-all .form-line { + margin: 0 !important; + padding: 0 0 21px !important; + background: transparent !important; + border: 0 !important; +} + +.form-all .form-input-wide { + margin-top: 0 !important; + padding-top: 0 !important; +} + +/* Labels and sublabels */ + +.form-all .form-label, +.form-all .form-label-left, +.form-all .form-label-right, +.form-all .form-label-top { + color: #254080 !important; + font-size: .875rem; + font-weight: 700; + line-height: 1.35 !important; + margin: 0 0 6px !important; + padding: 0 !important; +} + +.form-all .form-sub-label { + color: #254080; + font-size: .75rem; + line-height: 1.3 !important; + margin: 4px 0 0 !important; + padding: 0 !important; +} + +.form-all .form-sub-label-container { + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +.form-all .form-required { + color: #a21c28 !important; + margin-left: .2rem; + visibility: visible; +} + +/* Inputs */ + +.form-all input.form-textbox, +.form-all select.form-dropdown, +.form-all textarea.form-textarea { + background: #fcf9f5 !important; + background-image: none !important; + border: 1px solid #edeef3 !important; + border-radius: 4px !important; + box-shadow: none !important; + box-sizing: border-box; + color: #254080 !important; + font-family: inherit; + font-size: 1rem; + line-height: 1.4; + min-height: 2.75rem; + padding: .55rem .7rem; max-width: 100%; } -#form .form-all { - margin: 0; - padding: 0; +/* Preserve two-column name fields */ + +.form-all .form-sub-label-container input.form-textbox { + width: 100% !important; + max-width: 100% !important; } -#form .form-line { - background: transparent; - border: 0; - margin: 0; - padding: 0 0 1rem; +/* Full-width phone and email fields */ + +.form-all + .form-line:is( + [data-type="control_phone"], + [data-type="control_email"] + ) + :is(.form-input, .form-input-wide, input.form-textbox) { + width: 100% !important; + max-width: 100% !important; } -#form .form-label, -#form .form-label-left, -#form .form-label-right, -#form .form-label-top { - color: #254080; - font-size: .9rem; - font-weight: 700; - line-height: 1.5; - margin: 0 0 .4rem; - padding: 0; -} +/* Message field */ -#form .form-sub-label { - color: #254080; - font-size: .78rem; - line-height: 1.5; -} - -#form .form-required { - color: #a21c28; - margin-left: .2rem; -} - -#form input.form-textbox, -#form input.form-textbox.form-phone, -#form input.form-textbox.form-email, -#form select.form-dropdown, -#form textarea.form-textarea { - background: #fcf9f5; - border: 1px solid #edeef3; - border-radius: 4px; - box-sizing: border-box; - color: #254080; - font: inherit; - line-height: 1.5; - min-height: 2.9rem; - padding: .7rem .8rem; - width: 100%; -} - -#form textarea.form-textarea { - min-height: 7rem; +.form-all textarea.form-textarea { + width: 100% !important; + min-height: 5.5rem !important; resize: vertical; } -#form input.form-textbox:focus-visible, -#form select.form-dropdown:focus-visible, -#form textarea.form-textarea:focus-visible, -#form input[type='radio']:focus-visible + label, -#form input[type='checkbox']:focus-visible + label, -#form .form-submit-button:focus-visible { - outline: 3px solid #115bbd; - outline-offset: 3px; -} +/* Radio buttons and checkboxes */ -#form .form-line.form-line-error input.form-textbox, -#form .form-line.form-line-error select.form-dropdown, -#form .form-line.form-line-error textarea.form-textarea { - border-color: #a21c28; - outline: 2px solid #a21c28; - outline-offset: 1px; -} - -#form .form-error-message, -#form .form-line-error .form-error-message { - color: #a21c28; - font-size: .85rem; - font-weight: 700; - line-height: 1.5; - margin: .45rem 0 0; -} - -#form .form-radio-item, -#form .form-checkbox-item { +.form-all .form-radio-item, +.form-all .form-checkbox-item { color: #254080; - line-height: 1.5; - margin: 0 1rem .5rem 0; + line-height: 1.4; + margin: 0 .75rem .2rem 0 !important; + padding: 0; } -#form .form-radio, -#form .form-checkbox { +.form-all .form-radio, +.form-all .form-checkbox { accent-color: #254080; } -#form .form-buttons-wrapper { - background: transparent; - border: 0; - margin: .25rem 0 0; - padding: 0; - text-align: left; +/* Submit button and wrapper */ + +.form-all .jf-form-buttons, +.form-all .form-buttons-wrapper { + box-sizing: border-box; + width: 100% !important; + max-width: 100% !important; + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + border: 0 !important; } -#form .form-submit-button, -#form button[type='submit'] { - background: #ffbd59; - border: 2px solid #ffbd59; - border-radius: 4px; - box-shadow: none; - color: #254080; - cursor: pointer; - font: inherit; - font-weight: 400; - line-height: 1.5; +.form-all .form-submit-button, +.form-all button[type="submit"] { + width: 100%; + box-sizing: border-box; min-height: 3rem; padding: .65rem 1.2rem; - transition: .3s linear all; - width: 100%; + background: #ffbd59 !important; + background-image: none !important; + border: 2px solid #ffbd59 !important; + border-radius: 4px !important; + box-shadow: none !important; + color: #254080 !important; + cursor: pointer; + font-family: inherit; + font-size: 1rem; + font-weight: 400; + line-height: 1.4; + text-shadow: none !important; + transition: background .3s linear, border-color .3s linear; } -#form .form-submit-button:hover, -#form .form-submit-button:focus-visible, -#form button[type='submit']:hover, -#form button[type='submit']:focus-visible { - background: transparent; - border-color: #ff914d; - color: #254080; +.form-all .form-submit-button:hover, +.form-all button[type="submit"]:hover { + background: transparent !important; + border-color: #ff914d !important; + color: #254080 !important; } -#form .form-submit-button:disabled, -#form button[type='submit']:disabled { +.form-all .form-submit-button:disabled, +.form-all button[type="submit"]:disabled { cursor: not-allowed; opacity: .65; } -/* Jotform error and required content must remain visible. Do not hide these - classes in a campaign-specific override. */ -#form .form-validation-error, -#form .form-required, -#form .form-error-message { +/* Keyboard focus */ + +.form-all input.form-textbox:focus-visible, +.form-all select.form-dropdown:focus-visible, +.form-all textarea.form-textarea:focus-visible, +.form-all input[type="radio"]:focus-visible, +.form-all input[type="checkbox"]:focus-visible, +.form-all .form-submit-button:focus-visible { + outline: 3px solid #115bbd !important; + outline-offset: -3px; +} + +/* Validation */ + +.form-all .form-line-error input.form-textbox, +.form-all .form-line-error select.form-dropdown, +.form-all .form-line-error textarea.form-textarea, +.form-all input.form-validation-error { + border-color: #a21c28 !important; +} + +.form-all .form-error-message, +.form-all .form-line-error .form-error-message { + color: #a21c28 !important; + font-size: .8rem; + font-weight: 700; + line-height: 1.4; + margin: .3rem 0 0 !important; visibility: visible; } -@media (max-width: 640px) { - #form .form-line, - #form .form-label, - #form .form-label-left, - #form .form-label-right, - #form .form-label-top { - width: 100%; - } - - #form .form-all { - width: 100%; - } - - #form .form-submit-button, - #form button[type='submit'] { - min-height: 3rem; - } -} +/* Accessibility: reduced motion */ @media (prefers-reduced-motion: reduce) { - #form .form-submit-button, - #form button[type='submit'] { + .form-all .form-submit-button, + .form-all button[type="submit"] { transition: none; } -} +} \ No newline at end of file diff --git a/www/reports/phase-9-form-a-campaign-extension-2026-10-25.md b/www/reports/phase-9-form-a-campaign-extension-2026-10-25.md new file mode 100644 index 0000000..e238786 --- /dev/null +++ b/www/reports/phase-9-form-a-campaign-extension-2026-10-25.md @@ -0,0 +1,69 @@ +# Phase 9 — Form A isolated iframe campaign extension + +**Date:** 2026-10-25 UTC +**Repository state:** `faithful-astro-migration` at `f41af1966e5fc46ff3fe37019c30826304997b67` before this unstaged extension +**Scope:** initially extend the existing Form A isolated-test adapter to four approved English campaign routes; updated on 2026-10-26 to include `/lp/early-intervention`. This is not a live Jotform activation. + +## Owner decision and mapping + +The owner supplied this revised decision: use Form A (`262946078314059`) for the five landing pages below, with fixed `formContext=landing`. It supersedes the earlier Form B mapping only for these five visitor journeys. Form B remains the planned destination for other service-consultation journeys that require its additional approved fields. + +| Route | Locale | Form A context | Fixed hosted URL | +|---|---|---|---| +| `/contact` | English | `contact` | `https://form.jotform.com/262946078314059?formContext=contact&language=en` | +| `/es/contact` | Spanish | `contact` | `https://form.jotform.com/262946078314059?formContext=contact&language=es` | +| `/lp/aba-therapy` | English | `landing` | `https://form.jotform.com/262946078314059?formContext=landing&language=en` | +| `/lp/aba-insurance` | English | `landing` | `https://form.jotform.com/262946078314059?formContext=landing&language=en` | +| `/lp/aba-ahcccs` | English | `landing` | `https://form.jotform.com/262946078314059?formContext=landing&language=en` | +| `/lp/aba-private-pay` | English | `landing` | `https://form.jotform.com/262946078314059?formContext=landing&language=en` | +| `/lp/early-intervention` | English | `landing` | `https://form.jotform.com/262946078314059?formContext=landing&language=en` | + +The 2026-10-26 owner decision supersedes the earlier exception for `/lp/early-intervention`. Its existing AIA-branded card keeps its off-mode controls and shows the embedded Form A adapter only when the same isolated-test gate permits it. There are no Spanish campaign routes in this implementation, so no Spanish landing parameter has been invented. + +## Implementation + +`src/utils/form-integration.ts` now owns one typed route/locale/context allowlist for all seven Form A placements. `resolveFormAIframe` still requires every existing isolated-test gate: + +- `PUBLIC_AIA_FORMS_MODE=isolated-test`; +- `PUBLIC_AIA_FORMS_ENVIRONMENT=isolated-test`; +- `PUBLIC_AIA_FORMS_ISOLATED_TEST_APPROVED=true`; and +- a non-indexing build. + +Missing, malformed, `mock`, `off`, or `live` configuration continues to render the pre-existing non-submitting Astro surface. An indexing-enabled build also fails closed. An API URL, Jotform ID, route query, referrer, user value, or `PUBLIC_AIA_FORMS_LIVE_APPROVED` value cannot enable this adapter. + +`components/campaign/ConsultationForm.astro` reuses `JotformIframe.astro` only when the resolver permits its four campaign routes. `components/campaign/EarlyInterventionPage.astro` uses the same resolver and embedded adapter inside its existing `.lead-form` card. Each page retains its existing section, heading, CTA context, telephone details, and form wrapper. The adapter's embedded variant removes its own card styling so it does not nest a second form card or duplicate availability notices. The Form A mock remains available only in explicit non-indexing mock builds. No external Astro-controlled submit button is used; Jotform's current native Submit label is left unchanged. + +The adapter styles are route-local inline component CSS. This prevents a CSS asset shared by the main contact page and campaign layout from appearing in both route families, preserving the existing campaign-style isolation assertion. + +The iframe still supplies its localized accessible title and direct hosted-form fallback. It uses `referrerpolicy="no-referrer"`, no Jotform helper script, no `allow` feature policy, and the constrained sandbox `allow-forms allow-scripts allow-same-origin`. This is sufficient in principle for Jotform's in-frame controls and confirmation but is not proof that its current disabled runtime works. It intentionally withholds popups, modals, top navigation, downloads, and cross-origin parent control. + +## Documentation continuity + +The versioned October P9-03 contract and implementation plan are not edited by this extension because documentation PR #2 is open. This report and the additive README section record the supersession without overwriting that in-review history. The accepted documentation update must change the five named landing routes from Form B to Form A while retaining Form B for all other service-consultation routes. + +## Local verification + +| Check | Result | +|---|---| +| `npm run check` | 0 errors, 0 warnings, 0 hints | +| Focused Form A, preview, and nonproduction tests | 14 passed | +| Markdown, publication, SEO, Library, and route suites | 20, 33, 35, 53, and 10 passed respectively; publication and Library fixtures were rerun sequentially with `TMPDIR=/tmp` after an unsafe concurrent fixture collision | +| Campaign and artifact tests | 18 passed | +| Normal staging build | 128 HTML routes; every page `noindex,nofollow`; empty sitemap; zero hreflang; no Form A identifier or iframe | +| Explicit mock build | 128 routes; no Form A identifier, iframe, external form request, or native form action | +| Fully gated isolated-test build | 128 routes; adapter appears only at the seven allowlisted routes | +| Indexing-enabled build with every isolated-test value supplied | 127 routes; 109 sitemap URLs; 180 hreflang links; `/lp` absent; no Form A identifier or iframe | + +Playwright 1.61.0 with Chromium exercised all seven placements on a loopback-only preview at 320px, 390px, 768px, and 1440px. The latest isolated-test mode completed 35 checks: all seven routes at every width and all seven with JavaScript disabled at 390px. The browser asserted a visible, responsive iframe, one direct fallback, keyboard focusability, no horizontal page overflow, and the campaign adapter as the single embedded surface inside the existing `.form-wrap` or the Early Intervention `.lead-form`, rather than the inactive or mock form. + +The latest isolated run created 28 full-page screenshots and an evidence JSON outside the repository at `/tmp/aia-form-a-iframe-AuhT7S`. It attempted 35 fixed Jotform iframe GETs: ten `contact` and 25 `landing` URLs across the JavaScript and no-JavaScript runs. The request guard recorded and aborted every attempt before transmission. There were no attempted POST, DELETE, WebSocket, production API, analytics, advertising, conversion, form-submission, or Contabo requests. Existing Leaflet resource GETs on contact routes were likewise blocked before transmission. + +## Required acceptance before live activation + +1. Owner-authorized synthetic testing must confirm the disabled form's actual EN/ES language behavior, `formContext` conditions, validation, confirmation, and native Submit controls using approved synthetic values only. +2. Privacy/security review must confirm Jotform configuration, PHI protections, notification recipients/templates, redirects, integrations, retention, and no HTTP POST forwarding to Astro or Contabo. +3. Accessibility review must confirm real iframe keyboard behavior, focus, screen-reader experience, frame height, mobile reflow, direct fallback, and blocked/slow resource handling at 320px, 390px, 768px, and 1440px. +4. Hosting/CSP/referrer review must approve the Jotform origin and sandbox for the intended deployment environment. +5. Operations, clinical, language, privacy, and release owners must approve the final Form A field contract and workflow. Analytics, advertising, webhooks, conversion events, and PHI forwarding remain prohibited. + +No Jotform account access, form mutation, real submission, external network transmission, analytics, advertising, Contabo form endpoint, deployment, staging, commit, merge, or push occurred in this extension. diff --git a/www/reports/phase-9-form-a-iframe-adapter-2026-10-25.md b/www/reports/phase-9-form-a-iframe-adapter-2026-10-25.md new file mode 100644 index 0000000..d0a32b0 --- /dev/null +++ b/www/reports/phase-9-form-a-iframe-adapter-2026-10-25.md @@ -0,0 +1,105 @@ +# Phase 9 — Form A Jotform iframe adapter + +**Date:** 2026-10-25 UTC +**Repository state:** `faithful-astro-migration` at `f41af1966e5fc46ff3fe37019c30826304997b67` before this unstaged implementation +**Scope:** prepare only the owner-authorized, isolated-test Form A iframe adapter for `/contact` and `/es/contact` + +## Scope and evidence boundary + +The owner supplied the Form A identifier `262946078314059`, hosted origin `https://form.jotform.com`, the exact case-sensitive hidden-field Unique Name `formContext`, fixed contact value `contact`, and English/Spanish form-language availability. Those facts establish candidate embed URLs; they do not establish that the disabled form is ready for live use or that Jotform's `language` parameter has passed runtime acceptance. + +No Jotform account was opened or modified. No form was enabled, submitted, embedded in a normal staging artifact, or contacted over the network. No patient, parent, staff, or synthetic form answers were entered, stored, logged, emailed, or exported. + +The existing Form A mock remains intact. The four campaign landing pages retain their permanent logical Form B mapping. Their temporary Form A mock behavior is limited to explicit mock builds and is not affected by this adapter. + +## Implemented fail-closed contract + +`src/utils/form-integration.ts` recognizes a fourth configuration value, `isolated-test`, while preserving the existing `off`, `mock`, and fail-closed `live` behavior. + +| Requirement | Value required to render the adapter | +|---|---| +| Integration mode | `PUBLIC_AIA_FORMS_MODE=isolated-test` | +| Environment role | `PUBLIC_AIA_FORMS_ENVIRONMENT=isolated-test` | +| Separate authorization | `PUBLIC_AIA_FORMS_ISOLATED_TEST_APPROVED=true` | +| Publication mode | `PUBLIC_ALLOW_INDEXING` must not be `true` | +| Route and locale | `/contact` + `en`, or `/es/contact` + `es` | + +Any missing or invalid value renders the existing disabled Astro contact surface. `live`, including `PUBLIC_AIA_FORMS_LIVE_APPROVED=true`, is not a valid adapter path and remains inactive. The normal runtime resolver still only returns `off` or `mock` to every other form surface. + +The allowlisted URLs are constructed only from fixed values: + +| Route | URL | +|---|---| +| `/contact` | `https://form.jotform.com/262946078314059?formContext=contact&language=en` | +| `/es/contact` | `https://form.jotform.com/262946078314059?formContext=contact&language=es` | + +No visitor query parameter, referrer value, cookie, form value, or attribution data participates in URL construction. The `formContext` prefill selects presentation only; it is not treated as authorization, attribution, or a security control. + +## Adapter and presentation + +`src/components/JotformIframe.astro` is a reusable, server-rendered Form A frame component. It preserves the Astro-owned form-card heading and surrounding contact-page layout, supplies a localized iframe title, and provides a localized direct hosted-form fallback. It uses no Jotform helper script, cross-origin DOM access, `postMessage`, load-event success inference, tracking, storage, or parent-native form. + +The iframe has `referrerpolicy="no-referrer"`, no permissive `allow` attribute, and the minimal current sandbox `allow-forms allow-scripts allow-same-origin`. The form is disabled and no actual Jotform runtime was loaded, so the owner must verify this sandbox against Jotform's real validation, confirmation, accessibility, and language behavior before accepting it for any live workflow. The responsive frame is 54rem at desktop and 60rem below 640px; this prevents parent horizontal scrolling in local tests but its final height remains an acceptance item with the real form. + +The Jotform Builder CSS at `integrations/jotform/aia-form-a.css` now includes narrowly scoped `!important` overrides for the Builder theme's outer surface, labels, inputs, and submit-button design tokens. Required indicators, errors, and focus styles remain visible. This reconciles the owner-provided Builder styling result; it is not an independent account or generated-DOM audit. `integrations/jotform/README.md` documents the style reconciliation and isolated-test contract without changing the separate P9-03 contract or the open documentation PR. + +## Changed files + +- `.env.example` — documents the two additional default-false/local isolated-test gates. +- `integrations/jotform/aia-form-a.css` — applies Builder theme overrides while retaining error and focus visibility. +- `integrations/jotform/README.md` — records the CSS and isolated-test adapter instructions. +- `src/utils/form-integration.ts` — typed isolated-test resolver, fixed Form A URL builder, localized adapter copy, and route/locale gates. +- `src/components/JotformIframe.astro` — accessible, responsive iframe and hosted fallback. +- `src/components/pages/ContactPage.astro` — selects adapter only after the full resolver accepts `/contact` or `/es/contact`. +- `tools/jotform-form-a.test.mjs` — pure configuration, URL, route, source-safety, and CSS tests. +- `tools/jotform-form-a-browser.mjs` — loopback-only, request-intercepted Chromium adapter check. +- `tools/phase10-artifact-verify.mjs` and `tools/phase10-artifact.test.mjs` — reject the isolated-test Form A identifier in shipped staging or indexing artifacts, including text assets. +- `tools/library.test.mjs` — preserves the signed slashless nginx-template preamble in the existing redirect-template assertion. + +## Verification + +### Static and unit checks + +| Command | Result | +|---|---:| +| `node --experimental-strip-types --test tools/jotform-form-a.test.mjs tools/form-a-preview.test.mjs tools/phase9-nonproduction.test.mjs` | 14 passed | +| `npm run check` | 0 errors, 0 warnings, 0 hints | +| `npm run test:markdown` | 20 passed | +| `npm run test:publication` | 33 passed | +| `npm run test:seo` | 35 passed | +| `npm run test:library` | 53 passed | +| `npm run test:routes` | 10 passed | +| `node --test tools/campaign.test.mjs tools/phase10-artifact.test.mjs` | 18 passed | + +The first Library run exposed an assertion that predated the signed slashless-routing template. The test was corrected to require its documented 308/static-serving preamble and to require that the generated redirect rules remain the template suffix. It did not alter `nginx-rewrites.conf` or any deployment setting. + +### Build artifacts + +| Build | Result | +|---|---| +| Normal explicit `off` staging build | 128 HTML routes; all `noindex,nofollow`; empty sitemap; zero hreflang; no Form A identifier or iframe; zero active non-search native form actions | +| Explicit `mock` build | 128 routes; no Form A identifier or iframe in the generated artifact | +| Fully gated `isolated-test` build | 128 routes; adapter emitted only at the two contact routes with fixed EN/ES URLs | +| Indexing-enabled build with all isolated flags set | 127 routes; 109 sitemap URLs; 180 hreflang links; `/lp` absent; Form A adapter and identifier excluded; contact pages render the off fallback | + +`phase10-artifact-verify.mjs` passed for normal staging and indexing output. Its new exact-ID check deliberately rejects this isolated-test adapter from any normal release artifact. + +### Guarded Chromium evidence + +Playwright 1.61.0 and Chromium at `/etc/profiles/per-user/alice/bin/chromium` ran against loopback-only Astro previews. + +- Mock mode opened `/contact` and `/es/contact` at 390px and 1440px: four checks passed, no Jotform attempt, no non-read request. Existing Leaflet CSS/JS GET attempts were blocked before transmission. +- Isolated-test mode opened the same two routes at 390px and 1440px, plus both routes at 390px with JavaScript disabled: six checks passed. The localized title, exact fixed URL, immediately visible fallback, sandbox, no-referrer policy, parent focusability, single H1, no parent native form, no-JavaScript fallback, and no parent overflow all passed. Four full-page screenshots were written outside the repository in `/tmp/aia-form-a-iframe-9tLPnc`. +- The browser attempted exactly six Jotform iframe **GET** loads — one EN and one ES URL at each JavaScript-enabled viewport and one per locale with JavaScript disabled. The test route handler intercepted and aborted all six before network transmission. It recorded zero POST, DELETE, WebSocket, production API, analytics, advertising, or conversion-event attempt. It did not inspect iframe content or treat the load attempt as success. + +These results validate the local adapter boundaries only. They do not prove that the disabled Jotform is reachable, that either language runtime works, that a form can submit, or that an accepted lead exists. + +## Required owner-authorized acceptance before any live use + +1. Keep Form A disabled until the JF-01 through JF-08 configuration review is recorded, including question properties, PHI protection, conditions, recipients, templates, integrations, retention, redirect/POST-forwarding settings, and availability. +2. Verify that the Jotform runtime recognizes the fixed `language=en` and `language=es` URLs, with complete English/Spanish labels, validation, confirmation, and accessible native Submit-button behavior. +3. Test the Builder CSS, sandbox, frame height, responsive reflow, keyboard flow, focus, fallback, blocked/slow frame state, and real screen-reader experience at 320px, 390px, 768px, and 1440px. +4. Separately authorize JF-09 synthetic testing: an approved environment, test window, permitted origins/methods, synthetic data, recipient roles, generic notification content, failure/duplicate behavior, and no PHI/Contabo/Google transfer. +5. Obtain operations, clinical, fluent-Spanish, privacy/legal/security, hosting/CSP/referrer, and release-owner approval before mapping this adapter to any non-isolated or production artifact. + +No Google Ads, GA4, GTM, analytics, advertising, Jotform webhook, Contabo form endpoint, native POST, or API/counter behavior was added or activated. The Nginx template remains an untested deployment template. No deployment, Git staging, commit, merge, or push occurred. diff --git a/www/reports/phase-9-form-a-iframe-sizing-2026-10-25.md b/www/reports/phase-9-form-a-iframe-sizing-2026-10-25.md new file mode 100644 index 0000000..aaf3cdc --- /dev/null +++ b/www/reports/phase-9-form-a-iframe-sizing-2026-10-25.md @@ -0,0 +1,100 @@ +# Phase 9 — Form A iframe sizing refinement + +**Initial calibration:** 2026-10-25 UTC +**Latest calibration:** 2026-10-26 UTC + +**Scope:** isolated-test Form A presentation only; no Jotform configuration, submission, or production activation. + +**2026-10-26 route addendum:** `/lp/early-intervention` now uses the same fixed `landing` context and height profile as the four campaign landing routes. The adapter remains unavailable outside the explicit isolated-test gate. + +## Corrected presentation + +The single fixed iframe height caused two opposite problems in the real Firefox review: the contact context could clip the message field and native Submit button, while the compact landing context retained unnecessary space after Submit. + +The original pre-Builder-CSS fixed-height allowances were: + +| Context | Routes | 768px and 1440px | 320px and 390px | +|---|---|---:|---:| +| `contact` | `/contact`, `/es/contact` | 75rem / 1200px | 84rem / 1344px | +| `landing` | Five approved `/lp` landing routes, including `/lp/early-intervention` | 42rem / 672px | 50rem / 800px | + +After the current full-width Builder CSS was installed, the adapter was initially reduced at every approved placement: + +| Context | Routes | 768px and 1440px | 320px and 390px | +|---|---|---:|---:| +| `contact` | `/contact`, `/es/contact` | 56rem / 896px | 60rem / 960px | +| `landing` | Four approved `/lp/aba-*` routes | 36rem / 576px | 44rem / 704px | + +An October 26 desktop report then identified a Jotform required-field blur error creating an internal scrollbar on `/lp/aba-therapy`. The final fixed-height calibration adds bounded error-room to every Form A context: + +| Context | Routes | 768px and 1440px | 320px and 390px | +|---|---|---:|---:| +| `contact` | `/contact`, `/es/contact` | 60rem / 960px | 68rem / 1088px | +| `landing` | Four approved `/lp/aba-*` routes | 42rem / 672px | 50rem / 800px | + +The contact allowance retains fixed room for its textarea, Spanish wrapping, conditional email state, required-field messages, keyboard focus rings, and native Submit control. The compact landing allowance applies only to the four contact fields and one validation message. The hosted fallback, fixed URL parameters, isolated-test gates, `no-referrer` policy, and `allow-forms allow-scripts allow-same-origin` sandbox are unchanged. + +No cross-origin automatic resize mechanism was added. Reliable dynamic height synchronization would require a reviewed Jotform helper or `postMessage` contract, with its external-resource, origin-validation, privacy, and accessibility implications. That remains a separate decision. + +## Automated verification + +| Check | Result | +|---|---:| +| `npm run check` | 0 diagnostics | +| Focused Form A/preview/nonproduction tests | 14 passed | +| Guarded Chromium sizing test | 35 checks passed: seven routes at 320px, 390px, 768px, and 1440px, plus seven no-JavaScript checks | +| Normal staging artifact verification | 128 HTML pages; all `noindex,nofollow`; empty sitemap; zero hreflang; no Form A iframe or identifier | +| Campaign and artifact tests after normal-build restoration | 18 passed | + +The guarded test aborts all external requests. It verified the calculated dimensions, fixed route/locale/context URLs, iframe attributes, no parent native form, no page-level horizontal overflow, the four campaign surfaces, and the Early Intervention embedded card. + +## Read-only real-iframe probe + +`tools/jotform-form-a-real-iframe-sizing.mjs` requires both explicit environment approvals before it permits a request: + +```sh +FORM_A_IFRAME_REAL_LOAD_APPROVED=true +FORM_A_IFRAME_JOTFORM_ASSETS_APPROVED=true +``` + +It never fills, focuses, clicks, or submits a Jotform control. It permits only read requests to `https://form.jotform.com` and the observed Jotform asset origin `https://cdn.jotfor.ms`; it blocks WebSockets, downloads, all non-read methods, and every other external origin. + +The 2026-10-25 run captured 24 full-page screenshots in: + +```text +/tmp/aia-form-a-real-iframe-sizing-skBmLf +``` + +It observed 504 allowed Jotform GET/HEAD requests and blocked 40 requests to `https://unpkg.com` and `https://events.jotform.com`. No AIA production API, Google analytics/advertising, conversion, Contabo, POST, DELETE, or WebSocket attempt was observed. The Jotform event endpoint remains blocked; no decision has been made to permit it. + +The 2026-10-26 recalibration captured another 24 full-page screenshots in: + +```text +/tmp/aia-form-a-real-iframe-sizing-8RAs2f +``` + +It observed 432 permitted Jotform GET/HEAD requests and blocked 40 requests to `https://unpkg.com` and `https://events.jotform.com`; it recorded no AIA production API, analytics, advertising, WebSocket, POST, or DELETE attempt. + +After the Early Intervention route addition, a further 2026-10-26 read-only run captured 28 full-page screenshots (seven routes at four widths) in: + +```text +/tmp/aia-form-a-real-iframe-sizing-oCqA5n +``` + +It observed 504 permitted Jotform GET/HEAD requests and blocked 44 unapproved external GETs. It recorded zero forbidden production API, analytics, advertising, WebSocket, POST, or DELETE attempt. This retained the same read-only guard and did not fill, focus, click, inspect, or submit an iframe control. + +At 768px and 1440px, the current English and Spanish contact screenshots visibly contain the full field sequence, message area or prompt, sensitive-information text, and native Submit control. The fixed frame ends after a modest visual margin below Submit, rather than the large blank region produced by the original 1200px allowance. The assessment uses screenshots only: no cross-origin iframe DOM, field measurement, or Jotform submission was accessed. The later landing validation-error scrollbar was reported from a real desktop browser; the bounded final increase above addresses that observed error expansion without a dynamic resize channel. + +Under the strict guard, the mobile and landing iframe content did not render in the captured pages even after Jotform asset loading was allowed. The blocked `events.jotform.com` request is an unresolved Jotform-runtime dependency, not evidence that the endpoint is required. No privacy boundary was weakened to make those screenshots render. The seven parent-side frames still passed their fixed-height, no-overflow, fallback, URL, sandbox, and no-JavaScript assertions at 320px, 390px, 768px, and 1440px. + +The initial 2026-10-25 desktop capture showed Builder-only fields intended to be nonvisitor-facing, including `formContext`, `websiteCheck`, and `notificationReplyTo`. Those labels were not visible in the latest desktop screenshots. This is visual evidence only, not a Jotform Builder configuration audit; the account owner must still verify the fields and their conditional visibility. + +## Remaining acceptance + +1. Confirm whether the blocked Jotform event endpoint is necessary and whether its data flow is acceptable. Do not allow it by default. +2. Perform a manual Firefox review of all seven routes at 320px, 390px, 768px, and 1440px against the current Builder CSS. Verify the compact landing form, the Early Intervention card, both mobile contact forms, Spanish wrapping, validation-error expansion, focus rings, and all Submit controls. +3. Test conditional email validation, native Submit behavior, confirmation, and failure states using separately authorized synthetic data only. +4. Reconsider a reviewed cross-origin resize contract only if the manual review identifies a real clip or an unacceptable fixed-height gap. It is not warranted by the guarded evidence alone. +5. Obtain privacy/security, operations, fluent-language, accessibility, and release approval before any live activation. + +No production integration, Jotform account mutation, form submission, analytics, deployment, staging, commit, or push occurred. diff --git a/www/src/components/JotformIframe.astro b/www/src/components/JotformIframe.astro new file mode 100644 index 0000000..7e72cbe --- /dev/null +++ b/www/src/components/JotformIframe.astro @@ -0,0 +1,149 @@ +--- +import type { FormAIframeConfig } from '../utils/form-integration'; + +interface Props { + config: FormAIframeConfig; + heading: string; + description?: string; + variant?: 'standalone' | 'embedded'; +} + +const { config, heading, description, variant = 'standalone' } = Astro.props; + +if (!config.enabled || !config.url || !config.title || !config.fallbackText || !config.fallbackLinkText) { + throw new Error('JotformIframe requires an enabled, fully validated Form A iframe configuration.'); +} + +const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +})[character] ?? character); + +// Astro treats iframe descendants as raw text. set:html emits standards-based +// iframe fallback markup while preserving the fixed, server-validated URL. +const fallbackMarkup = `

${escapeHtml(config.fallbackText)} ${escapeHtml(config.fallbackLinkText)}

`; +--- + +
+

{heading}

+ {description &&

{description}

} + +
+ + + diff --git a/www/src/components/campaign/ConsultationForm.astro b/www/src/components/campaign/ConsultationForm.astro index c61cf9a..aee76f4 100644 --- a/www/src/components/campaign/ConsultationForm.astro +++ b/www/src/components/campaign/ConsultationForm.astro @@ -1,7 +1,8 @@ --- import Icon from './Icon.astro'; import FormAPreview from '../FormAPreview.astro'; -import { formAPreviewEnabled, formModeForRuntime, resolveFormMode } from '../../utils/form-integration'; +import JotformIframe from '../JotformIframe.astro'; +import { formAPreviewEnabled, formModeForRuntime, resolveFormAIframe, resolveFormMode } from '../../utils/form-integration'; interface Props { sourcePage: 'general' | 'commercial' | 'ahcccs' | 'private_pay'; title: string; @@ -13,6 +14,23 @@ const phone = import.meta.env.PUBLIC_AIA_PHONE || '480-687-7099'; const tel = phone.replace(/[^0-9+]/g, ''); const formConfig = resolveFormMode(import.meta.env.PUBLIC_AIA_FORMS_MODE, import.meta.env.PUBLIC_AIA_FORMS_LIVE_APPROVED); const formPreview = formAPreviewEnabled(import.meta.env.PUBLIC_AIA_FORM_A_PREVIEW, import.meta.env.PUBLIC_ALLOW_INDEXING, formModeForRuntime(formConfig)); +const campaignRoute = sourcePage === 'general' + ? '/lp/aba-therapy' + : sourcePage === 'commercial' + ? '/lp/aba-insurance' + : sourcePage === 'ahcccs' + ? '/lp/aba-ahcccs' + : '/lp/aba-private-pay'; +const iframeDescription = 'Use this form to request a conversation. An AIA team member can explain the next steps for your family.'; +const formAIframe = resolveFormAIframe({ + rawMode: import.meta.env.PUBLIC_AIA_FORMS_MODE, + liveApproved: import.meta.env.PUBLIC_AIA_FORMS_LIVE_APPROVED, + isolatedTestApproved: import.meta.env.PUBLIC_AIA_FORMS_ISOLATED_TEST_APPROVED, + environmentRole: import.meta.env.PUBLIC_AIA_FORMS_ENVIRONMENT, + allowIndexing: import.meta.env.PUBLIC_ALLOW_INDEXING, + route: campaignRoute, + locale: 'en', +}); ---
@@ -22,7 +40,9 @@ const formPreview = formAPreviewEnabled(import.meta.env.PUBLIC_AIA_FORM_A_PREVIE

{intro}

- {formPreview ? :
+ {formAIframe.enabled + ? + : formPreview ? :

Start with your contact details

diff --git a/www/src/components/campaign/EarlyInterventionPage.astro b/www/src/components/campaign/EarlyInterventionPage.astro index e69e421..6e04825 100644 --- a/www/src/components/campaign/EarlyInterventionPage.astro +++ b/www/src/components/campaign/EarlyInterventionPage.astro @@ -1,5 +1,20 @@ +--- +import JotformIframe from '../JotformIframe.astro'; +import { resolveFormAIframe } from '../../utils/form-integration'; + +const formAIframe = resolveFormAIframe({ + rawMode: import.meta.env.PUBLIC_AIA_FORMS_MODE, + liveApproved: import.meta.env.PUBLIC_AIA_FORMS_LIVE_APPROVED, + isolatedTestApproved: import.meta.env.PUBLIC_AIA_FORMS_ISOLATED_TEST_APPROVED, + environmentRole: import.meta.env.PUBLIC_AIA_FORMS_ENVIRONMENT, + allowIndexing: import.meta.env.PUBLIC_ALLOW_INDEXING, + route: '/lp/early-intervention', + locale: 'en', +}); +--- + - +
@@ -23,17 +38,19 @@
-
+
-

Call AIA about early intervention

+

Contact AIA about early intervention

Speak with an AIA team member about your questions and possible next steps.

+{formAIframe.enabled ? : <>
-
+ } +
diff --git a/www/src/utils/form-integration.ts b/www/src/utils/form-integration.ts index 2922ae9..9e39c7f 100644 --- a/www/src/utils/form-integration.ts +++ b/www/src/utils/form-integration.ts @@ -1,26 +1,156 @@ -export type FormMode = 'off' | 'mock' | 'live'; +export type FormMode = 'off' | 'mock' | 'isolated-test' | 'live'; export type LogicalForm = 'general' | 'service' | 'referral'; export type MockFormResult = 'success' | 'failed' | 'unavailable'; export interface FormModeConfig { mode: FormMode; - reason: 'default-off' | 'mock' | 'live-approved' | 'invalid-mode' | 'live-not-approved'; + reason: 'default-off' | 'mock' | 'isolated-test' | 'live-approved' | 'invalid-mode' | 'live-not-approved'; } export function resolveFormMode(rawMode: string | undefined, liveApproved: string | undefined): FormModeConfig { const mode = rawMode?.trim().toLowerCase(); if (!mode) return { mode: 'off', reason: 'default-off' }; if (mode === 'mock') return { mode: 'mock', reason: 'mock' }; + if (mode === 'isolated-test') return { mode: 'isolated-test', reason: 'isolated-test' }; if (mode !== 'live') return { mode: 'off', reason: 'invalid-mode' }; if (liveApproved?.trim().toLowerCase() !== 'true') return { mode: 'off', reason: 'live-not-approved' }; return { mode: 'live', reason: 'live-approved' }; } export function formModeForRuntime(config: FormModeConfig): 'off' | 'mock' { - // A live adapter and approved form IDs do not exist yet. Keep live fail-closed. + // The isolated iframe adapter has its own fixed Form A route allowlist. + // Keep all other lead-form surfaces and every live setting fail-closed. return config.mode === 'mock' ? 'mock' : 'off'; } +export type FormAIframeReason = + | 'enabled' + | 'mode-not-isolated-test' + | 'isolated-test-not-approved' + | 'invalid-environment-role' + | 'indexing-enabled' + | 'route-not-authorized' + | 'locale-not-authorized'; + +export interface FormAIframeConfig { + enabled: boolean; + reason: FormAIframeReason; + route: FormAIframeRoute | string; + locale: 'en' | 'es' | string; + context?: FormAIframeContext; + url?: string; + title?: string; + fallbackText?: string; + fallbackLinkText?: string; +} + +export interface ResolveFormAIframeOptions { + rawMode: string | undefined; + liveApproved: string | undefined; + isolatedTestApproved: string | undefined; + environmentRole: string | undefined; + allowIndexing: string | undefined; + route: string; + locale: string; +} + +/** + * The owner-approved Form A identifier is deliberately only made into an + * iframe URL after every isolated-test gate has passed. It is not a + * production setting and it is not enabled by an API URL or a live mode. + */ +export const formAIframeOrigin = 'https://form.jotform.com' as const; +export const formAIframeId = '262946078314059' as const; +const isolatedTestEnvironmentRole = 'isolated-test'; + +export type FormAIframeContext = 'contact' | 'landing'; +export type FormAIframeRoute = + | '/contact' + | '/es/contact' + | '/lp/aba-therapy' + | '/lp/aba-insurance' + | '/lp/aba-ahcccs' + | '/lp/aba-private-pay' + | '/lp/early-intervention'; + +interface FormAIframePlacement { + route: FormAIframeRoute; + locale: 'en' | 'es'; + context: FormAIframeContext; +} + +/** + * This is the complete fixed allowlist for the isolated Form A adapter. + * Campaign placements are an owner-approved override of their earlier + * logical Form B mapping. The shared compact landing presentation uses the + * same fixed `landing` context; no route may supply its own context or URL. + */ +export const formAIframePlacements: ReadonlyArray = [ + { route: '/contact', locale: 'en', context: 'contact' }, + { route: '/es/contact', locale: 'es', context: 'contact' }, + { route: '/lp/aba-therapy', locale: 'en', context: 'landing' }, + { route: '/lp/aba-insurance', locale: 'en', context: 'landing' }, + { route: '/lp/aba-ahcccs', locale: 'en', context: 'landing' }, + { route: '/lp/aba-private-pay', locale: 'en', context: 'landing' }, + { route: '/lp/early-intervention', locale: 'en', context: 'landing' }, +]; + +const isTrue = (value: string | undefined) => value?.trim().toLowerCase() === 'true'; + +function formAIframeCopy(locale: 'en' | 'es') { + return locale === 'es' + ? { + title: 'Formulario de consultas generales de AIA', + fallbackText: 'Si el formulario integrado no se carga,', + fallbackLinkText: 'abra el formulario en una pestaña nueva.', + } + : { + title: 'AIA general enquiries form', + fallbackText: 'If the embedded form does not load,', + fallbackLinkText: 'open the form in a new tab.', + }; +} + +/** Builds a fixed, allowlisted URL; it accepts neither visitor data nor URL parameters. */ +export function buildFormAIframeUrl(locale: 'en' | 'es', context: FormAIframeContext): string { + const url = new URL(`/${formAIframeId}`, formAIframeOrigin); + url.searchParams.set('formContext', context); + url.searchParams.set('language', locale); + return url.toString(); +} + +/** + * Enables the real Form A iframe only in an explicitly approved isolated + * test build. `live` is intentionally not a valid route to this adapter. + */ +export function resolveFormAIframe(options: ResolveFormAIframeOptions): FormAIframeConfig { + const disabled = (reason: Exclude): FormAIframeConfig => ({ + enabled: false, + reason, + route: options.route, + locale: options.locale, + }); + const config = resolveFormMode(options.rawMode, options.liveApproved); + if (config.mode !== 'isolated-test') return disabled('mode-not-isolated-test'); + if (!isTrue(options.isolatedTestApproved)) return disabled('isolated-test-not-approved'); + if (options.environmentRole?.trim().toLowerCase() !== isolatedTestEnvironmentRole) return disabled('invalid-environment-role'); + if (isTrue(options.allowIndexing)) return disabled('indexing-enabled'); + + const placement = formAIframePlacements.find((candidate) => candidate.route === options.route); + if (!placement) return disabled('route-not-authorized'); + if (options.locale !== placement.locale) return disabled('locale-not-authorized'); + + return { + enabled: true, + reason: 'enabled', + route: options.route, + locale: placement.locale, + context: placement.context, + url: buildFormAIframeUrl(placement.locale, placement.context), + ...formAIframeCopy(placement.locale), + }; +} + /** * The Form A meeting preview is deliberately narrower than the general mock * mode: it is available only for an explicitly requested, non-indexing mock @@ -55,11 +185,11 @@ export const formRouteMapping: ReadonlyArray<{ { route: '/aba-therapy', logicalForm: 'service', kind: 'native-instance' }, { route: '/autism-evaluations', logicalForm: 'service', kind: 'native-instance' }, { route: '/learner-social-club', logicalForm: 'service', kind: 'native-instance' }, - { route: '/lp/aba-therapy', logicalForm: 'service', kind: 'landing-journey' }, - { route: '/lp/aba-insurance', logicalForm: 'service', kind: 'landing-journey' }, - { route: '/lp/aba-ahcccs', logicalForm: 'service', kind: 'landing-journey' }, - { route: '/lp/aba-private-pay', logicalForm: 'service', kind: 'landing-journey' }, - { route: '/lp/early-intervention', logicalForm: 'service', kind: 'landing-journey' }, + { route: '/lp/aba-therapy', logicalForm: 'general', kind: 'landing-journey' }, + { route: '/lp/aba-insurance', logicalForm: 'general', kind: 'landing-journey' }, + { route: '/lp/aba-ahcccs', logicalForm: 'general', kind: 'landing-journey' }, + { route: '/lp/aba-private-pay', logicalForm: 'general', kind: 'landing-journey' }, + { route: '/lp/early-intervention', logicalForm: 'general', kind: 'landing-journey' }, ]; export function logicalFormForRoute(route: string): LogicalForm | undefined { diff --git a/www/tools/campaign.test.mjs b/www/tools/campaign.test.mjs index 39eff30..a98346e 100644 --- a/www/tools/campaign.test.mjs +++ b/www/tools/campaign.test.mjs @@ -150,8 +150,11 @@ test('early-intervention preserves its own publication identity and source secti assert.equal($('.qr-benefit-card').length, 3); assert.equal($('.qr-process-step').length, 3); assert.equal($('form, input[type="hidden"], script[src]').length, 0); + assert.equal($('#consultation-heading').text(), 'Contact AIA about early intervention'); assert.equal($('#consultation input').length, 3); assert.equal($('#consultation input:disabled').length, 3); + assert.equal($('#consultation').attr('data-forms-mode'), 'off'); + assert.equal($('#consultation [data-jotform-form-a]').length, 0); assert.equal($('#consultation button').attr('type'), 'button'); assert.ok($('.qr-call-card a[href="tel:+14806877099"]').length); assert.equal($('#consultation-unavailable, [data-form-status-notice]').length, 0); diff --git a/www/tools/jotform-form-a-browser.mjs b/www/tools/jotform-form-a-browser.mjs new file mode 100644 index 0000000..5a10075 --- /dev/null +++ b/www/tools/jotform-form-a-browser.mjs @@ -0,0 +1,193 @@ +/** + * Deterministic Form A adapter check. Build the requested mode first, then + * run this against a loopback-only Astro preview. Every external request is + * intercepted and aborted before it can leave the browser. + * + * FORM_A_IFRAME_BROWSER_MODE=isolated-test \ + * PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \ + * CHROMIUM_EXECUTABLE=/path/to/chromium node tools/jotform-form-a-browser.mjs + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const moduleName = process.env.PLAYWRIGHT_MODULE ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href : 'playwright'; +const mode = process.env.FORM_A_IFRAME_BROWSER_MODE ?? 'off'; +if (!['off', 'mock', 'isolated-test'].includes(mode)) throw new Error(`Unsupported Form A browser mode: ${mode}`); + +const expectedIframe = mode === 'isolated-test'; +const routes = [ + { route: '/contact', locale: 'en', context: 'contact', screenshot: 'contact' }, + { route: '/es/contact', locale: 'es', context: 'contact', screenshot: 'es-contact' }, + { route: '/lp/aba-therapy', locale: 'en', context: 'landing', screenshot: 'aba-therapy' }, + { route: '/lp/aba-insurance', locale: 'en', context: 'landing', screenshot: 'aba-insurance' }, + { route: '/lp/aba-ahcccs', locale: 'en', context: 'landing', screenshot: 'aba-ahcccs' }, + { route: '/lp/aba-private-pay', locale: 'en', context: 'landing', screenshot: 'aba-private-pay' }, + { route: '/lp/early-intervention', locale: 'en', context: 'landing', screenshot: 'early-intervention' }, +]; +const widths = [320, 390, 768, 1440]; +const output = await fs.mkdtemp('/tmp/aia-form-a-iframe-'); +const evidence = { mode, routes, widths, checks: 0, blocked: [], jotformAttempts: [], screenshots: [], status: 'failed' }; +const reserve = net.createServer(); +await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); }); +const port = reserve.address().port; +await new Promise((resolve) => reserve.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +let browser; +let preview; + +try { + const { chromium } = await import(moduleName); + browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE ? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE } : { headless: true }); + preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], { + cwd: root, + env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' }, + stdio: 'ignore', + }); + const reachable = () => new Promise((resolve) => { + const request = http.get(origin + '/', (response) => { response.resume(); resolve(response.statusCode === 200); }); + request.setTimeout(500, () => request.destroy()); + request.on('error', () => resolve(false)); + }); + for (let i = 0; i < 60 && !(await reachable()); i++) await delay(250); + assert.equal(await reachable(), true, 'Loopback preview did not become reachable'); + + for (const width of widths) { + const context = await browser.newContext({ viewport: { width, height: width < 500 ? 844 : 960 }, serviceWorkers: 'block' }); + await context.routeWebSocket('**/*', (socket) => socket.close()); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === origin && ['GET', 'HEAD'].includes(request.method())) return route.continue(); + const item = { origin: url.origin, path: url.pathname, search: url.search, method: request.method() }; + evidence.blocked.push(item); + if (url.origin === 'https://form.jotform.com') evidence.jotformAttempts.push(item); + return route.abort(); + }); + const page = await context.newPage(); + for (const expected of routes) { + const { route, locale, context: formContext, screenshot: screenshotName } = expected; + evidence.checks++; + const response = await page.goto(origin + route, { waitUntil: 'domcontentloaded', timeout: 15000 }); + assert.equal(response?.status(), 200, route); + assert.equal(await page.locator('main h1').count(), 1, route); + assert.equal(await page.locator('main form').count(), 0, route); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `${route} at ${width}px has horizontal overflow`); + const iframe = page.locator('[data-jotform-form-a] iframe'); + if (!expectedIframe) { + assert.equal(await iframe.count(), 0, `${route} should not emit an iframe in ${mode}`); + assert.equal(await page.locator('[data-jotform-form-a]').count(), 0, `${route} should not emit an adapter in ${mode}`); + continue; + } + const expectedUrl = `https://form.jotform.com/262946078314059?formContext=${formContext}&language=${locale}`; + assert.equal(await iframe.count(), 1, route); + assert.equal(await iframe.getAttribute('src'), expectedUrl, route); + assert.equal(await iframe.getAttribute('title'), locale === 'es' ? 'Formulario de consultas generales de AIA' : 'AIA general enquiries form', route); + assert.equal(await iframe.getAttribute('referrerpolicy'), 'no-referrer', route); + assert.equal(await iframe.getAttribute('sandbox'), 'allow-forms allow-scripts allow-same-origin', route); + assert.equal(await page.locator('[data-jotform-form-a][data-form-mode="isolated-test"]').count(), 1, route); + assert.equal(await page.locator(`[data-jotform-form-a][data-form-context="${formContext}"]`).count(), 1, route); + const frameBox = await iframe.boundingBox(); + const expectedFrameHeight = formContext === 'contact' + ? width < 500 ? 1088 : 960 + : width < 500 ? 800 : 672; + assert.ok(frameBox && frameBox.width > 0 && frameBox.height >= expectedFrameHeight, `${route} iframe has an insufficient context-specific responsive frame`); + if ((route === '/contact' || route === '/es/contact') && width >= 768) { + const copyBox = await page.locator('.contact-page__copy').boundingBox(); + const adapterBox = await page.locator('[data-jotform-form-a]').boundingBox(); + assert.ok(copyBox && adapterBox && Math.abs(copyBox.width - adapterBox.width) <= 1, `${route} should use the full desktop grid column`); + } + const fallbackMarkup = await iframe.evaluate((element) => element.innerHTML); + assert.match(fallbackMarkup, /class="jotform-iframe__fallback"/, route); + const encodedFallbackUrl = expectedUrl.replace(/&/g, '&'); + assert.match(fallbackMarkup, new RegExp(`href="${encodedFallbackUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`), route); + assert.match(fallbackMarkup, /target="_blank" rel="noopener noreferrer"/, route); + assert.equal(await page.locator('[data-jotform-form-a] > .jotform-iframe__fallback').count(), 0, `${route} should not show a duplicate parent fallback`); + if (route.startsWith('/lp/')) { + const expectedSurface = route === '/lp/early-intervention' + ? '#consultation.lead-form > [data-jotform-form-a].jotform-iframe--embedded' + : '.form-wrap > [data-jotform-form-a].jotform-iframe--embedded'; + assert.equal(await page.locator(expectedSurface).count(), 1, route); + assert.equal(await page.locator('#lead-form, [data-form-a-preview], #consultation input:enabled').count(), 0, route); + if (route !== '/lp/early-intervention') { + assert.equal( + await page.locator('[data-jotform-form-a] .jotform-iframe__description').innerText(), + 'Use this form to request a conversation. An AIA team member can explain the next steps for your family.', + `${route} should use the shared iframe description instead of repeating companion copy`, + ); + } + } + await iframe.focus(); + assert.equal(await page.evaluate(() => document.activeElement?.tagName), 'IFRAME', route); + const screenshot = path.join(output, `${screenshotName}-${width}.png`); + await page.screenshot({ path: screenshot, fullPage: true }); + evidence.screenshots.push(screenshot); + } + await context.close(); + } + + if (expectedIframe) { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, javaScriptEnabled: false, serviceWorkers: 'block' }); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === origin && ['GET', 'HEAD'].includes(request.method())) return route.continue(); + const item = { origin: url.origin, path: url.pathname, search: url.search, method: request.method(), noJavaScript: true }; + evidence.blocked.push(item); + if (url.origin === 'https://form.jotform.com') evidence.jotformAttempts.push(item); + return route.abort(); + }); + for (const { route } of routes) { + evidence.checks++; + const page = await context.newPage(); + const response = await page.goto(origin + route, { waitUntil: 'domcontentloaded', timeout: 15000 }); + assert.equal(response?.status(), 200, `${route} without JavaScript`); + assert.equal(await page.locator('[data-jotform-form-a] iframe').count(), 1, `${route} without JavaScript`); + const iframe = page.locator('[data-jotform-form-a] iframe'); + const fallbackMarkup = await iframe.evaluate((element) => element.innerHTML); + assert.match(fallbackMarkup, /class="jotform-iframe__fallback"/, `${route} without JavaScript`); + assert.match(fallbackMarkup, /target="_blank" rel="noopener noreferrer"/, `${route} without JavaScript`); + assert.equal(await page.locator('[data-jotform-form-a] > .jotform-iframe__fallback').count(), 0, `${route} without JavaScript should not show a duplicate parent fallback`); + if (route.startsWith('/lp/')) { + const expectedSurface = route === '/lp/early-intervention' + ? '#consultation.lead-form > [data-jotform-form-a].jotform-iframe--embedded' + : '.form-wrap > [data-jotform-form-a].jotform-iframe--embedded'; + assert.equal(await page.locator(expectedSurface).count(), 1, `${route} without JavaScript`); + } + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), `${route} without JavaScript has horizontal overflow`); + } + await context.close(); + } + + const formAttempts = evidence.jotformAttempts; + if (expectedIframe) { + assert.ok(formAttempts.length >= routes.length, 'Expected intercepted Jotform iframe GET attempts'); + for (const attempt of formAttempts) { + assert.equal(attempt.method, 'GET'); + assert.equal(attempt.path, '/262946078314059'); + assert.match(attempt.search, /^\?formContext=(?:contact|landing)&language=(?:en|es)$/); + } + } else { + assert.equal(formAttempts.length, 0, `${mode} attempted Jotform traffic`); + } + assert.equal(evidence.blocked.filter((item) => !['GET', 'HEAD'].includes(item.method)).length, 0, 'A blocked non-read request was attempted'); + evidence.status = 'passed'; +} catch (error) { + evidence.error = error instanceof Error ? error.message : String(error); + throw error; +} finally { + await fs.writeFile(path.join(output, 'evidence.json'), JSON.stringify(evidence, null, 2)); + if (preview && preview.exitCode === null) { + preview.kill('SIGTERM'); + await Promise.race([new Promise((resolve) => preview.once('exit', resolve)), delay(3000)]); + if (preview.exitCode === null) preview.kill('SIGKILL'); + } + await browser?.close(); + console.log(JSON.stringify({ ...evidence, screenshots: evidence.screenshots.map((file) => path.basename(file)), artifactDirectory: output }, null, 2)); +} diff --git a/www/tools/jotform-form-a-real-iframe-sizing.mjs b/www/tools/jotform-form-a-real-iframe-sizing.mjs new file mode 100644 index 0000000..c8422d9 --- /dev/null +++ b/www/tools/jotform-form-a-real-iframe-sizing.mjs @@ -0,0 +1,147 @@ +/** + * Read-only Form A iframe sizing acceptance probe. + * + * This intentionally requires a separate affirmative environment value + * because it permits GET/HEAD requests to form.jotform.com. It never fills, + * focuses, clicks, or submits an iframe control. All other external origins, + * non-read methods, downloads, and WebSockets are blocked and recorded. + * + * Build the isolated-test artifact first, then run: + * FORM_A_IFRAME_REAL_LOAD_APPROVED=true \ + * FORM_A_IFRAME_JOTFORM_ASSETS_APPROVED=true \ + * PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \ + * CHROMIUM_EXECUTABLE=/path/to/chromium \ + * node tools/jotform-form-a-real-iframe-sizing.mjs + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; + +if (process.env.FORM_A_IFRAME_REAL_LOAD_APPROVED !== 'true') { + throw new Error('Set FORM_A_IFRAME_REAL_LOAD_APPROVED=true to permit read-only requests to form.jotform.com.'); +} + +const root = fileURLToPath(new URL('../', import.meta.url)); +const playwrightModule = process.env.PLAYWRIGHT_MODULE + ? pathToFileURL(path.resolve(process.env.PLAYWRIGHT_MODULE)).href + : 'playwright'; +const jotformOrigin = 'https://form.jotform.com'; +const jotformAssetOrigin = 'https://cdn.jotfor.ms'; +const allowJotformAssets = process.env.FORM_A_IFRAME_JOTFORM_ASSETS_APPROVED === 'true'; +const allowedOrigins = new Set([jotformOrigin, ...(allowJotformAssets ? [jotformAssetOrigin] : [])]); +const routes = [ + { route: '/contact', context: 'contact', locale: 'en', file: 'contact' }, + { route: '/es/contact', context: 'contact', locale: 'es', file: 'es-contact' }, + { route: '/lp/aba-therapy', context: 'landing', locale: 'en', file: 'aba-therapy' }, + { route: '/lp/aba-insurance', context: 'landing', locale: 'en', file: 'aba-insurance' }, + { route: '/lp/aba-ahcccs', context: 'landing', locale: 'en', file: 'aba-ahcccs' }, + { route: '/lp/aba-private-pay', context: 'landing', locale: 'en', file: 'aba-private-pay' }, + { route: '/lp/early-intervention', context: 'landing', locale: 'en', file: 'early-intervention' }, +]; +const widths = [320, 390, 768, 1440]; +const output = await fs.mkdtemp('/tmp/aia-form-a-real-iframe-sizing-'); +const evidence = { + routes: routes.map(({ route, context, locale }) => ({ route, context, locale })), + widths, + allowedOrigins: [...allowedOrigins], + allowedJotformRequests: [], + blockedExternalRequests: [], + forbiddenAttempts: [], + screenshots: [], + status: 'failed', +}; +const forbiddenHost = /(?:api\.azinstitute4autism\.com|google-analytics|googletagmanager|googleadservices|doubleclick)/i; +const reserve = net.createServer(); +await new Promise((resolve, reject) => { reserve.once('error', reject); reserve.listen(0, '127.0.0.1', resolve); }); +const port = reserve.address().port; +await new Promise((resolve) => reserve.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +let browser; +let preview; + +try { + const { chromium } = await import(playwrightModule); + browser = await chromium.launch(process.env.CHROMIUM_EXECUTABLE + ? { headless: true, executablePath: process.env.CHROMIUM_EXECUTABLE } + : { headless: true }); + preview = spawn(process.execPath, ['node_modules/astro/bin/astro.mjs', 'preview', '--host', '127.0.0.1', '--port', String(port)], { + cwd: root, + env: { ...process.env, ASTRO_TELEMETRY_DISABLED: '1' }, + stdio: 'ignore', + }); + const reachable = () => new Promise((resolve) => { + const request = http.get(origin + '/', (response) => { response.resume(); resolve(response.statusCode === 200); }); + request.setTimeout(500, () => request.destroy()); + request.on('error', () => resolve(false)); + }); + for (let attempts = 0; attempts < 60 && !(await reachable()); attempts++) await delay(250); + assert.equal(await reachable(), true, 'Loopback preview did not become reachable'); + + for (const width of widths) { + const context = await browser.newContext({ + viewport: { width, height: width < 641 ? 844 : 960 }, + serviceWorkers: 'block', + acceptDownloads: false, + }); + await context.routeWebSocket('**/*', (socket) => socket.close()); + await context.route('**/*', async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const item = { origin: url.origin, path: url.pathname, method: request.method() }; + if (url.origin === origin && ['GET', 'HEAD'].includes(request.method())) return route.continue(); + if (allowedOrigins.has(url.origin) && ['GET', 'HEAD'].includes(request.method())) { + evidence.allowedJotformRequests.push(item); + return route.continue(); + } + evidence.blockedExternalRequests.push(item); + if (forbiddenHost.test(url.hostname)) evidence.forbiddenAttempts.push(item); + return route.abort(); + }); + const page = await context.newPage(); + for (const expected of routes) { + const response = await page.goto(origin + expected.route, { waitUntil: 'domcontentloaded', timeout: 15000 }); + assert.equal(response?.status(), 200, expected.route); + const iframe = page.locator('[data-jotform-form-a] iframe'); + const expectedUrl = `${jotformOrigin}/262946078314059?formContext=${expected.context}&language=${expected.locale}`; + assert.equal(await iframe.count(), 1, expected.route); + assert.equal(await iframe.getAttribute('src'), expectedUrl, expected.route); + assert.equal(await iframe.getAttribute('sandbox'), 'allow-forms allow-scripts allow-same-origin', expected.route); + assert.equal(await iframe.getAttribute('referrerpolicy'), 'no-referrer', expected.route); + const box = await iframe.boundingBox(); + const expectedHeight = expected.context === 'contact' + ? width < 641 ? 1088 : 960 + : width < 641 ? 800 : 672; + assert.ok(box && box.width > 0 && box.height >= expectedHeight, `${expected.route}: context-specific frame height is insufficient`); + await page.waitForTimeout(1200); + const screenshot = path.join(output, `${expected.file}-${width}.png`); + await page.screenshot({ path: screenshot, fullPage: true }); + evidence.screenshots.push(screenshot); + } + await context.close(); + } + assert.ok(evidence.allowedJotformRequests.length >= routes.length, 'Expected read-only Jotform iframe requests.'); + assert.equal(evidence.forbiddenAttempts.length, 0, `Forbidden external requests: ${JSON.stringify(evidence.forbiddenAttempts)}`); + assert.equal(evidence.blockedExternalRequests.filter((item) => !['GET', 'HEAD'].includes(item.method)).length, 0, 'A non-read external request was attempted.'); + evidence.status = 'passed'; +} catch (error) { + evidence.error = error instanceof Error ? error.message : String(error); + throw error; +} finally { + await fs.writeFile(path.join(output, 'evidence.json'), JSON.stringify(evidence, null, 2)); + if (preview?.exitCode === null) { + preview.kill('SIGTERM'); + await Promise.race([new Promise((resolve) => preview.once('exit', resolve)), delay(3000)]); + if (preview.exitCode === null) preview.kill('SIGKILL'); + } + await browser?.close(); + console.log(JSON.stringify({ + ...evidence, + screenshots: evidence.screenshots.map((file) => path.basename(file)), + artifactDirectory: output, + }, null, 2)); +} diff --git a/www/tools/jotform-form-a.test.mjs b/www/tools/jotform-form-a.test.mjs new file mode 100644 index 0000000..719e482 --- /dev/null +++ b/www/tools/jotform-form-a.test.mjs @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import { + buildFormAIframeUrl, + formAIframeId, + formAIframeOrigin, + formAIframePlacements, + formModeForRuntime, + resolveFormAIframe, + resolveFormMode, +} from '../src/utils/form-integration.ts'; + +const isolated = (overrides = {}) => ({ + rawMode: 'isolated-test', + liveApproved: 'false', + isolatedTestApproved: 'true', + environmentRole: 'isolated-test', + allowIndexing: 'false', + route: '/contact', + locale: 'en', + ...overrides, +}); + +test('Form A iframe uses only fixed, approved route parameters', () => { + assert.equal(formAIframeOrigin, 'https://form.jotform.com'); + assert.equal(formAIframeId, '262946078314059'); + assert.equal( + buildFormAIframeUrl('en', 'contact'), + 'https://form.jotform.com/262946078314059?formContext=contact&language=en', + ); + assert.equal( + buildFormAIframeUrl('es', 'contact'), + 'https://form.jotform.com/262946078314059?formContext=contact&language=es', + ); + assert.equal( + buildFormAIframeUrl('en', 'landing'), + 'https://form.jotform.com/262946078314059?formContext=landing&language=en', + ); + assert.deepEqual(formAIframePlacements.map((placement) => placement.route), [ + '/contact', + '/es/contact', + '/lp/aba-therapy', + '/lp/aba-insurance', + '/lp/aba-ahcccs', + '/lp/aba-private-pay', + '/lp/early-intervention', + ]); +}); + +test('Form A iframe requires every isolated-test gate and refuses live mode', () => { + const enabled = resolveFormAIframe(isolated()); + assert.equal(enabled.enabled, true); + assert.equal(enabled.reason, 'enabled'); + assert.equal(enabled.title, 'AIA general enquiries form'); + assert.equal(resolveFormAIframe(isolated({ rawMode: undefined })).reason, 'mode-not-isolated-test'); + assert.equal(resolveFormAIframe(isolated({ rawMode: 'mock' })).reason, 'mode-not-isolated-test'); + assert.equal(resolveFormAIframe(isolated({ rawMode: 'live', liveApproved: 'true' })).reason, 'mode-not-isolated-test'); + assert.equal(resolveFormAIframe(isolated({ isolatedTestApproved: 'false' })).reason, 'isolated-test-not-approved'); + assert.equal(resolveFormAIframe(isolated({ environmentRole: 'staging' })).reason, 'invalid-environment-role'); + assert.equal(resolveFormAIframe(isolated({ allowIndexing: 'true' })).reason, 'indexing-enabled'); + assert.equal(resolveFormAIframe(isolated({ route: '/lp/early-intervention' })).url, 'https://form.jotform.com/262946078314059?formContext=landing&language=en'); + assert.equal(resolveFormAIframe(isolated({ route: '/es/contact', locale: 'en' })).reason, 'locale-not-authorized'); + assert.equal(resolveFormAIframe(isolated({ route: '/es/contact', locale: 'es' })).url, 'https://form.jotform.com/262946078314059?formContext=contact&language=es'); + const campaign = resolveFormAIframe(isolated({ route: '/lp/aba-therapy' })); + assert.equal(campaign.url, 'https://form.jotform.com/262946078314059?formContext=landing&language=en'); + assert.equal(campaign.context, 'landing'); + assert.equal(formModeForRuntime(resolveFormMode('isolated-test', undefined)), 'off'); + assert.equal(formModeForRuntime(resolveFormMode('live', 'true')), 'off'); +}); + +test('Only the seven allowlisted placements can render the adapter, with no helper, tracking, or parent submission code', () => { + const contact = fs.readFileSync('src/components/pages/ContactPage.astro', 'utf8'); + const adapter = fs.readFileSync('src/components/JotformIframe.astro', 'utf8'); + const campaign = fs.readFileSync('src/components/campaign/ConsultationForm.astro', 'utf8'); + const earlyIntervention = fs.readFileSync('src/components/campaign/EarlyInterventionPage.astro', 'utf8'); + + assert.match(contact, /resolveFormAIframe/); + assert.match(contact, /route = lang === 'es' \? '\/es\/contact' : '\/contact'/); + assert.match(contact, /JotformIframe/); + assert.match(campaign, /resolveFormAIframe/); + assert.match(campaign, /JotformIframe/); + assert.match(campaign, /variant="embedded"/); + assert.match(campaign, /\/lp\/aba-therapy/); + assert.match(campaign, /const iframeDescription = 'Use this form to request a conversation\. An AIA team member can explain the next steps for your family\.'/); + assert.match(campaign, /description=\{iframeDescription\}/); + assert.doesNotMatch(campaign, /description=\{intro\}/); + assert.match(earlyIntervention, /resolveFormAIframe/); + assert.match(earlyIntervention, /route: '\/lp\/early-intervention'/); + assert.match(earlyIntervention, /JotformIframe/); + assert.match(adapter, /referrerpolicy="no-referrer"/); + assert.match(adapter, /sandbox="allow-forms allow-scripts allow-same-origin"/); + assert.match(adapter, /const fallbackMarkup = `

/); + assert.match(adapter, /set:html=\{fallbackMarkup\}/); + assert.match(adapter, /escapeHtml\(config\.url\)/); + assert.doesNotMatch(adapter, /<\/h3>\s*(?:\{description[^\n]*\}\s*)?

/); + assert.match(adapter, /target="_blank" rel="noopener noreferrer"/); + assert.match(adapter, /--jotform-frame-height: 60rem/); + assert.match(adapter, /max-width: none;/); + assert.match(adapter, /width: 100%;/); + assert.match(adapter, /\.jotform-iframe--embedded \{\s*--jotform-frame-height: 42rem/); + assert.match(adapter, /\.jotform-iframe__frame \{\s*height: 68rem/); + assert.match(adapter, /\.jotform-iframe--embedded \.jotform-iframe__frame \{\s*height: 50rem/); + assert.doesNotMatch(adapter, / { + const css = fs.readFileSync('integrations/jotform/aia-form-a.css', 'utf8'); + for (const declaration of [ + 'background: #fcf9f5 !important', + 'border: 1px solid #edeef3 !important', + 'background: #ffbd59 !important', + 'border: 2px solid #ffbd59 !important', + 'border-color: #ff914d !important', + 'color: #254080 !important', + ]) assert.match(css, new RegExp(declaration.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + assert.match(css, /:focus-visible/); + assert.doesNotMatch(css, /https?:\/\/|@import|url\(/i); +}); diff --git a/www/tools/phase10-artifact-verify.mjs b/www/tools/phase10-artifact-verify.mjs index 86a40b9..db5412c 100644 --- a/www/tools/phase10-artifact-verify.mjs +++ b/www/tools/phase10-artifact-verify.mjs @@ -39,6 +39,7 @@ function hasTerminalSlash(url) { function unsafeSignals(html) { const signals = []; if (/]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe'); + if (/262946078314059/i.test(html)) signals.push('isolated-test Form A identifier'); if (/]*\bsrc=["'][^"']*(?:jotform|googletagmanager|google-analytics|doubleclick)/i.test(html)) signals.push('active vendor tracking/form script'); if (/data-(?:form|forms)-mode=["']live["']/i.test(html)) signals.push('live form mode'); if (/api\.azinstitute4autism\.com/i.test(html)) signals.push('production API origin'); @@ -98,14 +99,20 @@ export async function verifyArtifact(root, mode = 'staging') { if (secretSignals.length) errors.push(`unsafe artifact signals: ${JSON.stringify(secretSignals)}`); const files = (await fg('**/*', { cwd: root, absolute: true, onlyFiles: true })).sort(); + const formAResourceFiles = []; const digest = createHash('sha256'); for (const file of files) { const relative = path.relative(root, file).replaceAll(path.sep, '/'); + if (/\.(?:html?|js|mjs|css|json|xml|txt)$/i.test(relative)) { + const contents = await fs.readFile(file, 'utf8'); + if (/262946078314059/i.test(contents)) formAResourceFiles.push(relative); + } digest.update(relative); digest.update('\0'); digest.update(createHash('sha256').update(await fs.readFile(file)).digest('hex')); digest.update('\n'); } + if (formAResourceFiles.length) errors.push(`isolated-test Form A identifier appears in artifact: ${formAResourceFiles.join(', ')}`); return { mode, htmlRoutes: htmlFiles.length, diff --git a/www/tools/phase10-artifact.test.mjs b/www/tools/phase10-artifact.test.mjs index b99f166..21a0252 100644 --- a/www/tools/phase10-artifact.test.mjs +++ b/www/tools/phase10-artifact.test.mjs @@ -49,6 +49,14 @@ test('artifact rejects live form mode and production API origin', async () => { assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals'))); }); +test('artifact rejects the isolated-test Form A identifier in any shipped text asset', async () => { + const root = await fixture('staging'); + await fs.writeFile(path.join(root, 'assets.js'), 'https://form.jotform.com/262946078314059?formContext=contact'); + const result = await verifyArtifact(root, 'staging'); + assert.equal(result.passed, false); + assert.ok(result.errors.some((error) => error.includes('isolated-test Form A identifier'))); +}); + test('artifact rejects root-relative URLs with terminal slashes', async () => { const root = await fixture('staging'); await fs.appendFile(path.join(root, 'index.html'), 'Campaign'); diff --git a/www/tools/phase9-nonproduction.test.mjs b/www/tools/phase9-nonproduction.test.mjs index 207d515..27b405a 100644 --- a/www/tools/phase9-nonproduction.test.mjs +++ b/www/tools/phase9-nonproduction.test.mjs @@ -51,7 +51,10 @@ test('form mapping covers nine native instances and five landing journeys', () = assert.equal(logicalFormForRoute('/contact'), 'general'); assert.equal(logicalFormForRoute('/es/client-consultation'), 'service'); assert.equal(logicalFormForRoute('/referrals'), 'referral'); - assert.equal(logicalFormForRoute('/lp/early-intervention'), 'service'); + for (const route of ['/lp/aba-therapy', '/lp/aba-insurance', '/lp/aba-ahcccs', '/lp/aba-private-pay']) { + assert.equal(logicalFormForRoute(route), 'general'); + } + assert.equal(logicalFormForRoute('/lp/early-intervention'), 'general'); assert.equal(logicalFormForRoute('/ar/contact'), undefined); });