diff --git a/spec/factories/external_credential.rb b/spec/factories/external_credential.rb index 4a8dd3f0d..0572ef5ac 100644 --- a/spec/factories/external_credential.rb +++ b/spec/factories/external_credential.rb @@ -9,10 +9,10 @@ FactoryBot.define do name { 'twitter' } credentials do - { consumer_key: 123, - consumer_secret: 123, - oauth_token: 123, - oauth_token_secret: 123 } + { consumer_key: '123', + consumer_secret: '123', + oauth_token: '123', + oauth_token_secret: '123' } end end end diff --git a/spec/requests/channels_twitter_spec.rb b/spec/requests/channels_twitter_spec.rb new file mode 100644 index 000000000..a67d98e53 --- /dev/null +++ b/spec/requests/channels_twitter_spec.rb @@ -0,0 +1,128 @@ +require 'rails_helper' + +RSpec.describe 'Twitter channel API endpoints', type: :request do + let!(:twitter_channel) { create(:twitter_channel) } + let!(:twitter_credential) { create(:twitter_credential) } + + let(:hash_signature) { %(sha256=#{Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', consumer_secret, payload))}) } + let(:consumer_secret) { twitter_credential.credentials[:consumer_secret] } + + # What's this all about? See the "Challenge-Response Checks" section of this article: + # https://developer.twitter.com/en/docs/accounts-and-users/subscribe-account-activity/guides/securing-webhooks + describe 'GET /api/v1/channels_twitter_webhook' do + let(:payload) { params[:crc_token] } + let(:params) { { crc_token: 'foo' } } + + context 'with consumer secret and "crc_token" param' do + it 'responds with { response_token: }' do + get '/api/v1/channels_twitter_webhook', params: params, as: :json + + expect(json_response).to eq('response_token' => hash_signature) + end + end + + context 'without valid twitter credentials in the DB' do + let!(:twitter_credential) { create(:twitter_credential, credentials: { foo: 'bar' }) } + + it 'responds 422 Unprocessable Entity' do + get '/api/v1/channels_twitter_webhook', params: params, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'without "crc_token" param' do + let(:params) { {} } + + it 'responds 422 Unprocessable Entity' do + get '/api/v1/channels_twitter_webhook', params: params, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + + describe 'POST /api/v1/channels_twitter_webhook' do + let(:payload) { params.stringify_keys.to_s.gsub(/=>/, ':').tr(' ', '') } + let(:headers) { { 'x-twitter-webhooks-signature': hash_signature } } + let(:params) { { foo: 'bar', for_user_id: user_id } } + let(:user_id) { twitter_channel.options[:user][:id] } + + # What's this all about? See the "Optional signature header validation" section of this article: + # https://developer.twitter.com/en/docs/accounts-and-users/subscribe-account-activity/guides/securing-webhooks + describe 'hash signature validation' do + context 'with valid params and headers (i.e., not one of the failure cases below)' do + it 'responds 200 OK' do + post '/api/v1/channels_twitter_webhook', params: params, headers: headers, as: :json + + expect(response).to have_http_status(:ok) + end + end + + describe '"x-twitter-webhooks-signature" header' do + context 'when absent' do + let(:headers) { {} } + + it 'responds 422 Unprocessable Entity' do + post '/api/v1/channels_twitter_webhook', params: params, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'when payload doesn’t match' do + let(:headers) { { 'x-twitter-webhooks-signature': 'Not actually a signature' } } + + it 'responds 401 Not Authorized' do + post '/api/v1/channels_twitter_webhook', params: params, headers: headers, as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe '"for_user_id" param' do + context 'when absent' do + let(:params) { { foo: 'bar' } } + + it 'responds 422 Unprocessable Entity' do + post '/api/v1/channels_twitter_webhook', params: params, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'without corresponding Channel' do + let(:params) { { foo: 'bar', for_user_id: 'no_such_user' } } + + it 'responds 422 Unprocessable Entity' do + post '/api/v1/channels_twitter_webhook', params: params, headers: headers, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end + + describe 'core behavior' do + before do + allow(TwitterSync).to receive(:new).and_return(twitter_sync) + allow(twitter_sync).to receive(:process_webhook) + end + + let(:twitter_sync) { instance_double('TwitterSync') } + + it 'delegates to TwitterSync#process_webhook' do + post '/api/v1/channels_twitter_webhook', params: params, headers: headers, as: :json + + expect(twitter_sync).to have_received(:process_webhook).with(twitter_channel) + end + + it 'responds with an empty hash' do + post '/api/v1/channels_twitter_webhook', params: params, headers: headers, as: :json + + expect(json_response).to eq({}) + end + end + end +end