From 9409d17de67411e42da70e1208180a4f6556b1b1 Mon Sep 17 00:00:00 2001 From: Ryan Lue Date: Wed, 4 Dec 2019 14:37:30 +0800 Subject: [PATCH] Request-spec controller actions for Twitter API webhook This is the first of many commits to add missing test coverage for Zammad's Twitter functionality. The testing process begins from the broadest scope possible, and will proceed in future commits to more granular, fine-grained behaviors. (Request specs provide integration testing at the controller level, and webhook controller actions are the main point of entry for incoming Twitter API functionality.) --- spec/factories/external_credential.rb | 8 +- spec/requests/channels_twitter_spec.rb | 128 +++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 spec/requests/channels_twitter_spec.rb 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