123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547 |
- # Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
- require 'rails_helper'
- RSpec.describe 'External Credentials', type: :request do
- let(:admin) { create(:admin) }
- context 'without authentication' do
- describe '#index' do
- it 'returns 403 Forbidden' do
- get '/api/v1/external_credentials', as: :json
- expect(response).to have_http_status(:forbidden)
- expect(json_response).to include('error' => 'Authentication required')
- end
- end
- describe '#app_verify' do
- it 'returns 403 Forbidden' do
- post '/api/v1/external_credentials/facebook/app_verify', as: :json
- expect(response).to have_http_status(:forbidden)
- expect(json_response).to include('error' => 'Authentication required')
- end
- end
- describe '#link_account' do
- it 'returns 403 Forbidden' do
- get '/api/v1/external_credentials/facebook/link_account', as: :json
- expect(response).to have_http_status(:forbidden)
- expect(json_response).to include('error' => 'Authentication required')
- end
- end
- describe '#callback' do
- it 'returns 403 Forbidden' do
- get '/api/v1/external_credentials/facebook/callback', as: :json
- expect(response).to have_http_status(:forbidden)
- expect(json_response).to include('error' => 'Authentication required')
- end
- end
- end
- context 'authenticated as admin' do
- before { authenticated_as(admin, via: :browser) }
- describe '#index' do
- it 'responds with an array of ExternalCredential records' do
- get '/api/v1/external_credentials', as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to eq([])
- end
- context 'with expand=true URL parameters' do
- it 'responds with an array of ExternalCredential records and their association data' do
- get '/api/v1/external_credentials?expand=true', as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to eq([])
- end
- end
- end
- context 'for Facebook' do
- let(:invalid_credentials) do
- { application_id: 123, application_secret: 123 }
- end
- describe '#app_verify' do
- describe 'failure cases' do
- context 'when permission for Facebook channel is deactivated' do
- before { Permission.find_by(name: 'admin.channel_facebook').update(active: false) }
- it 'returns 403 Forbidden with internal (Zammad) error' do
- post '/api/v1/external_credentials/facebook/app_verify', as: :json
- expect(response).to have_http_status(:forbidden)
- expect(json_response).to include('error' => 'Not authorized (user)!')
- end
- end
- context 'with no credentials' do
- it 'returns 200 with internal (Zammad) error' do
- post '/api/v1/external_credentials/facebook/app_verify', as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to include('error' => 'No application_id param!')
- end
- end
- context 'with invalid credentials, via request params' do
- it 'returns 200 with remote (Facebook auth) error', :use_vcr do
- post '/api/v1/external_credentials/facebook/app_verify', params: invalid_credentials, as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to include('error' => 'type: OAuthException, code: 101, message: Error validating application. Cannot get application info due to a system error. [HTTP 400]')
- end
- end
- context 'with invalid credentials, via ExternalCredential record' do
- before { create(:facebook_credential, credentials: invalid_credentials) }
- it 'returns 200 with remote (Facebook auth) error', :use_vcr do
- post '/api/v1/external_credentials/facebook/app_verify', as: :json
- expect(response).to have_http_status(:ok)
- expect(json_response).to include('error' => 'type: OAuthException, code: 101, message: Error validating application. Cannot get application info due to a system error. [HTTP 400]')
- end
- end
- end
- end
- describe '#link_account' do
- describe 'failure cases' do
- context 'with no credentials' do
- it 'returns 422 unprocessable entity with internal (Zammad) error' do
- get '/api/v1/external_credentials/facebook/link_account', as: :json
- expect(response).to have_http_status(:unprocessable_entity)
- expect(json_response).to include('error' => 'No Facebook app configured!')
- end
- end
- context 'with invalid credentials, via request params' do
- it 'returns 422 unprocessable entity with internal (Zammad) error' do
- get '/api/v1/external_credentials/facebook/link_account', params: invalid_credentials, as: :json
- expect(response).to have_http_status(:unprocessable_entity)
- expect(json_response).to include('error' => 'No Facebook app configured!')
- end
- end
- context 'with invalid credentials, via ExternalCredential record' do
- before { create(:facebook_credential, credentials: invalid_credentials) }
- it 'returns 500 with remote (Facebook auth) error', :use_vcr do
- get '/api/v1/external_credentials/facebook/link_account', as: :json
- expect(response).to have_http_status(:internal_server_error)
- expect(json_response).to include('error' => 'type: OAuthException, code: 101, message: Error validating application. Cannot get application info due to a system error. [HTTP 400]')
- end
- end
- end
- end
- describe '#callback' do
- describe 'failure cases' do
- context 'with no credentials' do
- it 'returns 422 unprocessable entity with internal (Zammad) error' do
- get '/api/v1/external_credentials/facebook/callback', as: :json
- expect(response).to have_http_status(:unprocessable_entity)
- expect(json_response).to include('error' => 'No Facebook app configured!')
- end
- end
- context 'with invalid credentials, via request params' do
- it 'returns 422 unprocessable entity with internal (Zammad) error' do
- get '/api/v1/external_credentials/facebook/callback', params: invalid_credentials, as: :json
- expect(response).to have_http_status(:unprocessable_entity)
- expect(json_response).to include('error' => 'No Facebook app configured!')
- end
- end
- context 'with invalid credentials, via ExternalCredential record' do
- before { create(:facebook_credential, credentials: invalid_credentials) }
- it 'returns 500 with remote (Facebook auth) error', :use_vcr do
- get '/api/v1/external_credentials/facebook/callback', as: :json
- expect(response).to have_http_status(:internal_server_error)
- expect(json_response).to include('error' => 'type: OAuthException, code: 101, message: Error validating application. Cannot get application info due to a system error. [HTTP 400]')
- end
- end
- end
- end
- end
- context 'for Twitter', :use_vcr, required_envs: %w[TWITTER_CONSUMER_KEY TWITTER_CONSUMER_SECRET TWITTER_OAUTH_TOKEN TWITTER_OAUTH_TOKEN_SECRET TWITTER_DEV_ENVIRONMENT] do
- shared_context 'for callback URL configuration' do
- # NOTE: When recording a new VCR cassette for these tests,
- # the URL below must match the callback URL
- # registered with developer.twitter.com.
- before do
- Setting.set('http_type', 'https')
- Setting.set('fqdn', 'zammad.example.com')
- end
- end
- shared_examples 'for failure cases' do
- it 'responds with the appropriate status and error message' do
- send(*endpoint, as: :json, params: try(:params) || {}, headers: headers)
- expect(response).to have_http_status(status)
- expect(json_response).to include('error' => error_message)
- end
- end
- let(:valid_credentials) { attributes_for(:twitter_credential)[:credentials] }
- let(:invalid_credentials) { attributes_for(:twitter_credential, :invalid)[:credentials] }
- describe 'POST /api/v1/external_credentials/twitter/app_verify' do
- let(:endpoint) { [:post, '/api/v1/external_credentials/twitter/app_verify'] }
- context 'when permission for Twitter channel is deactivated' do
- before { Permission.find_by(name: 'admin.channel_twitter').update(active: false) }
- include_examples 'for failure cases' do
- let(:status) { :forbidden }
- let(:error_message) { 'Not authorized (user)!' }
- end
- end
- context 'with no credentials' do
- include_examples 'for failure cases' do
- let(:status) { :ok }
- let(:error_message) { 'No consumer_key param!' }
- end
- end
- context 'with invalid credential params' do
- let(:params) { invalid_credentials }
- include_examples 'for failure cases' do
- let(:status) { :ok }
- let(:error_message) { <<~ERR.chomp }
- 401 Unauthorized (Invalid credentials may be to blame.)
- ERR
- end
- end
- context 'with valid credential params but misconfigured callback URL' do
- let(:params) { valid_credentials }
- include_examples 'for failure cases' do
- let(:status) { :ok }
- let(:error_message) { <<~ERR.chomp }
- 403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)
- ERR
- end
- end
- context 'with valid credential params and callback URL but no dev env registered' do
- let(:params) { valid_credentials }
- include_context 'for callback URL configuration'
- include_examples 'for failure cases' do
- let(:status) { :ok }
- let(:error_message) { <<~ERR.chomp }
- Forbidden. Are you sure you created a development environment on developer.twitter.com?
- ERR
- end
- end
- context 'with valid credential params and callback URL but wrong dev env label' do
- let(:params) { valid_credentials.merge(env: 'foo') }
- include_context 'for callback URL configuration'
- include_examples 'for failure cases' do
- let(:status) { :ok }
- let(:error_message) { <<~ERR.chomp }
- Dev Environment Label invalid. Please use an existing one ["#{ENV.fetch('TWITTER_DEV_ENVIRONMENT', 'Integration')}"], or create a new one.
- ERR
- end
- end
- context 'with valid credential params, callback URL, and dev env label' do
- let(:env_name) { valid_credentials[:env] }
- include_context 'for callback URL configuration'
- shared_examples 'for successful webhook connection' do
- let(:webhook_id) { '1241980494134145024' }
- it 'responds 200 OK with the new webhook ID' do
- send(*endpoint, as: :json, params: valid_credentials)
- expect(response).to have_http_status(:ok)
- expect(json_response).to match('attributes' => hash_including('webhook_id' => webhook_id))
- end
- end
- context 'with no existing webhooks' do
- let(:webhook_url) { "#{Setting.get('http_type')}://#{Setting.get('fqdn')}#{Rails.configuration.api_path}/channels_twitter_webhook" }
- include_examples 'for successful webhook connection'
- it 'registers a new webhook' do
- send(*endpoint, as: :json, params: valid_credentials)
- expect(WebMock)
- .to have_requested(:post, "https://api.twitter.com/1.1/account_activity/all/#{env_name}/webhooks.json")
- .with(body: "url=#{CGI.escape(webhook_url)}")
- end
- end
- context 'with an existing webhook registered to another app' do
- include_examples 'for successful webhook connection'
- it 'deletes all existing webhooks first' do
- send(*endpoint, as: :json, params: valid_credentials)
- expect(WebMock)
- .to have_requested(:delete, "https://api.twitter.com/1.1/account_activity/all/#{env_name}/webhooks/1241981813595049984.json")
- end
- end
- context 'with an existing, invalid webhook registered to Zammad' do
- include_examples 'for successful webhook connection'
- it 'revalidates by manually triggering a challenge-response check' do
- send(*endpoint, as: :json, params: valid_credentials)
- expect(WebMock)
- .to have_requested(:put, "https://api.twitter.com/1.1/account_activity/all/#{env_name}/webhooks/1241980494134145024.json")
- end
- end
- context 'with an existing, valid webhook registered to Zammad' do
- include_examples 'for successful webhook connection'
- it 'uses the existing webhook' do
- send(*endpoint, as: :json, params: valid_credentials)
- expect(WebMock)
- .not_to have_requested(:post, "https://api.twitter.com/1.1/account_activity/all/#{env_name}/webhooks.json")
- end
- end
- end
- end
- describe 'GET /api/v1/external_credentials/twitter/link_account' do
- let(:endpoint) { [:get, '/api/v1/external_credentials/twitter/link_account'] }
- context 'with no Twitter app' do
- include_examples 'for failure cases' do
- let(:status) { :unprocessable_entity }
- let(:error_message) { 'No Twitter app configured!' }
- end
- end
- context 'with invalid Twitter app (configured with invalid credentials)' do
- let!(:twitter_credential) { create(:twitter_credential, :invalid) }
- include_examples 'for failure cases' do
- let(:status) { :internal_server_error }
- let(:error_message) { <<~ERR.chomp }
- 401 Unauthorized (Invalid credentials may be to blame.)
- ERR
- end
- end
- context 'with a valid Twitter app but misconfigured callback URL' do
- let!(:twitter_credential) { create(:twitter_credential) }
- include_examples 'for failure cases' do
- let(:status) { :internal_server_error }
- let(:error_message) { <<~ERR.chomp }
- 403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)
- ERR
- end
- end
- context 'with a valid Twitter app and callback URL' do
- let!(:twitter_credential) { create(:twitter_credential) }
- include_context 'for callback URL configuration'
- it 'requests OAuth request token from Twitter API' do
- send(*endpoint, as: :json)
- expect(WebMock)
- .to have_requested(:post, 'https://api.twitter.com/oauth/request_token')
- .with(headers: { 'Authorization' => %r{oauth_consumer_key="#{twitter_credential.credentials[:consumer_key]}"} })
- end
- it 'redirects to Twitter authorization URL' do
- send(*endpoint, 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
- send(*endpoint, as: :json)
- expect(session[:request_token]).to be_a(OAuth::RequestToken)
- end
- end
- end
- describe 'GET /api/v1/external_credentials/twitter/callback' do
- let(:endpoint) { [:get, '/api/v1/external_credentials/twitter/callback'] }
- context 'with no Twitter app' do
- include_examples 'for failure cases' do
- let(:status) { :unprocessable_entity }
- let(:error_message) { 'No Twitter app configured!' }
- end
- end
- context 'with valid Twitter app but no request token' do
- let!(:twitter_credential) { create(:twitter_credential) }
- include_examples 'for failure cases' do
- let(:status) { :unprocessable_entity }
- let(:error_message) { 'No request_token for session found!' }
- end
- end
- context 'with valid Twitter app and request token but non-matching OAuth token (via params)' do
- include_context 'for callback URL configuration'
- let!(:twitter_credential) { create(:twitter_credential) }
- # Rails / Rack needs to detect that the request comes via HTTPS as well
- let(:headers) do
- {
- 'X-Forwarded-Proto' => 'https'
- }
- end
- before { get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: headers }
- include_examples 'for failure cases' do
- let(:status) { :unprocessable_entity }
- let(:error_message) { 'Invalid oauth_token given!' }
- end
- end
- # NOTE: Want to delete/regenerate the VCR cassettes for these examples?
- # It's gonna be messy--each one is actually two cassettes merged into one.
- #
- # Why? The OAuth flow can't be fully reproduced in a request spec:
- #
- # 1. User clicks "Add Twitter account" in Zammad.
- # Zammad asks Twitter for request token, saves it to session,
- # and redirects user to Twitter.
- # 2. User clicks "Authorize app" on Twitter.
- # Twitter generates temporary OAuth credentials
- # and redirects user back to this endpoint (with creds in URL query string).
- # 3. Zammad asks Twitter for an access token
- # (using request token from Step 1 + OAuth creds from Step 2).
- #
- # In these tests (Step 2), the user hits this endpoint
- # with parameters that ONLY the Twitter OAuth server can generate.
- # In the VCR cassette for Step 3,
- # Zammad sends these parameters back to Twitter for validation.
- # Without valid credentials in Step 2, Step 3 will always fail.
- #
- # Instead, we have to record the VCR cassette in a live development instance
- # and stitch the cassette together with a cassette for Step 1.
- #
- # tl;dr A feature spec might have made more sense here.
- context 'with valid Twitter app, request token, and matching OAuth token (via params)' do
- include_context 'for callback URL configuration'
- let!(:twitter_credential) { create(:twitter_credential) }
- # For illustrative purposes only.
- # These parameters cannot be used to record a new VCR cassette (see note above).
- let(:params) { { oauth_token: oauth_token, oauth_verifier: oauth_verifier } }
- let(:oauth_token) { 'DyhnyQAAAAAA9CNXAAABcSxAexs' }
- let(:oauth_verifier) { '15DOeRkjP4JkOSVqULkTKA1SCuIPP105' }
- # Rails / Rack needs to detect that the request comes via HTTPS as well
- let(:headers) do
- {
- 'X-Forwarded-Proto' => 'https'
- }
- end
- before { get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: headers }
- context 'if Twitter account has already been added' do
- let!(:channel) { create(:twitter_channel, custom_options: channel_options) }
- let(:channel_options) do
- {
- user: {
- id: '1205290247124217856',
- screen_name: 'pennbrooke1',
- }
- }
- end
- it 'uses the existing channel' do
- expect { send(*endpoint, as: :json, params: params, headers: headers) }
- .not_to change(Channel, :count)
- end
- it 'updates channel properties' do
- expect { send(*endpoint, as: :json, params: params, headers: headers) }
- .to change { channel.reload.options[:user][:name] }
- .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' do
- send(*endpoint, as: :json, params: params, headers: headers)
- expect(WebMock)
- .to have_requested(:post, "https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json")
- expect(channel.reload.options['subscribed_to_webhook_id'])
- .to eq(twitter_credential.credentials[:webhook_id])
- end
- end
- it 'creates a new channel' do
- expect { send(*endpoint, as: :json, params: params, headers: headers) }
- .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' do
- send(*endpoint, as: :json, params: params, headers: headers)
- expect(response).to redirect_to(%r{/#channels/twitter/#{Channel.last.id}$})
- end
- it 'clears the :request_token session variable' do
- send(*endpoint, as: :json, params: params, headers: headers)
- expect(session[:request_token]).to be_nil
- end
- it 'subscribes to webhooks' do
- send(*endpoint, as: :json, params: params, headers: headers)
- expect(WebMock)
- .to have_requested(:post, "https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json")
- expect(Channel.last.options['subscribed_to_webhook_id'])
- .to eq(twitter_credential.credentials[:webhook_id])
- end
- end
- end
- end
- end
- end
|