From b14c3351f177e4058c8af13b8d285da4e3b1e9d8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:39:52 +0200 Subject: [PATCH] Validate webhook test input by owner in CLI --- README.md | 7 ++ skills/polymorfa-webhooks/SKILL.md | 4 ++ src/commands/webhook/test.ts | 27 ++++++- test/commands/developer-webhooks.test.ts | 91 ++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 47eaf5f3..349fe3db 100644 --- a/README.md +++ b/README.md @@ -1527,6 +1527,13 @@ polymorfa webhook delivery attempt list delivery_123 \ --project project_123 ``` +`webhook test` requires `--event`. Team tests accept that event type only. +Project tests can also read a JSON object with `body` and `sessionId` from +`--input`; supply both fields together. The CLI rejects a team body or an +incomplete project body before requesting a delivery. The `body` must be an +exact base64-encoded native event for an HMS-enabled Number in the same +project. See the [webhook guide](https://docs.polymorfa.com/api/webhooks#send-a-test-delivery). + Webhook creation and secret rotation return a signing secret once. JSON and noninteractive runs require `--secret-out`; the CLI preflights a new path and writes it atomically with mode `0600`. Interactive runs can reveal the secret diff --git a/skills/polymorfa-webhooks/SKILL.md b/skills/polymorfa-webhooks/SKILL.md index 62c8fa19..ae339331 100644 --- a/skills/polymorfa-webhooks/SKILL.md +++ b/skills/polymorfa-webhooks/SKILL.md @@ -12,6 +12,10 @@ Keep these actions distinct: - `webhook delivery retry` creates a new immutable attempt on the same delivery. - `listen` receives the project event stream and may forward locally; it does not create webhook history or alter a delivery. +For `webhook test`, pass `--event`. Team tests accept only that event type. +Project tests may also use `--input` with both `body` and `sessionId` for an +exact native event from an HMS-enabled Number in the same project. + ## Inspect before changing 1. Resolve `--owner` and project context explicitly. diff --git a/src/commands/webhook/test.ts b/src/commands/webhook/test.ts index 0cecbf90..5230b92a 100644 --- a/src/commands/webhook/test.ts +++ b/src/commands/webhook/test.ts @@ -1,4 +1,5 @@ import {Args, Flags} from '@oclif/core' +import {PolymorfaValidationError} from '@polymorfa/sdk' import {readJsonInput} from '../../api/request.js' import {BaseCommand} from '../../base-command.js' @@ -19,7 +20,7 @@ export default class WebhookTest extends BaseCommand { 'idempotency-key': idempotencyFlag, input: Flags.string({ description: - 'Read an optional TestWebhookInput JSON object from a file or stdin (-).', + 'Read optional test input from a file or stdin (-). Project tests accept body and sessionId together; team tests accept only eventType.', }), } @@ -33,6 +34,7 @@ export default class WebhookTest extends BaseCommand { ...record(await readJsonInput(flags.input), 'Webhook test input'), eventType: flags.event, } + validateTestInput(session.context.owner, input) const response = await session.client.webhooks.test(args.webhook, input, { ...session.requestOptions, idempotencyKey: idempotencyKey(flags['idempotency-key']), @@ -48,3 +50,26 @@ export default class WebhookTest extends BaseCommand { this.writeValue(output, `Created test delivery ${deliveryId}.`, flags) } } + +function validateTestInput( + owner: 'organization' | 'project', + input: Record, +): void { + const allowed = + owner === 'organization' + ? new Set(['eventType']) + : new Set(['eventType', 'body', 'sessionId']) + const unexpected = Object.keys(input).find((key) => !allowed.has(key)) + if (unexpected !== undefined) { + throw new PolymorfaValidationError( + `${owner === 'organization' ? 'Team' : 'Project'} webhook tests do not accept ${unexpected}.`, + {code: 'invalid_parameter'}, + ) + } + if (owner === 'project' && 'body' in input !== 'sessionId' in input) { + throw new PolymorfaValidationError( + 'Project webhook test bodies require sessionId, and sessionId requires a body.', + {code: 'invalid_parameter'}, + ) + } +} diff --git a/test/commands/developer-webhooks.test.ts b/test/commands/developer-webhooks.test.ts index aece380c..35c368ac 100644 --- a/test/commands/developer-webhooks.test.ts +++ b/test/commands/developer-webhooks.test.ts @@ -395,6 +395,97 @@ describe('developer webhook commands', () => { }) }) + test('rejects a supplied team webhook body before calling the SDK', async () => { + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-command-')) + directories.push(directory) + const input = join(directory, 'test.json') + await writeFile( + input, + JSON.stringify({ + body: { + encoding: 'base64', + contentType: 'application/json', + data: 'e30=', + }, + sessionId: 'session_1', + }), + ) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const error = await developerEnvironment(() => + WebhookTest.run([ + 'wh_1', + '--owner', + 'organization', + '--event', + 'customer.created', + '--input', + input, + '--json', + ]), + ).catch((caught: unknown) => caught) + + expect(exitCode(error)).toBe(2) + expect(state.calls.some(({method}) => method === 'webhooks.test')).toBe( + false, + ) + }) + + test('requires a project session ID with a supplied test body', async () => { + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-command-')) + directories.push(directory) + const input = join(directory, 'test.json') + const body = { + encoding: 'base64', + contentType: 'application/json', + data: 'e30=', + } + await writeFile(input, JSON.stringify({body})) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const error = await developerEnvironment(() => + WebhookTest.run([ + 'wh_1', + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--event', + 'message.received', + '--input', + input, + '--json', + ]), + ).catch((caught: unknown) => caught) + + expect(exitCode(error)).toBe(2) + expect(state.calls.some(({method}) => method === 'webhooks.test')).toBe( + false, + ) + + await writeFile(input, JSON.stringify({body, sessionId: 'session_1'})) + stdout() + await developerEnvironment(() => + WebhookTest.run([ + 'wh_1', + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--event', + 'message.received', + '--input', + input, + '--json', + ]), + ) + expect(findCall('webhooks.test').args[1]).toEqual({ + body, + eventType: 'message.received', + sessionId: 'session_1', + }) + }) + test('rotates with confirmation, a bounded overlap, and no secret in JSON', async () => { const directory = await mkdtemp(join(tmpdir(), 'polymorfa-command-')) directories.push(directory)