2026-07-07 08:24:56 -07:00
|
|
|
import { describe, test, expect } from 'vitest';
|
|
|
|
|
import { ContactForm } from './ContactForm';
|
|
|
|
|
|
|
|
|
|
const toHtml = (ContactForm as any).toHtml;
|
|
|
|
|
|
|
|
|
|
describe('ContactForm.toHtml relay wiring', () => {
|
|
|
|
|
test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
|
|
|
|
|
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '');
|
2026-07-12 14:43:31 -07:00
|
|
|
expect(html).toMatch(/<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
|
|
|
|
expect(html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
2026-07-07 08:24:56 -07:00
|
|
|
expect(html).toContain('method="POST"');
|
|
|
|
|
expect(html).toContain('name="_gotcha"');
|
|
|
|
|
// marker id and action id match
|
2026-07-12 14:43:31 -07:00
|
|
|
const mid = html.match(/id="(F_[0-9a-z]+)"/)![1];
|
2026-07-07 08:24:56 -07:00
|
|
|
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('without recipientEmail: no marker, falls back to formAction', () => {
|
|
|
|
|
const { html } = toHtml({ formAction: '/legacy', fields: [] }, '');
|
|
|
|
|
expect(html).not.toContain('WHP-FORM');
|
|
|
|
|
expect(html).toContain('action="/legacy"');
|
|
|
|
|
expect(html).not.toContain('_gotcha');
|
2026-07-07 08:30:36 -07:00
|
|
|
// Backward-compat: ensure non-relay output is byte-identical (no extra blank lines from honeypot)
|
|
|
|
|
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
|
2026-07-07 08:24:56 -07:00
|
|
|
});
|
2026-07-07 12:22:28 -07:00
|
|
|
|
|
|
|
|
test('without recipientEmail + real fields: byte-clean legacy output (realistic case)', () => {
|
|
|
|
|
// The empty-fields case is NOT byte-identical to the old code (the old
|
|
|
|
|
// template emitted a stray whitespace line when fields was empty; the new
|
|
|
|
|
// ternary drops it). Real forms always have fields, so pin THAT scenario:
|
|
|
|
|
// no marker, no honeypot, and no whitespace-only line between <form> and
|
|
|
|
|
// the first field.
|
|
|
|
|
const fields = [{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true }];
|
|
|
|
|
const { html } = toHtml({ formAction: '/legacy', fields }, '');
|
|
|
|
|
expect(html).not.toContain('WHP-FORM');
|
|
|
|
|
expect(html).not.toContain('_gotcha');
|
|
|
|
|
expect(html).toContain('action="/legacy"');
|
|
|
|
|
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
|
|
|
|
|
// First field renders directly after the form tag (no stray blank line).
|
|
|
|
|
expect(html).toMatch(/<form[^>]*>\n\s*<div/);
|
|
|
|
|
expect(html).toContain('Name');
|
|
|
|
|
});
|
2026-07-07 08:24:56 -07:00
|
|
|
});
|
2026-07-12 13:46:29 -07:00
|
|
|
|
|
|
|
|
describe('ContactForm.toHtml successMessage', () => {
|
|
|
|
|
// The published form-sender relay (form-sender/app/submit.php) delivers
|
|
|
|
|
// success via a full-page 303 redirect to thankYouUrl or a hosted
|
|
|
|
|
// thanks.php page -- there is no in-page JS to reveal an inline success
|
|
|
|
|
// element. So successMessage is emitted as a forward-compatible data
|
|
|
|
|
// attribute for a future AJAX/JS submission mode, not a live DOM element.
|
|
|
|
|
test('with successMessage set: emits it as an escaped data attribute on the form', () => {
|
|
|
|
|
const { html } = toHtml({ successMessage: "We'll be in touch!", fields: [] }, '');
|
|
|
|
|
expect(html).toContain('data-whp-success-message="We'll be in touch!"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('without successMessage: no data attribute emitted', () => {
|
|
|
|
|
const { html } = toHtml({ fields: [] }, '');
|
|
|
|
|
expect(html).not.toContain('data-whp-success-message');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('escapes attribute-breakout attempts in successMessage', () => {
|
|
|
|
|
const { html } = toHtml({ successMessage: 'x" onerror="alert(1)', fields: [] }, '');
|
|
|
|
|
expect(html).not.toContain('onerror="alert(1)"');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-12 14:19:01 -07:00
|
|
|
|
2026-07-12 14:43:31 -07:00
|
|
|
describe('ContactForm.toHtml relay marker deterministic + unique via node id (no Math.random)', () => {
|
|
|
|
|
test('same node id -> identical marker+placeholder ids across two calls', () => {
|
|
|
|
|
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
|
|
|
|
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
|
|
|
|
expect(html1).toBe(html2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('marker id always equals the placeholder id it pairs with', () => {
|
|
|
|
|
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
|
|
|
|
const mid = html.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
|
|
|
|
expect(html).toContain(`action="__WHP_FORM_ACTION__${mid}__"`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('two different node ids -> different fids', () => {
|
|
|
|
|
const { html: html1 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf1');
|
|
|
|
|
const { html: html2 } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '', 'node-cf2');
|
|
|
|
|
const mid1 = html1.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
|
|
|
|
const mid2 = html2.match(/<!--WHP-FORM id="([^"]+)"/)![1];
|
|
|
|
|
expect(mid1).not.toBe(mid2);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-12 14:19:01 -07:00
|
|
|
describe('ContactForm.toHtml accessibility (F2.1)', () => {
|
|
|
|
|
const fields = [
|
|
|
|
|
{ type: 'text' as const, label: 'Name', name: 'name', placeholder: 'Your name', required: true },
|
|
|
|
|
{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: true },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
test('each field label for= matches its control id=, and ids are unique', () => {
|
|
|
|
|
const { html } = toHtml({ fields }, '');
|
|
|
|
|
const labelIds = [...html.matchAll(/<label for="([^"]+)"/g)].map((m) => m[1]);
|
|
|
|
|
const controlIds = [...html.matchAll(/<(?:input|textarea|select) id="([^"]+)"/g)].map((m) => m[1]);
|
|
|
|
|
expect(labelIds.length).toBe(2);
|
|
|
|
|
expect(controlIds.length).toBe(2);
|
|
|
|
|
expect(labelIds).toEqual(controlIds);
|
|
|
|
|
expect(new Set(controlIds).size).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('ids are deterministic across repeated calls with the same fields', () => {
|
|
|
|
|
const { html: html1 } = toHtml({ fields }, '');
|
|
|
|
|
const { html: html2 } = toHtml({ fields }, '');
|
|
|
|
|
const ids1 = [...html1.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
|
|
|
|
|
const ids2 = [...html2.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
|
|
|
|
|
expect(ids1).toEqual(ids2);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-12 18:03:44 -07:00
|
|
|
|
|
|
|
|
describe('ContactForm.toHtml field type attribute sanitization', () => {
|
|
|
|
|
test('malicious field.type cannot break out of the input attribute; falls back to type="text"', () => {
|
|
|
|
|
const fields = [{ type: 'text"><img src=x onerror=alert(1)>' as any, label: 'Name', name: 'name', placeholder: 'Your name', required: false }];
|
|
|
|
|
const { html } = toHtml({ fields }, '');
|
|
|
|
|
expect(html).not.toContain('<img');
|
|
|
|
|
expect(html).not.toContain('onerror=');
|
|
|
|
|
expect(html).toContain('type="text"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('legitimate email field type still passes through unchanged', () => {
|
|
|
|
|
const fields = [{ type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: false }];
|
|
|
|
|
const { html } = toHtml({ fields }, '');
|
|
|
|
|
expect(html).toContain('type="email"');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-07-14 06:44:21 -07:00
|
|
|
|
|
|
|
|
// F1: the field editor (FormStylePanel) can now create fields of every type
|
|
|
|
|
// in sanitizeInputType's allowlist, plus textarea/select. Verify each
|
|
|
|
|
// renders with the right control, label/for association, and required flag.
|
|
|
|
|
describe('ContactForm.toHtml renders every configured field type/label/required', () => {
|
|
|
|
|
const cases: { type: string; tag: string }[] = [
|
|
|
|
|
{ type: 'text', tag: 'input' },
|
|
|
|
|
{ type: 'email', tag: 'input' },
|
|
|
|
|
{ type: 'tel', tag: 'input' },
|
|
|
|
|
{ type: 'number', tag: 'input' },
|
|
|
|
|
{ type: 'password', tag: 'input' },
|
|
|
|
|
{ type: 'url', tag: 'input' },
|
|
|
|
|
{ type: 'search', tag: 'input' },
|
|
|
|
|
{ type: 'date', tag: 'input' },
|
|
|
|
|
{ type: 'checkbox', tag: 'input' },
|
|
|
|
|
{ type: 'radio', tag: 'input' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
test.each(cases)('type=$type renders a sanitized <$tag type="$type"> with label + for/id wiring', ({ type, tag }) => {
|
|
|
|
|
const fields = [{ type: type as any, label: `Field ${type}`, name: `f_${type}`, placeholder: '', required: true }];
|
|
|
|
|
const { html } = toHtml({ fields }, '');
|
|
|
|
|
expect(html).toContain(`<${tag}`);
|
|
|
|
|
expect(html).toContain(`type="${type}"`);
|
|
|
|
|
expect(html).toContain(`Field ${type}`);
|
|
|
|
|
// required renders the input attribute AND the visual asterisk
|
|
|
|
|
expect(html).toMatch(/ required/);
|
|
|
|
|
expect(html).toContain('*</span>');
|
|
|
|
|
const labelFor = html.match(/<label for="([^"]+)"/)![1];
|
|
|
|
|
expect(html).toContain(`id="${labelFor}"`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('type=textarea renders a <textarea>, not an <input>', () => {
|
|
|
|
|
const fields = [{ type: 'textarea' as const, label: 'Message', name: 'message', placeholder: '', required: false }];
|
|
|
|
|
const { html } = toHtml({ fields }, '');
|
|
|
|
|
expect(html).toMatch(/<textarea[^>]*name="message"/);
|
|
|
|
|
expect(html).not.toMatch(/<input[^>]*name="message"/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('type=select renders a <select> with escaped <option> values from field.options', () => {
|
|
|
|
|
const fields = [{ type: 'select' as const, label: 'Plan', name: 'plan', placeholder: 'Choose one', required: false, options: ['Basic', 'Pro', '"><script>alert(1)</script>'] }];
|
|
|
|
|
const { html } = toHtml({ fields }, '');
|
|
|
|
|
expect(html).toMatch(/<select[^>]*name="plan"/);
|
|
|
|
|
expect(html).toContain('<option value="Basic">Basic</option>');
|
|
|
|
|
expect(html).toContain('<option value="Pro">Pro</option>');
|
|
|
|
|
expect(html).not.toContain('<script>alert(1)</script>');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a non-required field omits both the required attribute and the asterisk', () => {
|
|
|
|
|
const fields = [{ type: 'text' as const, label: 'Nickname', name: 'nickname', placeholder: '', required: false }];
|
|
|
|
|
const { html } = toHtml({ fields }, '');
|
|
|
|
|
expect(html).not.toMatch(/ required/);
|
|
|
|
|
expect(html).not.toContain('*</span>');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Box-model / animation / visibility rollout (common enh-batch pattern):
|
|
|
|
|
// these are top-level props consumed generically by the export's
|
|
|
|
|
// buildDataAttrs() -- this just confirms the defaults are present on
|
|
|
|
|
// craft.props so the panel controls render and the props survive save/load.
|
|
|
|
|
describe('ContactForm.craft.props includes animation/visibility defaults', () => {
|
|
|
|
|
test('has blank/false defaults for animation, animationDelay, hideOnDesktop/Tablet/Mobile', () => {
|
|
|
|
|
expect(ContactForm.craft!.props).toMatchObject({
|
|
|
|
|
animation: '',
|
|
|
|
|
animationDelay: '',
|
|
|
|
|
hideOnDesktop: false,
|
|
|
|
|
hideOnTablet: false,
|
|
|
|
|
hideOnMobile: false,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-10 15:45:05 -07:00
|
|
|
|
|
|
|
|
/* ---------- Webhook destination (Task 10) ----------
|
|
|
|
|
This file builds props inline rather than spreading a shared object, so
|
|
|
|
|
`defaultProps` is introduced here for the destination cases only; every
|
|
|
|
|
pre-existing test above is untouched. */
|
|
|
|
|
const defaultProps = { fields: [] as any[], formAction: '#' };
|
|
|
|
|
|
|
|
|
|
describe('ContactForm.craft.props includes the destination defaults', () => {
|
|
|
|
|
// The trap this pins: FormStylePanel renders each destination control behind
|
|
|
|
|
// `nodeProps.X !== undefined`, so a prop omitted from these defaults yields an
|
|
|
|
|
// invisible control and the whole feature looks like it does nothing.
|
|
|
|
|
test('destinationType/webhookUrl/webhookSecretId/webhookAuthMode are all present', () => {
|
|
|
|
|
expect(ContactForm.craft!.props).toMatchObject({
|
|
|
|
|
destinationType: 'email',
|
|
|
|
|
webhookUrl: '',
|
|
|
|
|
webhookSecretId: '',
|
|
|
|
|
webhookAuthMode: 'signature',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('no craft prop holds a raw secret -- only an id', () => {
|
|
|
|
|
const keys = Object.keys(ContactForm.craft!.props as object);
|
|
|
|
|
expect(keys).toContain('webhookSecretId');
|
|
|
|
|
expect(keys.filter((k) => /secret/i.test(k))).toEqual(['webhookSecretId']);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('ContactForm.toHtml destination marker', () => {
|
|
|
|
|
test('email destination emits the legacy marker unchanged', () => {
|
|
|
|
|
const out = toHtml(
|
|
|
|
|
{ ...defaultProps, destinationType: 'email', recipientEmail: 'a@example.com', thankYouUrl: '' }, '');
|
|
|
|
|
expect(out.html).toContain('<!--WHP-FORM');
|
|
|
|
|
expect(out.html).toContain('recipient="a@example.com"');
|
|
|
|
|
expect(out.html).not.toContain('type="webhook"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('BYTE-IDENTITY: an email destination emits exactly what a pre-feature form emits', () => {
|
|
|
|
|
// The guarantee every already-published site depends on: a marker with no
|
|
|
|
|
// `type` still provisions an email endpoint, so its bytes must not drift by
|
2026-08-10 15:57:38 -07:00
|
|
|
// so much as a space.
|
|
|
|
|
//
|
|
|
|
|
// FROZEN LITERAL, not a self-comparison. Comparing two head-revision outputs
|
|
|
|
|
// to each other only catches a drift that affects ONE of them -- a uniform
|
|
|
|
|
// change passes it. This string was captured from `071f3447` (the revision
|
|
|
|
|
// deployed to production before this feature) and is the actual reference:
|
|
|
|
|
// if it has to be edited, every already-published site's forms have changed
|
|
|
|
|
// shape and that is the thing to stop, not the test.
|
|
|
|
|
const FROZEN_LEGACY_MARKER =
|
|
|
|
|
'<!--WHP-FORM id="F_3hodg" recipient="a@example.com" thankyou="/thx"-->';
|
|
|
|
|
|
2026-08-10 15:45:05 -07:00
|
|
|
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
2026-08-10 15:57:38 -07:00
|
|
|
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
|
|
|
|
|
|
|
|
|
|
// ...and the new props, set to their defaults, change nothing about it.
|
2026-08-10 15:45:05 -07:00
|
|
|
const explicit = toHtml(
|
|
|
|
|
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
|
|
|
|
|
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
|
|
|
|
expect(explicit.html).toBe(legacy.html);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('webhook destination emits type, url, secret id and auth mode', () => {
|
|
|
|
|
const out = toHtml(
|
|
|
|
|
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
|
|
|
|
webhookSecretId: 'sec-1', webhookAuthMode: 'bearer', recipientEmail: 'fb@example.com' }, '');
|
|
|
|
|
expect(out.html).toContain('type="webhook"');
|
|
|
|
|
expect(out.html).toContain('url="https://hooks.example.com/x"');
|
|
|
|
|
expect(out.html).toContain('secret="sec-1"');
|
|
|
|
|
expect(out.html).toContain('authmode="bearer"');
|
|
|
|
|
expect(out.html).toContain('recipient="fb@example.com"');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a raw secret value is never emitted, only its id', () => {
|
|
|
|
|
const out = toHtml(
|
|
|
|
|
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x',
|
|
|
|
|
webhookSecretId: 'sec-1', webhookSecret: 'SUPERSECRET' } as any, '');
|
|
|
|
|
// Non-vacuous: the marker IS emitted (so there is something that could have
|
|
|
|
|
// carried the secret) and carries the id, but not the value.
|
|
|
|
|
expect(out.html).toContain('secret="sec-1"');
|
|
|
|
|
expect(out.html).not.toContain('SUPERSECRET');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a webhook form with no fallback email still emits a marker (never a bare formAction)', () => {
|
|
|
|
|
const out = toHtml(
|
|
|
|
|
{ ...defaultProps, destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' }, '');
|
|
|
|
|
expect(out.html).toContain('type="webhook"');
|
|
|
|
|
expect(out.html).toContain('recipient=""');
|
|
|
|
|
expect(out.html).toMatch(/action="__WHP_FORM_ACTION__F_[0-9a-z]+__"/);
|
|
|
|
|
expect(out.html).toContain('name="_gotcha"');
|
|
|
|
|
});
|
|
|
|
|
});
|