fix(site-builder): review findings on the webhook destination controls
I-1: WebhookSecretField now mounts with key={selectedId}. GuidedStyles renders
FormStylePanel with no key, so a selection change re-rendered rather than
remounted it and React kept `draft`/`status`. Since a failed store deliberately
retains the draft, clicking a second contact form showed node A's raw secret in
node B's field, and the next blur assigned the returned secret_id to the wrong
form (burning a slot against the per-site cap). The status banner leaked the
same way. Both pinned by the reviewer's repro sequence.
I-2: the button is "Clear", not "Remove", and says so -- nothing deletes a
stored key file, so the 50-per-site cap counts stores-ever. Labelling it Remove
told the customer they had reclaimed a slot right up until the 429 that said
otherwise. Actually deleting the file is Task 8's territory.
M-1: inline warning when the webhook URL is blank or not absolute https. The
publish step does refuse these, but into an error_log the customer never reads.
Warning only -- isHttpsWebhookUrl never edits the value or blocks the publish,
since either would trade a loud server-side refusal for a silently inert form.
M-2: the BYTE-IDENTITY test now compares against a literal marker captured by
executing the emitter at 071f3447, not against another head-revision output. The
self-comparison could only catch a drift affecting one side; a uniform one
passed it. Verified: a uniform `<!-- WHP-FORM` drift now fails this assertion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -235,15 +235,25 @@ describe('ContactForm.toHtml destination marker', () => {
|
||||
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
|
||||
// so much as a space. Compared against a form that has no destination props
|
||||
// at all (a page saved before this feature existed).
|
||||
// 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"-->';
|
||||
|
||||
const legacy = toHtml({ ...defaultProps, recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||
expect(legacy.html.startsWith(`${FROZEN_LEGACY_MARKER}<form `)).toBe(true);
|
||||
|
||||
// ...and the new props, set to their defaults, change nothing about it.
|
||||
const explicit = toHtml(
|
||||
{ ...defaultProps, destinationType: 'email', webhookUrl: '', webhookSecretId: '',
|
||||
webhookAuthMode: 'signature', recipientEmail: 'a@example.com', thankYouUrl: '/thx' }, '', 'n1');
|
||||
expect(explicit.html).toBe(legacy.html);
|
||||
// ...and the marker itself is the exact narrow shape, anchored.
|
||||
expect(legacy.html).toMatch(/^<!--WHP-FORM id="F_[0-9a-z]+" recipient="a@example\.com" thankyou="\/thx"--><form /);
|
||||
});
|
||||
|
||||
test('webhook destination emits type, url, secret id and auth mode', () => {
|
||||
|
||||
@@ -61,12 +61,40 @@ const SPACING_SIDE_KEYS: { side: 'top' | 'right' | 'bottom' | 'left'; suffix: 'T
|
||||
|
||||
const hintStyle: React.CSSProperties = { fontSize: 10, color: '#71717a', margin: '4px 0 0' };
|
||||
|
||||
/**
|
||||
* Does this look like a webhook URL the publish step will accept?
|
||||
*
|
||||
* Mirrors the shape `FormRelayProvisioner::upsertWebhookToken()` enforces
|
||||
* (absolute https, a host, no whitespace/control characters) closely enough to
|
||||
* warn in the panel. It is a HINT, not a gate -- the server-side check is the
|
||||
* real one, and this deliberately never edits or blocks the value.
|
||||
*/
|
||||
export function isHttpsWebhookUrl(value: unknown): boolean {
|
||||
const v = typeof value === 'string' ? value.trim() : '';
|
||||
if (v === '' || /[\s\x00-\x1F\x7F]/.test(v)) return false;
|
||||
try {
|
||||
const u = new URL(v);
|
||||
return u.protocol === 'https:' && u.hostname !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Webhook shared secret: WRITE-ONLY field ----------
|
||||
The raw secret lives in this component's local state and nowhere else. On
|
||||
blur it is POSTed to the panel endpoint, which returns an opaque id; only
|
||||
that id is handed to `onStored` (and thence to a craft prop). The field is
|
||||
then cleared, because there is no read route and nothing to show back --
|
||||
the UI offers set / replace / remove, never "view". */
|
||||
the UI offers set / replace / clear, never "view".
|
||||
|
||||
MOUNT THIS WITH `key={selectedId}`. GuidedStyles renders <FormStylePanel>
|
||||
with no key, so a selection change re-renders this component rather than
|
||||
remounting it, and React keeps `draft`/`status`. A failed store deliberately
|
||||
RETAINS the draft (so a 429 or a network blip doesn't make the customer
|
||||
retype a pasted key) -- which means without the key, clicking a second
|
||||
contact form shows node A's raw secret in node B's field, and the next blur
|
||||
assigns the returned id to the wrong form and burns a slot against the
|
||||
per-site cap. The `status` banner leaks the same way. */
|
||||
export const WebhookSecretField: React.FC<{
|
||||
secretId: string;
|
||||
onStored: (secretId: string) => void;
|
||||
@@ -109,16 +137,26 @@ export const WebhookSecretField: React.FC<{
|
||||
style={inputStyle}
|
||||
/>
|
||||
{secretId && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
|
||||
<span data-testid="webhook-secret-status" style={{ fontSize: 10, color: '#22c55e' }}>Secret stored</span>
|
||||
<button
|
||||
data-testid="webhook-secret-remove"
|
||||
onClick={onRemoved}
|
||||
style={{ ...moveBtnStyle, flex: 'none', padding: '3px 8px' }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
|
||||
<span data-testid="webhook-secret-status" style={{ fontSize: 10, color: '#22c55e' }}>Secret stored</span>
|
||||
{/* "Clear", not "Remove": this only drops the form's reference to the
|
||||
key. The stored key file stays on the server and still counts
|
||||
toward the per-site cap -- nothing deletes one, so a button
|
||||
labelled Remove would be telling the customer they had reclaimed
|
||||
a slot right up until the 429 that says otherwise. */}
|
||||
<button
|
||||
data-testid="webhook-secret-remove"
|
||||
onClick={onRemoved}
|
||||
style={{ ...moveBtnStyle, flex: 'none', padding: '3px 8px' }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<p style={hintStyle}>
|
||||
Clearing stops this form using the secret; the stored key stays on the server.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'saving' && <p style={hintStyle}>Storing…</p>}
|
||||
{status === 'saved' && <p data-testid="webhook-secret-saved" style={{ ...hintStyle, color: '#22c55e' }}>Secret stored.</p>}
|
||||
@@ -268,6 +306,19 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
placeholder="https://hooks.example.com/..."
|
||||
style={inputStyle}
|
||||
/>
|
||||
{/* The publish step DOES refuse a blank/non-https URL -- loudly, but
|
||||
into an error_log the customer never reads, leaving them with a
|
||||
form that just doesn't work. Warn here instead. Deliberately a
|
||||
warning only: not blanking the value and not blocking the
|
||||
publish, since either would trade a loud server-side refusal for
|
||||
a silently inert form. */}
|
||||
{!isHttpsWebhookUrl(nodeProps.webhookUrl) && (
|
||||
<p data-testid="webhook-url-warning" style={{ ...hintStyle, color: '#fbbf24' }}>
|
||||
{nodeProps.webhookUrl
|
||||
? 'This must be an absolute https:// URL — submissions to this form won\'t be delivered until it is.'
|
||||
: 'Enter the https:// URL to POST submissions to — this form won\'t deliver anything until you do.'}
|
||||
</p>
|
||||
)}
|
||||
<p style={hintStyle}>
|
||||
Must be an absolute <strong>https://</strong> URL. Each submission is POSTed as JSON;
|
||||
failures are retried, then emailed to the fallback address below.
|
||||
@@ -286,6 +337,10 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
</select>
|
||||
</div>
|
||||
<WebhookSecretField
|
||||
/* Remounts on every selection change, so a retained draft (and its
|
||||
status banner) can never follow the customer to another form --
|
||||
see the comment on WebhookSecretField. */
|
||||
key={selectedId}
|
||||
secretId={nodeProps.webhookSecretId || ''}
|
||||
onStored={(id) => setProp('webhookSecretId', id)}
|
||||
onRemoved={() => setProp('webhookSecretId', '')}
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { setProp: setPropSpy } }),
|
||||
}));
|
||||
|
||||
import { FormStylePanel } from './FormStylePanel';
|
||||
import { FormStylePanel, isHttpsWebhookUrl } from './FormStylePanel';
|
||||
|
||||
const RAW_SECRET = 'hunter2-SUPER-SECRET-VALUE';
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('FormStylePanel webhook secret field is WRITE-ONLY', () => {
|
||||
expect(JSON.stringify(lastProps)).not.toContain(RAW_SECRET);
|
||||
});
|
||||
|
||||
test('the stored secret is never shown -- only its state, with a Remove action', () => {
|
||||
test('the stored secret is never shown -- only its state, with a Clear action', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-secret-status')!.textContent).toContain('Secret stored');
|
||||
@@ -219,4 +219,105 @@ describe('FormStylePanel webhook secret field is WRITE-ONLY', () => {
|
||||
await blur(testid('webhook-secret-input')!);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('the Clear action says what it does: the stored key stays on the server', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook', webhookSecretId: 'whs_42_deadbeefdeadbeef' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
// "Remove" would be a lie: nothing deletes the key file, and it keeps
|
||||
// counting toward the per-site cap the customer eventually 429s against.
|
||||
expect(testid('webhook-secret-remove')!.textContent).toBe('Clear');
|
||||
expect(container.textContent).toContain('the stored key stays on the server');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FormStylePanel: a retained secret draft never follows the selection to another form', () => {
|
||||
/* GuidedStyles renders <FormStylePanel> with no key, so a selection change
|
||||
re-renders rather than remounts. A failed store deliberately KEEPS the
|
||||
draft, so without a remount the next form's field would open holding the
|
||||
previous form's raw secret -- and the next blur would POST it and assign
|
||||
the returned id to the wrong node. */
|
||||
test('after a failed store on node A, selecting node B shows an empty field and no status', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false, status: 429,
|
||||
json: async () => ({ success: false, error: 'Too many webhook secrets stored for this site — please contact support.' }),
|
||||
}));
|
||||
|
||||
const nodeA = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://a.example/x' });
|
||||
lastProps = nodeA;
|
||||
render(<FormStylePanel selectedId="nodeA" nodeProps={nodeA} />);
|
||||
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||
setValue(input, RAW_SECRET);
|
||||
await blur(input);
|
||||
|
||||
// Precondition: the draft really was retained on node A (otherwise this
|
||||
// test would pass for the wrong reason).
|
||||
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe(RAW_SECRET);
|
||||
expect(testid('webhook-secret-error')).toBeTruthy();
|
||||
|
||||
const nodeB = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://b.example/y' });
|
||||
lastProps = nodeB;
|
||||
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
|
||||
|
||||
expect((testid('webhook-secret-input') as HTMLInputElement).value).toBe('');
|
||||
expect(container.textContent).not.toContain(RAW_SECRET);
|
||||
expect(testid('webhook-secret-error')).toBeNull();
|
||||
});
|
||||
|
||||
test('a "Secret stored." banner does not follow the selection either', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ success: true, secret_id: 'whs_42_deadbeefdeadbeef' }),
|
||||
}));
|
||||
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="nodeA" nodeProps={lastProps} />);
|
||||
const input = testid('webhook-secret-input') as HTMLInputElement;
|
||||
setValue(input, RAW_SECRET);
|
||||
await blur(input);
|
||||
expect(testid('webhook-secret-saved')).toBeTruthy();
|
||||
|
||||
const nodeB = contactFormProps({ destinationType: 'webhook' });
|
||||
lastProps = nodeB;
|
||||
rerender(<FormStylePanel selectedId="nodeB" nodeProps={nodeB} />);
|
||||
expect(testid('webhook-secret-saved')).toBeNull();
|
||||
expect(testid('webhook-secret-status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHttpsWebhookUrl / the inline URL warning', () => {
|
||||
test.each([
|
||||
['https://hooks.example.com/x', true],
|
||||
['https://hooks.example.com/x?a=1&b=2', true],
|
||||
['http://hooks.example.com/x', false],
|
||||
['hooks.example.com/x', false],
|
||||
['/relative/path', false],
|
||||
['', false],
|
||||
[' ', false],
|
||||
['https://hooks.example.com/x\nHost: evil', false],
|
||||
['javascript:alert(1)', false],
|
||||
])('%s -> %s', (value, expected) => {
|
||||
expect(isHttpsWebhookUrl(value)).toBe(expected);
|
||||
});
|
||||
|
||||
test('a blank URL warns that nothing will be delivered', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-url-warning')!.textContent).toContain("won't deliver anything");
|
||||
});
|
||||
|
||||
test('a non-https URL warns without altering the value', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'http://hooks.example.com/x' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-url-warning')!.textContent).toContain('absolute https:// URL');
|
||||
// Warning only -- the panel must not blank or rewrite the prop, which would
|
||||
// turn the publish step's loud refusal into a silently inert form.
|
||||
expect(lastProps.webhookUrl).toBe('http://hooks.example.com/x');
|
||||
expect((testid('webhook-url') as HTMLInputElement).value).toBe('http://hooks.example.com/x');
|
||||
});
|
||||
|
||||
test('a valid https URL shows no warning', () => {
|
||||
lastProps = contactFormProps({ destinationType: 'webhook', webhookUrl: 'https://hooks.example.com/x' });
|
||||
render(<FormStylePanel selectedId="n1" nodeProps={lastProps} />);
|
||||
expect(testid('webhook-url-warning')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user