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
277 lines
9.4 KiB
TypeScript
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('{}')
|
|
})
|
|
})
|
|
})
|