Files
site-builder/craft/src/utils/form-webhook-secret.test.ts
T
shadowdaoandClaude Opus 5 d1c57db967 feat(site-builder): webhook destination controls on the contact form
ContactForm gains four craft props (destinationType, webhookUrl,
webhookSecretId, webhookAuthMode), all present in craft.props defaults so
FormStylePanel's `nodeProps.X !== undefined` gates actually render their
controls. The controls live in FormStylePanel (RightPanel renders only
GuidedStyles, so related.settings would be dead UI).

relayFormWiring widens the marker to optionally carry type/url/secret/
authmode BETWEEN `id` and `recipient`, which is where FormRelayRewrite.php's
parser looks. A marker with no type is byte-identical to what shipped before
-- pinned by a test that diffs an explicit-email form against one with no
destination props at all, since every already-published site depends on that
shape continuing to provision an email endpoint.

Every optional attribute value goes through one escaping site (markerAttr ->
escapeAttr); type and authmode are additionally allowlisted, so a case-drifted
"Bearer" reaches the relay as the exact literal it compares against instead of
being silently downgraded to unsigned.

The raw shared secret is never a prop: it is held in WebhookSecretField's
local state, POSTed to /api/form-webhook-secret.php on blur, and only the
returned opaque id is persisted. The field is write-only (set / replace /
remove, never view) because the endpoint has no read route, and the endpoint's
429 cap message is surfaced verbatim so a customer can act on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:45:05 -07:00

80 lines
3.2 KiB
TypeScript

import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { storeWebhookSecret, webhookSecretEndpoint } from './form-webhook-secret';
const CFG = { apiUrl: '/api/site-builder.php', csrfToken: 'tok-123', siteId: 42 };
beforeEach(() => {
(window as any).WHP_CONFIG = { ...CFG };
});
afterEach(() => {
delete (window as any).WHP_CONFIG;
vi.unstubAllGlobals();
});
describe('webhookSecretEndpoint', () => {
test('derives the sibling endpoint from the configured API url', () => {
expect(webhookSecretEndpoint('/api/site-builder.php')).toBe('/api/form-webhook-secret.php');
expect(webhookSecretEndpoint('https://panel.example.com/api/site-builder'))
.toBe('https://panel.example.com/api/form-webhook-secret.php');
});
test('falls back to the absolute path when there is no configured url', () => {
expect(webhookSecretEndpoint(undefined)).toBe('/api/form-webhook-secret.php');
expect(webhookSecretEndpoint('')).toBe('/api/form-webhook-secret.php');
});
});
describe('storeWebhookSecret', () => {
test('POSTs the secret with the CSRF header and returns only the id', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true, status: 200,
json: async () => ({ success: true, secret_id: 'whs_42_abcdef0123456789' }),
});
vi.stubGlobal('fetch', fetchMock);
const result = await storeWebhookSecret('SUPERSECRET');
expect(result).toEqual({ ok: true, secretId: 'whs_42_abcdef0123456789' });
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe('/api/form-webhook-secret.php');
// POST only -- the endpoint has no read route by design.
expect(opts.method).toBe('POST');
expect(opts.headers['X-CSRF-Token']).toBe('tok-123');
expect(JSON.parse(opts.body)).toEqual({ site_id: 42, secret: 'SUPERSECRET' });
});
test('passes the 429 cap message through so a customer can act on it', 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 result = await storeWebhookSecret('s');
expect(result.ok).toBe(false);
expect(result.error).toContain('Too many webhook secrets stored for this site');
});
test('a success:false body is a failure even with HTTP 200', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, status: 200, json: async () => ({ success: false, error: 'Invalid CSRF token' }),
}));
expect(await storeWebhookSecret('s')).toEqual({ ok: false, error: 'Invalid CSRF token' });
});
test('a network failure resolves with an error rather than throwing', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline')));
const result = await storeWebhookSecret('s');
expect(result.ok).toBe(false);
expect(result.secretId).toBeUndefined();
});
test('standalone mode (no WHP_CONFIG) never posts anywhere', async () => {
delete (window as any).WHP_CONFIG;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const result = await storeWebhookSecret('s');
expect(result.ok).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
});