Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions skills/polymorfa-webhooks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 26 additions & 1 deletion src/commands/webhook/test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.',
}),
}

Expand All @@ -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']),
Expand All @@ -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<string, unknown>,
): 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'},
)
}
}
91 changes: 91 additions & 0 deletions test/commands/developer-webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down