external_credentials_spec.rb 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. # Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
  2. require 'rails_helper'
  3. RSpec.describe 'External Credentials', type: :request do
  4. let(:admin) { create(:admin) }
  5. context 'without authentication' do
  6. describe '#index' do
  7. it 'returns 403 Forbidden' do
  8. get '/api/v1/external_credentials', as: :json
  9. expect(response).to have_http_status(:forbidden)
  10. expect(json_response).to include('error' => 'Authentication required')
  11. end
  12. end
  13. describe '#app_verify' do
  14. it 'returns 403 Forbidden' do
  15. post '/api/v1/external_credentials/facebook/app_verify', as: :json
  16. expect(response).to have_http_status(:forbidden)
  17. expect(json_response).to include('error' => 'Authentication required')
  18. end
  19. end
  20. describe '#link_account' do
  21. it 'returns 403 Forbidden' do
  22. get '/api/v1/external_credentials/facebook/link_account', as: :json
  23. expect(response).to have_http_status(:forbidden)
  24. expect(json_response).to include('error' => 'Authentication required')
  25. end
  26. end
  27. describe '#callback' do
  28. it 'returns 403 Forbidden' do
  29. get '/api/v1/external_credentials/facebook/callback', as: :json
  30. expect(response).to have_http_status(:forbidden)
  31. expect(json_response).to include('error' => 'Authentication required')
  32. end
  33. end
  34. end
  35. context 'authenticated as admin' do
  36. before { authenticated_as(admin, via: :browser) }
  37. describe '#index' do
  38. it 'responds with an array of ExternalCredential records' do
  39. get '/api/v1/external_credentials', as: :json
  40. expect(response).to have_http_status(:ok)
  41. expect(json_response).to eq([])
  42. end
  43. context 'with expand=true URL parameters' do
  44. it 'responds with an array of ExternalCredential records and their association data' do
  45. get '/api/v1/external_credentials?expand=true', as: :json
  46. expect(response).to have_http_status(:ok)
  47. expect(json_response).to eq([])
  48. end
  49. end
  50. end
  51. context 'for Facebook' do
  52. let(:invalid_credentials) do
  53. { application_id: 123, application_secret: 123 }
  54. end
  55. describe '#app_verify' do
  56. describe 'failure cases' do
  57. context 'when permission for Facebook channel is deactivated' do
  58. before { Permission.find_by(name: 'admin.channel_facebook').update(active: false) }
  59. it 'returns 403 Forbidden with internal (Zammad) error' do
  60. post '/api/v1/external_credentials/facebook/app_verify', as: :json
  61. expect(response).to have_http_status(:forbidden)
  62. expect(json_response).to include('error' => 'Not authorized (user)!')
  63. end
  64. end
  65. context 'with no credentials' do
  66. it 'returns 200 with internal (Zammad) error' do
  67. post '/api/v1/external_credentials/facebook/app_verify', as: :json
  68. expect(response).to have_http_status(:ok)
  69. expect(json_response).to include('error' => 'No application_id param!')
  70. end
  71. end
  72. context 'with invalid credentials, via request params' do
  73. it 'returns 200 with remote (Facebook auth) error', :use_vcr do
  74. post '/api/v1/external_credentials/facebook/app_verify', params: invalid_credentials, as: :json
  75. expect(response).to have_http_status(:ok)
  76. 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]')
  77. end
  78. end
  79. context 'with invalid credentials, via ExternalCredential record' do
  80. before { create(:facebook_credential, credentials: invalid_credentials) }
  81. it 'returns 200 with remote (Facebook auth) error', :use_vcr do
  82. post '/api/v1/external_credentials/facebook/app_verify', as: :json
  83. expect(response).to have_http_status(:ok)
  84. 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]')
  85. end
  86. end
  87. end
  88. end
  89. describe '#link_account' do
  90. describe 'failure cases' do
  91. context 'with no credentials' do
  92. it 'returns 422 unprocessable entity with internal (Zammad) error' do
  93. get '/api/v1/external_credentials/facebook/link_account', as: :json
  94. expect(response).to have_http_status(:unprocessable_entity)
  95. expect(json_response).to include('error' => 'No Facebook app configured!')
  96. end
  97. end
  98. context 'with invalid credentials, via request params' do
  99. it 'returns 422 unprocessable entity with internal (Zammad) error' do
  100. get '/api/v1/external_credentials/facebook/link_account', params: invalid_credentials, as: :json
  101. expect(response).to have_http_status(:unprocessable_entity)
  102. expect(json_response).to include('error' => 'No Facebook app configured!')
  103. end
  104. end
  105. context 'with invalid credentials, via ExternalCredential record' do
  106. before { create(:facebook_credential, credentials: invalid_credentials) }
  107. it 'returns 500 with remote (Facebook auth) error', :use_vcr do
  108. get '/api/v1/external_credentials/facebook/link_account', as: :json
  109. expect(response).to have_http_status(:internal_server_error)
  110. 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]')
  111. end
  112. end
  113. end
  114. end
  115. describe '#callback' do
  116. describe 'failure cases' do
  117. context 'with no credentials' do
  118. it 'returns 422 unprocessable entity with internal (Zammad) error' do
  119. get '/api/v1/external_credentials/facebook/callback', as: :json
  120. expect(response).to have_http_status(:unprocessable_entity)
  121. expect(json_response).to include('error' => 'No Facebook app configured!')
  122. end
  123. end
  124. context 'with invalid credentials, via request params' do
  125. it 'returns 422 unprocessable entity with internal (Zammad) error' do
  126. get '/api/v1/external_credentials/facebook/callback', params: invalid_credentials, as: :json
  127. expect(response).to have_http_status(:unprocessable_entity)
  128. expect(json_response).to include('error' => 'No Facebook app configured!')
  129. end
  130. end
  131. context 'with invalid credentials, via ExternalCredential record' do
  132. before { create(:facebook_credential, credentials: invalid_credentials) }
  133. it 'returns 500 with remote (Facebook auth) error', :use_vcr do
  134. get '/api/v1/external_credentials/facebook/callback', as: :json
  135. expect(response).to have_http_status(:internal_server_error)
  136. 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]')
  137. end
  138. end
  139. end
  140. end
  141. end
  142. 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
  143. shared_context 'for callback URL configuration' do
  144. # NOTE: When recording a new VCR cassette for these tests,
  145. # the URL below must match the callback URL
  146. # registered with developer.twitter.com.
  147. before do
  148. Setting.set('http_type', 'https')
  149. Setting.set('fqdn', 'zammad.example.com')
  150. end
  151. end
  152. shared_examples 'for failure cases' do
  153. it 'responds with the appropriate status and error message' do
  154. send(*endpoint, as: :json, params: try(:params) || {}, headers: headers)
  155. expect(response).to have_http_status(status)
  156. expect(json_response).to include('error' => error_message)
  157. end
  158. end
  159. let(:valid_credentials) { attributes_for(:twitter_credential)[:credentials] }
  160. let(:invalid_credentials) { attributes_for(:twitter_credential, :invalid)[:credentials] }
  161. describe 'POST /api/v1/external_credentials/twitter/app_verify' do
  162. let(:endpoint) { [:post, '/api/v1/external_credentials/twitter/app_verify'] }
  163. context 'when permission for Twitter channel is deactivated' do
  164. before { Permission.find_by(name: 'admin.channel_twitter').update(active: false) }
  165. include_examples 'for failure cases' do
  166. let(:status) { :forbidden }
  167. let(:error_message) { 'Not authorized (user)!' }
  168. end
  169. end
  170. context 'with no credentials' do
  171. include_examples 'for failure cases' do
  172. let(:status) { :ok }
  173. let(:error_message) { 'No consumer_key param!' }
  174. end
  175. end
  176. context 'with invalid credential params' do
  177. let(:params) { invalid_credentials }
  178. include_examples 'for failure cases' do
  179. let(:status) { :ok }
  180. let(:error_message) { <<~ERR.chomp }
  181. 401 Unauthorized (Invalid credentials may be to blame.)
  182. ERR
  183. end
  184. end
  185. context 'with valid credential params but misconfigured callback URL' do
  186. let(:params) { valid_credentials }
  187. include_examples 'for failure cases' do
  188. let(:status) { :ok }
  189. let(:error_message) { <<~ERR.chomp }
  190. 403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)
  191. ERR
  192. end
  193. end
  194. context 'with valid credential params and callback URL but no dev env registered' do
  195. let(:params) { valid_credentials }
  196. include_context 'for callback URL configuration'
  197. include_examples 'for failure cases' do
  198. let(:status) { :ok }
  199. let(:error_message) { <<~ERR.chomp }
  200. Forbidden. Are you sure you created a development environment on developer.twitter.com?
  201. ERR
  202. end
  203. end
  204. context 'with valid credential params and callback URL but wrong dev env label' do
  205. let(:params) { valid_credentials.merge(env: 'foo') }
  206. include_context 'for callback URL configuration'
  207. include_examples 'for failure cases' do
  208. let(:status) { :ok }
  209. let(:error_message) { <<~ERR.chomp }
  210. Dev Environment Label invalid. Please use an existing one ["#{ENV.fetch('TWITTER_DEV_ENVIRONMENT', 'Integration')}"], or create a new one.
  211. ERR
  212. end
  213. end
  214. context 'with valid credential params, callback URL, and dev env label' do
  215. let(:env_name) { valid_credentials[:env] }
  216. include_context 'for callback URL configuration'
  217. shared_examples 'for successful webhook connection' do
  218. let(:webhook_id) { '1241980494134145024' }
  219. it 'responds 200 OK with the new webhook ID' do
  220. send(*endpoint, as: :json, params: valid_credentials)
  221. expect(response).to have_http_status(:ok)
  222. expect(json_response).to match('attributes' => hash_including('webhook_id' => webhook_id))
  223. end
  224. end
  225. context 'with no existing webhooks' do
  226. let(:webhook_url) { "#{Setting.get('http_type')}://#{Setting.get('fqdn')}#{Rails.configuration.api_path}/channels_twitter_webhook" }
  227. include_examples 'for successful webhook connection'
  228. it 'registers a new webhook' do
  229. send(*endpoint, as: :json, params: valid_credentials)
  230. expect(WebMock)
  231. .to have_requested(:post, "https://api.twitter.com/1.1/account_activity/all/#{env_name}/webhooks.json")
  232. .with(body: "url=#{CGI.escape(webhook_url)}")
  233. end
  234. end
  235. context 'with an existing webhook registered to another app' do
  236. include_examples 'for successful webhook connection'
  237. it 'deletes all existing webhooks first' do
  238. send(*endpoint, as: :json, params: valid_credentials)
  239. expect(WebMock)
  240. .to have_requested(:delete, "https://api.twitter.com/1.1/account_activity/all/#{env_name}/webhooks/1241981813595049984.json")
  241. end
  242. end
  243. context 'with an existing, invalid webhook registered to Zammad' do
  244. include_examples 'for successful webhook connection'
  245. it 'revalidates by manually triggering a challenge-response check' do
  246. send(*endpoint, as: :json, params: valid_credentials)
  247. expect(WebMock)
  248. .to have_requested(:put, "https://api.twitter.com/1.1/account_activity/all/#{env_name}/webhooks/1241980494134145024.json")
  249. end
  250. end
  251. context 'with an existing, valid webhook registered to Zammad' do
  252. include_examples 'for successful webhook connection'
  253. it 'uses the existing webhook' do
  254. send(*endpoint, as: :json, params: valid_credentials)
  255. expect(WebMock)
  256. .not_to have_requested(:post, "https://api.twitter.com/1.1/account_activity/all/#{env_name}/webhooks.json")
  257. end
  258. end
  259. end
  260. end
  261. describe 'GET /api/v1/external_credentials/twitter/link_account' do
  262. let(:endpoint) { [:get, '/api/v1/external_credentials/twitter/link_account'] }
  263. context 'with no Twitter app' do
  264. include_examples 'for failure cases' do
  265. let(:status) { :unprocessable_entity }
  266. let(:error_message) { 'No Twitter app configured!' }
  267. end
  268. end
  269. context 'with invalid Twitter app (configured with invalid credentials)' do
  270. let!(:twitter_credential) { create(:twitter_credential, :invalid) }
  271. include_examples 'for failure cases' do
  272. let(:status) { :internal_server_error }
  273. let(:error_message) { <<~ERR.chomp }
  274. 401 Unauthorized (Invalid credentials may be to blame.)
  275. ERR
  276. end
  277. end
  278. context 'with a valid Twitter app but misconfigured callback URL' do
  279. let!(:twitter_credential) { create(:twitter_credential) }
  280. include_examples 'for failure cases' do
  281. let(:status) { :internal_server_error }
  282. let(:error_message) { <<~ERR.chomp }
  283. 403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)
  284. ERR
  285. end
  286. end
  287. context 'with a valid Twitter app and callback URL' do
  288. let!(:twitter_credential) { create(:twitter_credential) }
  289. include_context 'for callback URL configuration'
  290. it 'requests OAuth request token from Twitter API' do
  291. send(*endpoint, as: :json)
  292. expect(WebMock)
  293. .to have_requested(:post, 'https://api.twitter.com/oauth/request_token')
  294. .with(headers: { 'Authorization' => %r{oauth_consumer_key="#{twitter_credential.credentials[:consumer_key]}"} })
  295. end
  296. it 'redirects to Twitter authorization URL' do
  297. send(*endpoint, as: :json)
  298. expect(response).to redirect_to(%r{^https://api.twitter.com/oauth/authorize\?oauth_token=\w+$})
  299. end
  300. it 'saves request token to session hash' do
  301. send(*endpoint, as: :json)
  302. expect(session[:request_token]).to be_a(OAuth::RequestToken)
  303. end
  304. end
  305. end
  306. describe 'GET /api/v1/external_credentials/twitter/callback' do
  307. let(:endpoint) { [:get, '/api/v1/external_credentials/twitter/callback'] }
  308. context 'with no Twitter app' do
  309. include_examples 'for failure cases' do
  310. let(:status) { :unprocessable_entity }
  311. let(:error_message) { 'No Twitter app configured!' }
  312. end
  313. end
  314. context 'with valid Twitter app but no request token' do
  315. let!(:twitter_credential) { create(:twitter_credential) }
  316. include_examples 'for failure cases' do
  317. let(:status) { :unprocessable_entity }
  318. let(:error_message) { 'No request_token for session found!' }
  319. end
  320. end
  321. context 'with valid Twitter app and request token but non-matching OAuth token (via params)' do
  322. include_context 'for callback URL configuration'
  323. let!(:twitter_credential) { create(:twitter_credential) }
  324. # Rails / Rack needs to detect that the request comes via HTTPS as well
  325. let(:headers) do
  326. {
  327. 'X-Forwarded-Proto' => 'https'
  328. }
  329. end
  330. before { get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: headers }
  331. include_examples 'for failure cases' do
  332. let(:status) { :unprocessable_entity }
  333. let(:error_message) { 'Invalid oauth_token given!' }
  334. end
  335. end
  336. # NOTE: Want to delete/regenerate the VCR cassettes for these examples?
  337. # It's gonna be messy--each one is actually two cassettes merged into one.
  338. #
  339. # Why? The OAuth flow can't be fully reproduced in a request spec:
  340. #
  341. # 1. User clicks "Add Twitter account" in Zammad.
  342. # Zammad asks Twitter for request token, saves it to session,
  343. # and redirects user to Twitter.
  344. # 2. User clicks "Authorize app" on Twitter.
  345. # Twitter generates temporary OAuth credentials
  346. # and redirects user back to this endpoint (with creds in URL query string).
  347. # 3. Zammad asks Twitter for an access token
  348. # (using request token from Step 1 + OAuth creds from Step 2).
  349. #
  350. # In these tests (Step 2), the user hits this endpoint
  351. # with parameters that ONLY the Twitter OAuth server can generate.
  352. # In the VCR cassette for Step 3,
  353. # Zammad sends these parameters back to Twitter for validation.
  354. # Without valid credentials in Step 2, Step 3 will always fail.
  355. #
  356. # Instead, we have to record the VCR cassette in a live development instance
  357. # and stitch the cassette together with a cassette for Step 1.
  358. #
  359. # tl;dr A feature spec might have made more sense here.
  360. context 'with valid Twitter app, request token, and matching OAuth token (via params)' do
  361. include_context 'for callback URL configuration'
  362. let!(:twitter_credential) { create(:twitter_credential) }
  363. # For illustrative purposes only.
  364. # These parameters cannot be used to record a new VCR cassette (see note above).
  365. let(:params) { { oauth_token: oauth_token, oauth_verifier: oauth_verifier } }
  366. let(:oauth_token) { 'DyhnyQAAAAAA9CNXAAABcSxAexs' }
  367. let(:oauth_verifier) { '15DOeRkjP4JkOSVqULkTKA1SCuIPP105' }
  368. # Rails / Rack needs to detect that the request comes via HTTPS as well
  369. let(:headers) do
  370. {
  371. 'X-Forwarded-Proto' => 'https'
  372. }
  373. end
  374. before { get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: headers }
  375. context 'if Twitter account has already been added' do
  376. let!(:channel) { create(:twitter_channel, custom_options: channel_options) }
  377. let(:channel_options) do
  378. {
  379. user: {
  380. id: '1205290247124217856',
  381. screen_name: 'pennbrooke1',
  382. }
  383. }
  384. end
  385. it 'uses the existing channel' do
  386. expect { send(*endpoint, as: :json, params: params, headers: headers) }
  387. .not_to change(Channel, :count)
  388. end
  389. it 'updates channel properties' do
  390. expect { send(*endpoint, as: :json, params: params, headers: headers) }
  391. .to change { channel.reload.options[:user][:name] }
  392. .and change { channel.reload.options[:auth][:external_credential_id] }
  393. .and change { channel.reload.options[:auth][:oauth_token] }
  394. .and change { channel.reload.options[:auth][:oauth_token_secret] }
  395. end
  396. it 'subscribes to webhooks' do
  397. send(*endpoint, as: :json, params: params, headers: headers)
  398. expect(WebMock)
  399. .to have_requested(:post, "https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json")
  400. expect(channel.reload.options['subscribed_to_webhook_id'])
  401. .to eq(twitter_credential.credentials[:webhook_id])
  402. end
  403. end
  404. it 'creates a new channel' do
  405. expect { send(*endpoint, as: :json, params: params, headers: headers) }
  406. .to change(Channel, :count).by(1)
  407. expect(Channel.last.options)
  408. .to include('adapter' => 'twitter')
  409. .and include('user' => hash_including('id', 'screen_name', 'name'))
  410. .and include('auth' => hash_including('external_credential_id', 'oauth_token', 'oauth_token_secret'))
  411. end
  412. it 'redirects to the newly created channel' do
  413. send(*endpoint, as: :json, params: params, headers: headers)
  414. expect(response).to redirect_to(%r{/#channels/twitter/#{Channel.last.id}$})
  415. end
  416. it 'clears the :request_token session variable' do
  417. send(*endpoint, as: :json, params: params, headers: headers)
  418. expect(session[:request_token]).to be_nil
  419. end
  420. it 'subscribes to webhooks' do
  421. send(*endpoint, as: :json, params: params, headers: headers)
  422. expect(WebMock)
  423. .to have_requested(:post, "https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json")
  424. expect(Channel.last.options['subscribed_to_webhook_id'])
  425. .to eq(twitter_credential.credentials[:webhook_id])
  426. end
  427. end
  428. end
  429. end
  430. end
  431. end