Twilio includes `MessagingServiceSid` on inbound webhooks whenever the number is part of a Messaging Service pool on Twilio's side (required for US A2P 10DLC), even for Chatwoot channels registered by phone number alone. The signature-verify concern was rejecting those webhooks with 403 because lookup-by-MSS returned nil and did not fall through. Only reject outright when the MSS does resolve to a channel but the AccountSid mismatches — the cross-tenant guard from the earlier review is preserved.
168 lines
5.8 KiB
Ruby
168 lines
5.8 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe 'Twilio::CallbacksController', type: :request do
|
|
include Rails.application.routes.url_helpers
|
|
|
|
describe 'POST /twilio/callback' do
|
|
let(:account) { create(:account) }
|
|
let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') }
|
|
let(:params) do
|
|
{
|
|
'From' => '+1234567890',
|
|
'To' => twilio_channel.phone_number,
|
|
'Body' => 'Test message',
|
|
'AccountSid' => 'AC123',
|
|
'SmsSid' => 'SM123'
|
|
}
|
|
end
|
|
|
|
def post_with_signature(url, params:)
|
|
validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token)
|
|
signature = validator.build_signature_for(url, params)
|
|
post url, params: params, headers: { 'X-Twilio-Signature' => signature }
|
|
end
|
|
|
|
context 'with valid signature' do
|
|
it 'enqueues the Twilio events job' do
|
|
url = twilio_callback_index_url
|
|
expect do
|
|
post_with_signature(url, params: params)
|
|
end.to have_enqueued_job(Webhooks::TwilioEventsJob)
|
|
end
|
|
|
|
it 'returns no content status' do
|
|
url = twilio_callback_index_url
|
|
post_with_signature(url, params: params)
|
|
expect(response).to have_http_status(:no_content)
|
|
end
|
|
end
|
|
|
|
context 'with invalid signature' do
|
|
it 'returns forbidden status' do
|
|
post twilio_callback_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
|
|
expect(response).to have_http_status(:forbidden)
|
|
end
|
|
|
|
it 'does not enqueue the job' do
|
|
expect do
|
|
post twilio_callback_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
|
|
end.not_to have_enqueued_job(Webhooks::TwilioEventsJob)
|
|
end
|
|
end
|
|
|
|
context 'with missing signature header' do
|
|
it 'returns forbidden status' do
|
|
post twilio_callback_index_url, params: params
|
|
expect(response).to have_http_status(:forbidden)
|
|
end
|
|
end
|
|
|
|
context 'when channel is not found' do
|
|
it 'returns forbidden status' do
|
|
post twilio_callback_index_url, params: params.merge('AccountSid' => 'UNKNOWN', 'To' => '+0000000000')
|
|
expect(response).to have_http_status(:forbidden)
|
|
end
|
|
end
|
|
|
|
context 'when channel uses API key authentication' do
|
|
let(:twilio_channel) do
|
|
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123')
|
|
end
|
|
|
|
it 'skips signature validation and enqueues the job' do
|
|
expect do
|
|
post twilio_callback_index_url, params: params
|
|
end.to have_enqueued_job(Webhooks::TwilioEventsJob)
|
|
end
|
|
end
|
|
|
|
context 'when behind a reverse proxy with X-Forwarded-Proto' do
|
|
it 'validates signature against the HTTPS URL' do
|
|
http_url = twilio_callback_index_url
|
|
https_url = http_url.sub('http://', 'https://')
|
|
validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token)
|
|
signature = validator.build_signature_for(https_url, params)
|
|
post http_url, params: params, headers: {
|
|
'X-Twilio-Signature' => signature,
|
|
'X-Forwarded-Proto' => 'https'
|
|
}
|
|
expect(response).to have_http_status(:no_content)
|
|
end
|
|
|
|
it 'validates signature when forwarded proto is a comma-separated chain' do
|
|
http_url = twilio_callback_index_url
|
|
https_url = http_url.sub('http://', 'https://')
|
|
validator = Twilio::Security::RequestValidator.new(twilio_channel.auth_token)
|
|
signature = validator.build_signature_for(https_url, params)
|
|
post http_url, params: params, headers: {
|
|
'X-Twilio-Signature' => signature,
|
|
'X-Forwarded-Proto' => 'https,http'
|
|
}
|
|
expect(response).to have_http_status(:no_content)
|
|
end
|
|
end
|
|
|
|
context 'with MessagingServiceSid lookup' do
|
|
let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123') }
|
|
let(:params) do
|
|
{
|
|
'From' => '+1234567890',
|
|
'Body' => 'Test message',
|
|
'AccountSid' => 'AC123',
|
|
'SmsSid' => 'SM123',
|
|
'MessagingServiceSid' => twilio_channel.messaging_service_sid
|
|
}
|
|
end
|
|
|
|
it 'validates and enqueues the job' do
|
|
url = twilio_callback_index_url
|
|
post_with_signature(url, params: params)
|
|
expect(response).to have_http_status(:no_content)
|
|
end
|
|
end
|
|
|
|
context 'when MessagingServiceSid is present but does not match any channel' do
|
|
let(:params) do
|
|
{
|
|
'From' => '+1234567890',
|
|
'To' => twilio_channel.phone_number,
|
|
'Body' => 'Test message',
|
|
'AccountSid' => 'AC123',
|
|
'SmsSid' => 'SM123',
|
|
'MessagingServiceSid' => 'MG_UNKNOWN'
|
|
}
|
|
end
|
|
|
|
it 'falls back to phone number lookup and enqueues the job' do
|
|
url = twilio_callback_index_url
|
|
|
|
expect do
|
|
post_with_signature(url, params: params)
|
|
end.to have_enqueued_job(Webhooks::TwilioEventsJob)
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
end
|
|
end
|
|
|
|
context 'when MessagingServiceSid matches a channel but AccountSid does not' do
|
|
let(:other_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC_OTHER') }
|
|
let(:params) do
|
|
{
|
|
'From' => '+1234567890',
|
|
'To' => twilio_channel.phone_number,
|
|
'Body' => 'Test message',
|
|
'AccountSid' => 'AC123',
|
|
'SmsSid' => 'SM123',
|
|
'MessagingServiceSid' => other_channel.messaging_service_sid
|
|
}
|
|
end
|
|
|
|
it 'returns forbidden without falling back to phone number lookup' do
|
|
url = twilio_callback_index_url
|
|
post_with_signature(url, params: params)
|
|
expect(response).to have_http_status(:forbidden)
|
|
end
|
|
end
|
|
end
|
|
end
|