Files
simstudioai--sim/apps/sim/lib/webhooks/providers/twilio.test.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

277 lines
9.4 KiB
TypeScript

/**
* @vitest-environment node
*/
import crypto from 'crypto'
import { createMockRequest } from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { twilioHandler } from '@/lib/webhooks/providers/twilio'
/** Twilio canonical signature: HMAC-SHA1(authToken, url + sorted(key+value)) base64. */
function signTwilio(authToken: string, url: string, params: Record<string, string>): string {
const data = Object.keys(params)
.sort()
.reduce((acc, key) => acc + key + params[key], url)
return crypto.createHmac('sha1', authToken).update(Buffer.from(data, 'utf8')).digest('base64')
}
describe('twilioHandler', () => {
describe('verifyAuth', () => {
const authToken = 'test-auth-token'
const url = 'http://localhost:3000/api/test'
const params = { From: '+15551234567', To: '+15557654321', Body: 'hello', MessageSid: 'SM123' }
const rawBody = new URLSearchParams(params).toString()
const signature = signTwilio(authToken, url, params)
it('rejects a forged request with no signature header', async () => {
const request = createMockRequest('POST', undefined, {
'content-type': 'application/x-www-form-urlencoded',
})
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('rejects a request with an invalid signature', async () => {
const request = createMockRequest('POST', undefined, {
'x-twilio-signature': 'not-the-real-signature',
})
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
it('accepts a request with a valid signature', async () => {
const request = createMockRequest('POST', undefined, {
'x-twilio-signature': signature,
})
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('skips verification when no auth token is configured (optional-secret convention)', async () => {
const request = createMockRequest('POST', undefined, {})
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: {},
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('reconstructs the public URL from forwarding headers when validating', async () => {
const publicUrl = 'https://sim.ai/api/webhooks/trigger/twilio-sms-abc123'
const fwdSignature = signTwilio(authToken, publicUrl, params)
const request = createMockRequest(
'POST',
undefined,
{
'x-twilio-signature': fwdSignature,
'x-forwarded-proto': 'https',
'x-forwarded-host': 'sim.ai',
},
'http://internal-host:3000/api/webhooks/trigger/twilio-sms-abc123'
)
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res).toBeNull()
})
it('rejects a forged body even with a forwarded host (no valid token)', async () => {
const request = createMockRequest(
'POST',
undefined,
{
'x-twilio-signature': signTwilio('attacker-guess', url, params),
'x-forwarded-host': 'sim.ai',
},
url
)
const res = await twilioHandler.verifyAuth!({
request: request as any,
rawBody,
requestId: 'r1',
providerConfig: { authToken },
webhook: {},
workflow: {},
})
expect(res?.status).toBe(401)
})
})
describe('extractIdempotencyId', () => {
it('prefers MessageSid, falls back to CallSid', () => {
expect(
twilioHandler.extractIdempotencyId!({ MessageSid: 'SM1', SmsStatus: 'received' })
).toBe('SM1')
expect(twilioHandler.extractIdempotencyId!({ CallSid: 'CA1' })).toBe('CA1')
expect(twilioHandler.extractIdempotencyId!({})).toBeNull()
})
it('returns null instead of throwing when body is not a record', () => {
expect(twilioHandler.extractIdempotencyId!(null)).toBeNull()
expect(twilioHandler.extractIdempotencyId!(undefined)).toBeNull()
expect(twilioHandler.extractIdempotencyId!('not-an-object')).toBeNull()
expect(twilioHandler.extractIdempotencyId!([1, 2, 3])).toBeNull()
})
it('keys status callbacks by SID + status so each delivery state is distinct', () => {
const sent = twilioHandler.extractIdempotencyId!({ MessageSid: 'SM1', MessageStatus: 'sent' })
const delivered = twilioHandler.extractIdempotencyId!({
MessageSid: 'SM1',
MessageStatus: 'delivered',
})
expect(sent).toBe('SM1:sent')
expect(delivered).toBe('SM1:delivered')
expect(sent).not.toBe(delivered)
})
})
describe('matchEvent', () => {
const match = (triggerId: string, body: Record<string, unknown>) =>
twilioHandler.matchEvent!({
body,
request: new Request('http://localhost/test') as never,
rawBody: '',
requestId: 'r1',
providerConfig: { triggerId },
webhook: {},
workflow: {},
})
const inbound = { MessageSid: 'SM1', From: '+1', Body: 'hi', SmsStatus: 'received' }
const status = { MessageSid: 'SM1', MessageStatus: 'delivered', SmsStatus: 'delivered' }
it('routes inbound messages only to the received trigger', () => {
expect(match('twilio_sms_received', inbound)).toBe(true)
expect(match('twilio_sms_status', inbound)).toBe(false)
})
it('routes delivery callbacks only to the status trigger', () => {
expect(match('twilio_sms_status', status)).toBe(true)
expect(match('twilio_sms_received', status)).toBe(false)
})
it('passes through when no triggerId is configured', () => {
expect(match('', inbound)).toBe(true)
})
it('matches neither trigger for an ambiguous payload missing status fields', () => {
const ambiguous = { MessageSid: 'SM1', From: '+1' }
expect(match('twilio_sms_received', ambiguous)).toBe(false)
expect(match('twilio_sms_status', ambiguous)).toBe(false)
})
it('matches neither trigger instead of throwing when body is not a record', () => {
expect(match('twilio_sms_received', null as never)).toBe(false)
expect(match('twilio_sms_status', null as never)).toBe(false)
})
})
describe('formatInput', () => {
const ctx = (body: Record<string, unknown>) => ({
webhook: {},
workflow: { id: 'wf1', userId: 'u1' },
body,
headers: {},
requestId: 'r1',
})
it('maps inbound SMS params to aligned output keys', async () => {
const body = {
MessageSid: 'SM123',
AccountSid: 'AC123',
From: '+15551234567',
To: '+15557654321',
Body: 'hello world',
NumMedia: '0',
NumSegments: '1',
SmsStatus: 'received',
ApiVersion: '2010-04-01',
FromCity: 'SAN FRANCISCO',
FromState: 'CA',
FromCountry: 'US',
}
const { input } = await twilioHandler.formatInput!(ctx(body))
const i = input as Record<string, unknown>
expect(i.messageSid).toBe('SM123')
expect(i.from).toBe('+15551234567')
expect(i.to).toBe('+15557654321')
expect(i.body).toBe('hello world')
expect(i.smsStatus).toBe('received')
expect(i.numMedia).toBe('0')
expect(i.media).toEqual([])
expect(i.fromCity).toBe('SAN FRANCISCO')
expect(i.raw).toBe(JSON.stringify(body))
})
it('extracts MMS media items from NumMedia / MediaUrl{N}', async () => {
const body = {
MessageSid: 'MM123',
NumMedia: '2',
MediaUrl0: 'https://api.twilio.com/media/0',
MediaContentType0: 'image/jpeg',
MediaUrl1: 'https://api.twilio.com/media/1',
MediaContentType1: 'image/png',
}
const { input } = await twilioHandler.formatInput!(ctx(body))
const i = input as Record<string, unknown>
expect(i.media).toEqual([
{ url: 'https://api.twilio.com/media/0', contentType: 'image/jpeg' },
{ url: 'https://api.twilio.com/media/1', contentType: 'image/png' },
])
})
it('maps status-callback params including ErrorCode on failure', async () => {
const body = {
MessageSid: 'SM999',
MessageStatus: 'failed',
SmsStatus: 'failed',
ErrorCode: '30008',
From: '+15550000000',
To: '+15551111111',
}
const { input } = await twilioHandler.formatInput!(ctx(body))
const i = input as Record<string, unknown>
expect(i.messageStatus).toBe('failed')
expect(i.errorCode).toBe('30008')
expect(i.media).toEqual([])
})
it('degrades to empty output instead of throwing when body is not a record', async () => {
const { input } = await twilioHandler.formatInput!(ctx(null as never))
const i = input as Record<string, unknown>
expect(i.messageSid).toBeUndefined()
expect(i.media).toEqual([])
expect(i.raw).toBe('{}')
})
})
})