123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507 |
- # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
- require 'rails_helper'
- RSpec.describe 'External Credentials > Twitter', type: :request do
- let(:admin) { create(:admin) }
- let(:valid_credentials) { attributes_for(:twitter_credential)[:credentials] }
- let(:invalid_credentials) { attributes_for(:twitter_credential, :invalid)[:credentials] }
- let(:webhook_url) { "#{Setting.get('http_type')}://#{Setting.get('fqdn')}#{Rails.configuration.api_path}/channels_twitter_webhook" }
- def body_forbidden
- {
- errors: [
- {
- code: 403,
- message: 'Forbidden.',
- },
- ],
- }.to_json
- end
- def headers
- { content_type: 'application/json; charset=utf-8' }
- end
- def oauth_request_token
- stub_post('https://api.twitter.com/oauth/request_token').to_return(
- status: 200,
- body: 'oauth_token=oauth_token&oauth_token_secret=oauth_token_secret&oauth_callback_confirmed=true',
- )
- end
- def oauth_request_token_unauthorized
- stub_post('https://api.twitter.com/oauth/request_token').to_return(
- status: [ 401, 'Unauthorized' ],
- body: '',
- )
- end
- def oauth_request_token_forbidden
- stub_post('https://api.twitter.com/oauth/request_token').to_return(
- status: [ 403, 'Forbidden' ],
- body: '',
- )
- end
- def oauth_access_token
- stub_post('https://api.twitter.com/oauth/access_token').to_return(
- body: 'oauth_token=oauth_token&oauth_token_secret=oauth_verifier&user_id=1408314039470538752&screen_name=APITesting001'
- )
- end
- def webhook_data(app, valid)
- {
- id: '1234567890',
- url: "https://#{app}.example.com/api/v1/channels_twitter_webhook",
- valid: valid,
- created_at: '2022-10-11T07:30:00Z',
- }
- end
- def webhooks_forbidden
- stub_get('https://api.twitter.com/1.1/account_activity/all/webhooks.json').to_return(
- status: 403,
- body: body_forbidden,
- headers: headers,
- )
- end
- def webhooks_ok
- stub_get('https://api.twitter.com/1.1/account_activity/all/webhooks.json').to_return(
- status: 200,
- body: {
- environments: [
- environment_name: 'Integration',
- webhooks: [ webhook_data('zammad', true) ],
- ],
- }.to_json,
- headers: headers,
- )
- end
- def webhooks_env_empty(env: 'zammad')
- stub_get("https://api.twitter.com/1.1/account_activity/all/#{env}/webhooks.json").to_return(
- status: 200,
- body: [].to_json,
- headers: headers,
- )
- end
- def webhooks_env_forbidden(env: 'zammad')
- stub_get("https://api.twitter.com/1.1/account_activity/all/#{env}/webhooks.json").to_return(
- status: 403,
- body: body_forbidden,
- headers: headers,
- )
- end
- def webhooks_env_another_app
- webhooks_env_ok(valid: true, app: 'another-app')
- end
- def webhooks_env_invalid
- webhooks_env_ok(valid: false)
- end
- def webhooks_env_ok(valid: true, app: 'zammad')
- stub_get('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').to_return(
- status: 200,
- body: [ webhook_data(app, valid) ].to_json,
- headers: headers,
- )
- end
- def register_webhook
- stub_post('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').to_return(
- status: 200,
- body: webhook_data('zammad', true).to_json,
- headers: headers,
- )
- end
- def delete_webhook
- stub_delete('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json').to_return(
- status: 204,
- body: nil,
- )
- end
- def crc_webhook
- stub_put('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json').to_return(
- status: 204,
- body: nil,
- )
- end
- def account_verify_credentials
- stub_get('https://api.twitter.com/1.1/account/verify_credentials.json').to_return(
- body: Rails.root.join('spec/fixtures/files/external_credentials/twitter/zammad_testing.json').read,
- headers: { content_type: 'application/json; charset=utf-8' },
- )
- end
- def env_subscriptions(env: 'zammad')
- stub_post("https://api.twitter.com/1.1/account_activity/all/#{env}/subscriptions.json").to_return(
- status: 204,
- headers: { content_type: 'application/json; charset=utf-8' },
- )
- end
- describe 'POST /api/v1/external_credentials/twitter/app_verify' do
- before do
- authenticated_as(admin, via: :browser)
- end
- context 'when permission for Twitter channel is deactivated' do
- before do
- Permission.find_by(name: 'admin.channel_twitter').update(active: false)
- end
- it 'blocks the request', :aggregate_failures do
- post '/api/v1/external_credentials/twitter/app_verify', params: {}, as: :json
- expect(response).to have_http_status(:forbidden)
- expect(json_response).to include('error' => 'User authorization failed.')
- end
- end
- context 'with no credentials' do
- it 'blocks the request', :aggregate_failures do
- post '/api/v1/external_credentials/twitter/app_verify', params: {}, as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to include('error' => "The required parameter 'consumer_key' is missing.")
- end
- end
- context 'with invalid credential params' do
- before do
- oauth_request_token_unauthorized
- end
- it 'blocks the request', :aggregate_failures do
- post '/api/v1/external_credentials/twitter/app_verify', params: invalid_credentials, as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to include('error' => '401 Unauthorized (Invalid credentials may be to blame.)')
- end
- end
- context 'with valid credential params but misconfigured callback URL' do
- before do
- oauth_request_token_forbidden
- end
- it 'blocks the request', :aggregate_failures do
- post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to include('error' => "403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)")
- end
- end
- context 'with valid credential params and callback URL but no dev env registered' do
- before do
- oauth_request_token
- webhooks_forbidden
- webhooks_env_forbidden
- end
- it 'blocks the request', :aggregate_failures do
- post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to include('error' => 'Forbidden. Are you sure you created a development environment on developer.twitter.com?')
- end
- end
- context 'with valid credential params and callback URL but wrong dev env label' do
- before do
- oauth_request_token
- webhooks_ok
- webhooks_env_forbidden(env: 'foo')
- end
- it 'blocks the request', :aggregate_failures do
- post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials.merge(env: 'foo'), as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to include('error' => 'Dev Environment Label invalid. Please use an existing one ["Integration"], or create a new one.')
- end
- end
- context 'with valid credential params, callback URL, and dev env label' do
- before do
- oauth_request_token
- Setting.set('http_type', 'https')
- Setting.set('fqdn', 'zammad.example.com')
- end
- context 'with no existing webhooks' do
- before do
- webhooks_env_empty
- register_webhook
- end
- it 'registers a new webhook', :aggregate_failures do
- post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
- expect(a_post('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').with(body: "url=#{CGI.escape(webhook_url)}")).to have_been_made.once
- expect(response).to have_http_status(:ok)
- expect(json_response).to match('attributes' => hash_including('webhook_id' => '1234567890'))
- end
- end
- context 'with an existing webhook registered to another app' do
- before do
- webhooks_env_another_app
- delete_webhook
- register_webhook
- end
- it 'deletes all existing webhooks and registers a new one', :aggregate_failures do
- post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
- expect(a_delete('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json'))
- .to have_been_made.once
- expect(response).to have_http_status(:ok)
- expect(json_response).to match('attributes' => hash_including('webhook_id' => '1234567890'))
- end
- end
- context 'with an existing, invalid webhook registered to Zammad' do
- before do
- webhooks_env_invalid
- crc_webhook
- end
- it 'revalidates by manually triggering a challenge-response check', :aggregate_failures do
- post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
- expect(a_put('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json')).to have_been_made.once
- expect(response).to have_http_status(:ok)
- expect(json_response).to match('attributes' => hash_including('webhook_id' => '1234567890'))
- end
- end
- context 'with an existing, valid webhook registered to Zammad' do
- before do
- webhooks_env_ok
- end
- it 'uses the existing webhook' do
- post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
- expect(a_post('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').with(body: "url=#{CGI.escape(webhook_url)}")).not_to have_been_made
- end
- end
- end
- end
- describe 'GET /api/v1/external_credentials/twitter/link_account' do
- before do
- authenticated_as(admin, via: :browser)
- end
- context 'with no Twitter app' do
- it 'returns an error message', :aggregate_failures do
- get '/api/v1/external_credentials/twitter/link_account', as: :json
- expect(response).to have_http_status(:unprocessable_entity)
- expect(json_response).to include('error' => 'There is no Twitter app configured.')
- end
- end
- context 'with invalid Twitter app (configured with invalid credentials)' do
- before do
- create(:twitter_credential, :invalid)
- oauth_request_token_unauthorized
- end
- it 'returns an error message', :aggregate_failures do
- get '/api/v1/external_credentials/twitter/link_account', as: :json
- expect(response).to have_http_status(:internal_server_error)
- expect(json_response).to include('error' => '401 Unauthorized (Invalid credentials may be to blame.)')
- end
- end
- context 'with a valid Twitter app but misconfigured callback URL' do
- before do
- create(:twitter_credential)
- oauth_request_token_forbidden
- end
- it 'returns an error message', :aggregate_failures do
- get '/api/v1/external_credentials/twitter/link_account', as: :json
- expect(response).to have_http_status(:internal_server_error)
- expect(json_response).to include('error' => "403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)")
- end
- end
- context 'with a valid Twitter app and callback URL' do
- let(:twitter_credential) { create(:twitter_credential) }
- before do
- twitter_credential
- oauth_request_token
- Setting.set('http_type', 'https')
- Setting.set('fqdn', 'zammad.example.com')
- end
- it 'returns authorization data in the headers' do
- get '/api/v1/external_credentials/twitter/link_account', as: :json
- expect(
- a_post('https://api.twitter.com/oauth/request_token').with(headers: { 'Authorization' => %r{oauth_consumer_key="#{twitter_credential.credentials[:consumer_key]}"} })
- ).to have_been_made.once
- end
- it 'redirects to Twitter authorization URL' do
- get '/api/v1/external_credentials/twitter/link_account', as: :json
- expect(response).to redirect_to(%r{^https://api.twitter.com/oauth/authorize\?oauth_token=\w+$})
- end
- it 'saves request token to session hash' do
- get '/api/v1/external_credentials/twitter/link_account', as: :json
- expect(session[:request_token]).to be_a(OAuth::RequestToken)
- end
- end
- end
- describe 'GET /api/v1/external_credentials/twitter/callback' do
- before do
- authenticated_as(admin, via: :browser)
- end
- context 'with no Twitter app' do
- it 'returns an error message', :aggregate_failures do
- get '/api/v1/external_credentials/twitter/callback', as: :json
- expect(response).to have_http_status(:unprocessable_entity)
- expect(json_response).to include('error' => 'There is no Twitter app configured.')
- end
- end
- context 'with valid Twitter app but no request token' do
- before do
- create(:twitter_credential)
- end
- it 'returns an error message', :aggregate_failures do
- get '/api/v1/external_credentials/twitter/callback', as: :json
- expect(response).to have_http_status(:unprocessable_entity)
- expect(json_response).to include('error' => "The required parameter 'request_token' is missing.")
- end
- end
- context 'with valid Twitter app and request token but non-matching OAuth token (via params)' do
- before do
- create(:twitter_credential)
- Setting.set('http_type', 'https')
- Setting.set('fqdn', 'zammad.example.com')
- oauth_request_token
- get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: { 'X-Forwarded-Proto' => 'https' }
- end
- it 'returns an error message', :aggregate_failures do
- get '/api/v1/external_credentials/twitter/callback', as: :json
- expect(response).to have_http_status(:unprocessable_entity)
- expect(json_response).to include('error' => "The provided 'oauth_token' is invalid.")
- end
- end
- context 'with valid Twitter app, request token, and matching OAuth token (via params)' do
- let(:twitter_credential) { create(:twitter_credential) }
- let(:params) { { oauth_token: 'oauth_token', oauth_verifier: 'oauth_verifier' } }
- before do
- twitter_credential
- Setting.set('http_type', 'https')
- Setting.set('fqdn', 'zammad.example.com')
- oauth_request_token
- get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: { 'X-Forwarded-Proto' => 'https' }
- oauth_access_token
- account_verify_credentials
- env_subscriptions
- end
- it 'creates a new channel', :aggregate_failures do
- expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
- expect(Channel.last.options).to include('adapter' => 'twitter')
- .and include('user' => hash_including('id', 'screen_name', 'name'))
- .and include('auth' => hash_including('external_credential_id', 'oauth_token', 'oauth_token_secret'))
- end
- it 'redirects to the newly created channel', :aggregate_failures do
- expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
- expect(response).to redirect_to(%r{/#channels/twitter/#{Channel.last.id}$})
- end
- it 'clears the :request_token session variable', :aggregate_failures do
- expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
- expect(session[:request_token]).to be_nil
- end
- it 'subscribes to webhooks', :aggregate_failures do
- expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
- expect(
- a_post("https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json").with(body: {})
- ).to have_been_made.once
- expect(Channel.last.options['subscribed_to_webhook_id']).to eq(twitter_credential.credentials[:webhook_id])
- end
- context 'when Twitter account has already been added' do
- let(:channel) { create(:twitter_channel) }
- before do
- channel
- end
- it 'uses the existing channel' do
- expect do
- get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' }
- end.not_to change(Channel, :count)
- end
- it 'updates channel properties' do
- expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change { channel.reload.updated_at }
- .and change { channel.reload.options[:auth][:external_credential_id] }
- .and change { channel.reload.options[:auth][:oauth_token] }
- .and change { channel.reload.options[:auth][:oauth_token_secret] }
- end
- it 'subscribes to webhooks', :aggregate_failures do
- get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' }
- expect(
- a_post("https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json").with(body: {})
- ).to have_been_made.once
- expect(channel.reload.options['subscribed_to_webhook_id']).to eq(twitter_credential.credentials[:webhook_id])
- end
- end
- end
- end
- end
|