twitter_spec.rb 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. require 'rails_helper'
  3. RSpec.describe 'External Credentials > Twitter', type: :request do
  4. let(:admin) { create(:admin) }
  5. let(:valid_credentials) { attributes_for(:twitter_credential)[:credentials] }
  6. let(:invalid_credentials) { attributes_for(:twitter_credential, :invalid)[:credentials] }
  7. let(:webhook_url) { "#{Setting.get('http_type')}://#{Setting.get('fqdn')}#{Rails.configuration.api_path}/channels_twitter_webhook" }
  8. def body_forbidden
  9. {
  10. errors: [
  11. {
  12. code: 403,
  13. message: 'Forbidden.',
  14. },
  15. ],
  16. }.to_json
  17. end
  18. def headers
  19. { content_type: 'application/json; charset=utf-8' }
  20. end
  21. def oauth_request_token
  22. stub_post('https://api.twitter.com/oauth/request_token').to_return(
  23. status: 200,
  24. body: 'oauth_token=oauth_token&oauth_token_secret=oauth_token_secret&oauth_callback_confirmed=true',
  25. )
  26. end
  27. def oauth_request_token_unauthorized
  28. stub_post('https://api.twitter.com/oauth/request_token').to_return(
  29. status: [ 401, 'Unauthorized' ],
  30. body: '',
  31. )
  32. end
  33. def oauth_request_token_forbidden
  34. stub_post('https://api.twitter.com/oauth/request_token').to_return(
  35. status: [ 403, 'Forbidden' ],
  36. body: '',
  37. )
  38. end
  39. def oauth_access_token
  40. stub_post('https://api.twitter.com/oauth/access_token').to_return(
  41. body: 'oauth_token=oauth_token&oauth_token_secret=oauth_verifier&user_id=1408314039470538752&screen_name=APITesting001'
  42. )
  43. end
  44. def webhook_data(app, valid)
  45. {
  46. id: '1234567890',
  47. url: "https://#{app}.example.com/api/v1/channels_twitter_webhook",
  48. valid: valid,
  49. created_at: '2022-10-11T07:30:00Z',
  50. }
  51. end
  52. def webhooks_forbidden
  53. stub_get('https://api.twitter.com/1.1/account_activity/all/webhooks.json').to_return(
  54. status: 403,
  55. body: body_forbidden,
  56. headers: headers,
  57. )
  58. end
  59. def webhooks_ok
  60. stub_get('https://api.twitter.com/1.1/account_activity/all/webhooks.json').to_return(
  61. status: 200,
  62. body: {
  63. environments: [
  64. environment_name: 'Integration',
  65. webhooks: [ webhook_data('zammad', true) ],
  66. ],
  67. }.to_json,
  68. headers: headers,
  69. )
  70. end
  71. def webhooks_env_empty(env: 'zammad')
  72. stub_get("https://api.twitter.com/1.1/account_activity/all/#{env}/webhooks.json").to_return(
  73. status: 200,
  74. body: [].to_json,
  75. headers: headers,
  76. )
  77. end
  78. def webhooks_env_forbidden(env: 'zammad')
  79. stub_get("https://api.twitter.com/1.1/account_activity/all/#{env}/webhooks.json").to_return(
  80. status: 403,
  81. body: body_forbidden,
  82. headers: headers,
  83. )
  84. end
  85. def webhooks_env_another_app
  86. webhooks_env_ok(valid: true, app: 'another-app')
  87. end
  88. def webhooks_env_invalid
  89. webhooks_env_ok(valid: false)
  90. end
  91. def webhooks_env_ok(valid: true, app: 'zammad')
  92. stub_get('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').to_return(
  93. status: 200,
  94. body: [ webhook_data(app, valid) ].to_json,
  95. headers: headers,
  96. )
  97. end
  98. def register_webhook
  99. stub_post('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks.json').to_return(
  100. status: 200,
  101. body: webhook_data('zammad', true).to_json,
  102. headers: headers,
  103. )
  104. end
  105. def delete_webhook
  106. stub_delete('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json').to_return(
  107. status: 204,
  108. body: nil,
  109. )
  110. end
  111. def crc_webhook
  112. stub_put('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json').to_return(
  113. status: 204,
  114. body: nil,
  115. )
  116. end
  117. def account_verify_credentials
  118. stub_get('https://api.twitter.com/1.1/account/verify_credentials.json').to_return(
  119. body: Rails.root.join('spec/fixtures/files/external_credentials/twitter/zammad_testing.json').read,
  120. headers: { content_type: 'application/json; charset=utf-8' },
  121. )
  122. end
  123. def env_subscriptions(env: 'zammad')
  124. stub_post("https://api.twitter.com/1.1/account_activity/all/#{env}/subscriptions.json").to_return(
  125. status: 204,
  126. headers: { content_type: 'application/json; charset=utf-8' },
  127. )
  128. end
  129. describe 'POST /api/v1/external_credentials/twitter/app_verify' do
  130. before do
  131. authenticated_as(admin, via: :browser)
  132. end
  133. context 'when permission for Twitter channel is deactivated' do
  134. before do
  135. Permission.find_by(name: 'admin.channel_twitter').update(active: false)
  136. end
  137. it 'blocks the request', :aggregate_failures do
  138. post '/api/v1/external_credentials/twitter/app_verify', params: {}, as: :json
  139. expect(response).to have_http_status(:forbidden)
  140. expect(json_response).to include('error' => 'User authorization failed.')
  141. end
  142. end
  143. context 'with no credentials' do
  144. it 'blocks the request', :aggregate_failures do
  145. post '/api/v1/external_credentials/twitter/app_verify', params: {}, as: :json
  146. expect(response).to have_http_status(:ok)
  147. expect(json_response).to include('error' => "The required parameter 'consumer_key' is missing.")
  148. end
  149. end
  150. context 'with invalid credential params' do
  151. before do
  152. oauth_request_token_unauthorized
  153. end
  154. it 'blocks the request', :aggregate_failures do
  155. post '/api/v1/external_credentials/twitter/app_verify', params: invalid_credentials, as: :json
  156. expect(response).to have_http_status(:ok)
  157. expect(json_response).to include('error' => '401 Unauthorized (Invalid credentials may be to blame.)')
  158. end
  159. end
  160. context 'with valid credential params but misconfigured callback URL' do
  161. before do
  162. oauth_request_token_forbidden
  163. end
  164. it 'blocks the request', :aggregate_failures do
  165. post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
  166. expect(response).to have_http_status(:ok)
  167. expect(json_response).to include('error' => "403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)")
  168. end
  169. end
  170. context 'with valid credential params and callback URL but no dev env registered' do
  171. before do
  172. oauth_request_token
  173. webhooks_forbidden
  174. webhooks_env_forbidden
  175. end
  176. it 'blocks the request', :aggregate_failures do
  177. post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
  178. expect(response).to have_http_status(:ok)
  179. expect(json_response).to include('error' => 'Forbidden. Are you sure you created a development environment on developer.twitter.com?')
  180. end
  181. end
  182. context 'with valid credential params and callback URL but wrong dev env label' do
  183. before do
  184. oauth_request_token
  185. webhooks_ok
  186. webhooks_env_forbidden(env: 'foo')
  187. end
  188. it 'blocks the request', :aggregate_failures do
  189. post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials.merge(env: 'foo'), as: :json
  190. expect(response).to have_http_status(:ok)
  191. expect(json_response).to include('error' => 'Dev Environment Label invalid. Please use an existing one ["Integration"], or create a new one.')
  192. end
  193. end
  194. context 'with valid credential params, callback URL, and dev env label' do
  195. before do
  196. oauth_request_token
  197. Setting.set('http_type', 'https')
  198. Setting.set('fqdn', 'zammad.example.com')
  199. end
  200. context 'with no existing webhooks' do
  201. before do
  202. webhooks_env_empty
  203. register_webhook
  204. end
  205. it 'registers a new webhook', :aggregate_failures do
  206. post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
  207. 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
  208. expect(response).to have_http_status(:ok)
  209. expect(json_response).to match('attributes' => hash_including('webhook_id' => '1234567890'))
  210. end
  211. end
  212. context 'with an existing webhook registered to another app' do
  213. before do
  214. webhooks_env_another_app
  215. delete_webhook
  216. register_webhook
  217. end
  218. it 'deletes all existing webhooks and registers a new one', :aggregate_failures do
  219. post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
  220. expect(a_delete('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json'))
  221. .to have_been_made.once
  222. expect(response).to have_http_status(:ok)
  223. expect(json_response).to match('attributes' => hash_including('webhook_id' => '1234567890'))
  224. end
  225. end
  226. context 'with an existing, invalid webhook registered to Zammad' do
  227. before do
  228. webhooks_env_invalid
  229. crc_webhook
  230. end
  231. it 'revalidates by manually triggering a challenge-response check', :aggregate_failures do
  232. post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
  233. expect(a_put('https://api.twitter.com/1.1/account_activity/all/zammad/webhooks/1234567890.json')).to have_been_made.once
  234. expect(response).to have_http_status(:ok)
  235. expect(json_response).to match('attributes' => hash_including('webhook_id' => '1234567890'))
  236. end
  237. end
  238. context 'with an existing, valid webhook registered to Zammad' do
  239. before do
  240. webhooks_env_ok
  241. end
  242. it 'uses the existing webhook' do
  243. post '/api/v1/external_credentials/twitter/app_verify', params: valid_credentials, as: :json
  244. 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
  245. end
  246. end
  247. end
  248. end
  249. describe 'GET /api/v1/external_credentials/twitter/link_account' do
  250. before do
  251. authenticated_as(admin, via: :browser)
  252. end
  253. context 'with no Twitter app' do
  254. it 'returns an error message', :aggregate_failures do
  255. get '/api/v1/external_credentials/twitter/link_account', as: :json
  256. expect(response).to have_http_status(:unprocessable_entity)
  257. expect(json_response).to include('error' => 'There is no Twitter app configured.')
  258. end
  259. end
  260. context 'with invalid Twitter app (configured with invalid credentials)' do
  261. before do
  262. create(:twitter_credential, :invalid)
  263. oauth_request_token_unauthorized
  264. end
  265. it 'returns an error message', :aggregate_failures do
  266. get '/api/v1/external_credentials/twitter/link_account', as: :json
  267. expect(response).to have_http_status(:internal_server_error)
  268. expect(json_response).to include('error' => '401 Unauthorized (Invalid credentials may be to blame.)')
  269. end
  270. end
  271. context 'with a valid Twitter app but misconfigured callback URL' do
  272. before do
  273. create(:twitter_credential)
  274. oauth_request_token_forbidden
  275. end
  276. it 'returns an error message', :aggregate_failures do
  277. get '/api/v1/external_credentials/twitter/link_account', as: :json
  278. expect(response).to have_http_status(:internal_server_error)
  279. expect(json_response).to include('error' => "403 Forbidden (Your app's callback URL configuration on developer.twitter.com may be to blame.)")
  280. end
  281. end
  282. context 'with a valid Twitter app and callback URL' do
  283. let(:twitter_credential) { create(:twitter_credential) }
  284. before do
  285. twitter_credential
  286. oauth_request_token
  287. Setting.set('http_type', 'https')
  288. Setting.set('fqdn', 'zammad.example.com')
  289. end
  290. it 'returns authorization data in the headers' do
  291. get '/api/v1/external_credentials/twitter/link_account', as: :json
  292. expect(
  293. a_post('https://api.twitter.com/oauth/request_token').with(headers: { 'Authorization' => %r{oauth_consumer_key="#{twitter_credential.credentials[:consumer_key]}"} })
  294. ).to have_been_made.once
  295. end
  296. it 'redirects to Twitter authorization URL' do
  297. get '/api/v1/external_credentials/twitter/link_account', 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. get '/api/v1/external_credentials/twitter/link_account', 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. before do
  308. authenticated_as(admin, via: :browser)
  309. end
  310. context 'with no Twitter app' do
  311. it 'returns an error message', :aggregate_failures do
  312. get '/api/v1/external_credentials/twitter/callback', as: :json
  313. expect(response).to have_http_status(:unprocessable_entity)
  314. expect(json_response).to include('error' => 'There is no Twitter app configured.')
  315. end
  316. end
  317. context 'with valid Twitter app but no request token' do
  318. before do
  319. create(:twitter_credential)
  320. end
  321. it 'returns an error message', :aggregate_failures do
  322. get '/api/v1/external_credentials/twitter/callback', as: :json
  323. expect(response).to have_http_status(:unprocessable_entity)
  324. expect(json_response).to include('error' => "The required parameter 'request_token' is missing.")
  325. end
  326. end
  327. context 'with valid Twitter app and request token but non-matching OAuth token (via params)' do
  328. before do
  329. create(:twitter_credential)
  330. Setting.set('http_type', 'https')
  331. Setting.set('fqdn', 'zammad.example.com')
  332. oauth_request_token
  333. get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: { 'X-Forwarded-Proto' => 'https' }
  334. end
  335. it 'returns an error message', :aggregate_failures do
  336. get '/api/v1/external_credentials/twitter/callback', as: :json
  337. expect(response).to have_http_status(:unprocessable_entity)
  338. expect(json_response).to include('error' => "The provided 'oauth_token' is invalid.")
  339. end
  340. end
  341. context 'with valid Twitter app, request token, and matching OAuth token (via params)' do
  342. let(:twitter_credential) { create(:twitter_credential) }
  343. let(:params) { { oauth_token: 'oauth_token', oauth_verifier: 'oauth_verifier' } }
  344. before do
  345. twitter_credential
  346. Setting.set('http_type', 'https')
  347. Setting.set('fqdn', 'zammad.example.com')
  348. oauth_request_token
  349. get '/api/v1/external_credentials/twitter/link_account', as: :json, headers: { 'X-Forwarded-Proto' => 'https' }
  350. oauth_access_token
  351. account_verify_credentials
  352. env_subscriptions
  353. end
  354. it 'creates a new channel', :aggregate_failures do
  355. expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
  356. expect(Channel.last.options).to include('adapter' => 'twitter')
  357. .and include('user' => hash_including('id', 'screen_name', 'name'))
  358. .and include('auth' => hash_including('external_credential_id', 'oauth_token', 'oauth_token_secret'))
  359. end
  360. it 'redirects to the newly created channel', :aggregate_failures do
  361. expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
  362. expect(response).to redirect_to(%r{/#channels/twitter/#{Channel.last.id}$})
  363. end
  364. it 'clears the :request_token session variable', :aggregate_failures do
  365. expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
  366. expect(session[:request_token]).to be_nil
  367. end
  368. it 'subscribes to webhooks', :aggregate_failures do
  369. expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change(Channel, :count).by(1)
  370. expect(
  371. a_post("https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json").with(body: {})
  372. ).to have_been_made.once
  373. expect(Channel.last.options['subscribed_to_webhook_id']).to eq(twitter_credential.credentials[:webhook_id])
  374. end
  375. context 'when Twitter account has already been added' do
  376. let(:channel) { create(:twitter_channel) }
  377. before do
  378. channel
  379. end
  380. it 'uses the existing channel' do
  381. expect do
  382. get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' }
  383. end.not_to change(Channel, :count)
  384. end
  385. it 'updates channel properties' do
  386. expect { get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' } }.to change { channel.reload.updated_at }
  387. .and change { channel.reload.options[:auth][:external_credential_id] }
  388. .and change { channel.reload.options[:auth][:oauth_token] }
  389. .and change { channel.reload.options[:auth][:oauth_token_secret] }
  390. end
  391. it 'subscribes to webhooks', :aggregate_failures do
  392. get '/api/v1/external_credentials/twitter/callback', as: :json, params: params, headers: { 'X-Forwarded-Proto' => 'https' }
  393. expect(
  394. a_post("https://api.twitter.com/1.1/account_activity/all/#{twitter_credential.credentials[:env]}/subscriptions.json").with(body: {})
  395. ).to have_been_made.once
  396. expect(channel.reload.options['subscribed_to_webhook_id']).to eq(twitter_credential.credentials[:webhook_id])
  397. end
  398. end
  399. end
  400. end
  401. end