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>
80 lines
3.2 KiB
TypeScript
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();
|
|
});
|
|
});
|