google_spec.rb 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. require 'rails_helper'
  3. RSpec.describe ExternalCredential::Google do
  4. let(:token_url) { 'https://accounts.google.com/o/oauth2/token' }
  5. let(:alias_url) { 'https://www.googleapis.com/gmail/v1/users/me/settings/sendAs' }
  6. let(:authorize_url) { "https://accounts.google.com/o/oauth2/auth?access_type=offline&client_id=#{client_id}&prompt=consent&redirect_uri=http%3A%2F%2Fzammad.example.com%2Fapi%2Fv1%2Fexternal_credentials%2Fgoogle%2Fcallback&response_type=code&scope=openid+email+profile+https%3A%2F%2Fmail.google.com%2F" }
  7. let(:id_token) { 'eyJhbGciOiJSUzI1NiIsImtpZCI6Inh4eHh4eDkwYmNkNzZhZWIyMDAyNmY2Yjc3MGNhYzIyMTc4MyIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhY2NvdW50cy5nb29nbGUuY29tIiwiYXpwIjoiMTMzNy1jdGYuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiIxMzM3LWN0Zi5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsInN1YiI6IjAwMDg5MjkxMzM3NDkxMDAwMDAyIiwiaGQiOiJleGFtcGxlLmNvbSIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoibjAwd19fNVdwQ1RGNUcwMDBjbU56QSIsImlhdCI6MTU4NzczMjg5MywiZXhwIjoxNTg3NzM2NDkzfQ==' }
  8. let(:access_token) { '000.0000lvC3gAbjs8CYoKitfqM5LBS5N13374MCg6pNpZ28mxO2HuZvg0000_rsW00aACmFEto1BJeGDuu0000vmV6Esqv78iec-FbEe842ZevQtOOemQyQXjhMs62K1E6g3ehDLPRp6j4vtpSKSb6I-3MuDPfdzdqI23hM0' }
  9. let(:refresh_token) { '1//00000VO1ES0hFCgYIARAAGAkSNwF-L9IraWQNMj5ZTqhB00006DssAYcpEyFks5OuvZ1337wrqX0D7tE5o71FIPzcWEMM5000004' }
  10. let(:request_token) { nil } # not used but required by ExternalCredential API
  11. let(:scope_payload) { 'email profile openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://mail.google.com/' }
  12. let(:scope_stub) { 'https://mail.google.com/ https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid' }
  13. let(:client_id) { '123' }
  14. let(:client_secret) { '345' }
  15. let(:authorization_code) { '567' }
  16. let(:primary_email) { 'test@example.com' }
  17. let(:provider) { 'google' }
  18. let(:token_ttl) { 3599 }
  19. let!(:alias_response_payload) do
  20. {
  21. 'sendAs' => [
  22. {
  23. 'sendAsEmail' => primary_email,
  24. 'displayName' => '',
  25. 'replyToAddress' => '',
  26. 'signature' => '',
  27. 'isPrimary' => true,
  28. 'isDefault' => true
  29. },
  30. {
  31. 'sendAsEmail' => 'alias1@example.com',
  32. 'displayName' => 'alias1',
  33. 'replyToAddress' => '',
  34. 'signature' => '',
  35. 'verificationStatus' => 'accepted',
  36. },
  37. {
  38. 'sendAsEmail' => 'alias2@example.com',
  39. 'displayName' => 'alias2',
  40. 'replyToAddress' => '',
  41. 'signature' => '',
  42. 'verificationStatus' => 'accepted',
  43. },
  44. {
  45. 'sendAsEmail' => 'alias3@example.com',
  46. 'displayName' => 'alias3',
  47. 'replyToAddress' => '',
  48. 'signature' => '',
  49. 'verificationStatus' => 'accepted',
  50. },
  51. ]
  52. }
  53. end
  54. let!(:token_response_payload) do
  55. {
  56. 'access_token' => access_token,
  57. 'expires_in' => token_ttl,
  58. 'refresh_token' => refresh_token,
  59. 'scope' => scope_stub,
  60. 'token_type' => 'Bearer',
  61. 'id_token' => id_token,
  62. 'type' => 'XOAUTH2',
  63. }
  64. end
  65. describe '.link_account' do
  66. let!(:authorization_payload) do
  67. {
  68. code: authorization_code,
  69. scope: scope_payload,
  70. authuser: '4',
  71. hd: 'example.com',
  72. prompt: 'consent',
  73. controller: 'external_credentials',
  74. action: 'callback',
  75. provider: provider
  76. }
  77. end
  78. before do
  79. # we check the TTL of tokens and therefore need freeze the time
  80. freeze_time
  81. end
  82. context 'success' do
  83. let(:request_payload) do
  84. {
  85. 'client_secret' => client_secret,
  86. 'code' => authorization_code,
  87. 'grant_type' => 'authorization_code',
  88. 'client_id' => client_id,
  89. 'redirect_uri' => ExternalCredential.callback_url(provider),
  90. }
  91. end
  92. before do
  93. stub_request(:post, token_url)
  94. .with(body: hash_including(request_payload))
  95. .to_return(status: 200, body: token_response_payload.to_json, headers: {})
  96. stub_request(:get, alias_url).to_return(status: 200, body: alias_response_payload.to_json, headers: {})
  97. create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret })
  98. end
  99. it 'creates a Channel instance', :aggregate_failures do
  100. channel = described_class.link_account(request_token, authorization_payload)
  101. expect(channel.options).to include(
  102. 'inbound' => include(
  103. 'options' => include(
  104. 'auth_type' => 'XOAUTH2',
  105. 'host' => 'imap.gmail.com',
  106. 'ssl' => 'ssl',
  107. 'user' => primary_email,
  108. )
  109. ),
  110. 'outbound' => include(
  111. 'options' => include(
  112. 'authentication' => 'xoauth2',
  113. 'host' => 'smtp.gmail.com',
  114. 'port' => 465,
  115. 'ssl' => true,
  116. 'user' => primary_email,
  117. )
  118. ),
  119. 'auth' => include(
  120. 'access_token' => access_token,
  121. 'expires_in' => token_ttl,
  122. 'refresh_token' => refresh_token,
  123. 'scope' => scope_stub,
  124. 'token_type' => 'Bearer',
  125. 'id_token' => id_token,
  126. 'created_at' => Time.zone.now,
  127. 'type' => 'XOAUTH2',
  128. 'client_id' => client_id,
  129. 'client_secret' => client_secret,
  130. ),
  131. )
  132. channel.options[:inbound][:options][:keep_on_server] = true
  133. channel.save
  134. channel = described_class.link_account(request_token, authorization_payload.merge(channel_id: channel.id))
  135. expect(channel.reload.options[:inbound][:options][:keep_on_server]).to be(true)
  136. end
  137. end
  138. context 'API errors' do
  139. before do
  140. stub_request(:post, token_url).to_return(status: response_status, body: response_payload&.to_json, headers: {})
  141. create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret })
  142. end
  143. shared_examples 'failed attempt' do
  144. it 'raises an exception' do
  145. expect do
  146. described_class.link_account(request_token, authorization_payload)
  147. end.to raise_error(RuntimeError, exception_message)
  148. end
  149. end
  150. context '404 invalid_client' do
  151. let(:response_status) { 404 }
  152. let(:response_payload) do
  153. {
  154. error: 'invalid_client',
  155. error_description: 'The OAuth client was not found.'
  156. }
  157. end
  158. let(:exception_message) { 'Request failed! ERROR: invalid_client (The OAuth client was not found.)' }
  159. include_examples 'failed attempt'
  160. end
  161. context '500 Internal Server Error' do
  162. let(:response_status) { 500 }
  163. let(:response_payload) { nil }
  164. let(:exception_message) { 'Request failed! (code: 500)' }
  165. include_examples 'failed attempt'
  166. end
  167. end
  168. end
  169. describe '.refresh_token' do
  170. let!(:authorization_payload) do
  171. {
  172. code: authorization_code,
  173. scope: scope_payload,
  174. authuser: '4',
  175. hd: 'example.com',
  176. prompt: 'consent',
  177. controller: 'external_credentials',
  178. action: 'callback',
  179. provider: provider
  180. }
  181. end
  182. let!(:channel) do
  183. stub_request(:post, token_url).to_return(status: 200, body: token_response_payload.to_json, headers: {})
  184. stub_request(:get, alias_url).to_return(status: 200, body: alias_response_payload.to_json, headers: {})
  185. create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret })
  186. channel = described_class.link_account(request_token, authorization_payload)
  187. # remove stubs and allow new stubbing for tested requests
  188. WebMock.reset!
  189. channel
  190. end
  191. before do
  192. # we check the TTL of tokens and therefore need freeze the time
  193. freeze_time
  194. end
  195. context 'success' do
  196. let!(:expired_at) { channel.options['auth']['created_at'] }
  197. before do
  198. stub_request(:post, token_url).to_return(status: 200, body: response_payload.to_json, headers: {})
  199. end
  200. context 'access_token still valid' do
  201. let(:response_payload) do
  202. {
  203. 'access_token' => access_token,
  204. 'expires_in' => token_ttl,
  205. 'scope' => scope_stub,
  206. 'token_type' => 'Bearer',
  207. 'type' => 'XOAUTH2',
  208. }
  209. end
  210. it 'does not refresh' do
  211. expect do
  212. channel.refresh_xoauth2!
  213. end.not_to change { channel.options['auth']['created_at'] }
  214. end
  215. end
  216. context 'access_token expired' do
  217. let(:refreshed_access_token) { 'some_new_token' }
  218. let(:response_payload) do
  219. {
  220. 'access_token' => refreshed_access_token,
  221. 'expires_in' => token_ttl,
  222. 'scope' => scope_stub,
  223. 'token_type' => 'Bearer',
  224. 'type' => 'XOAUTH2',
  225. }
  226. end
  227. before do
  228. travel 1.hour
  229. end
  230. it 'refreshes token' do
  231. expect do
  232. channel.refresh_xoauth2!
  233. end.to change { channel.options['auth'] }.to include(
  234. 'created_at' => Time.zone.now,
  235. 'access_token' => refreshed_access_token,
  236. )
  237. end
  238. end
  239. end
  240. context 'API errors' do
  241. before do
  242. stub_request(:post, token_url).to_return(status: response_status, body: response_payload&.to_json, headers: {})
  243. # invalidate existing token
  244. travel 1.hour
  245. end
  246. shared_examples 'failed attempt' do
  247. it 'raises an exception' do
  248. expect do
  249. channel.refresh_xoauth2!
  250. end.to raise_error(RuntimeError, exception_message)
  251. end
  252. end
  253. context '400 invalid_client' do
  254. let(:response_status) { 400 }
  255. let(:response_payload) do
  256. {
  257. error: 'invalid_client',
  258. error_description: 'The OAuth client was not found.'
  259. }
  260. end
  261. let(:exception_message) { %r{The OAuth client was not found} }
  262. include_examples 'failed attempt'
  263. end
  264. context '500 Internal Server Error' do
  265. let(:response_status) { 500 }
  266. let(:response_payload) { nil }
  267. let(:exception_message) { %r{code: 500} }
  268. include_examples 'failed attempt'
  269. end
  270. end
  271. end
  272. describe '.request_account_to_link' do
  273. it 'generates authorize_url from credentials' do
  274. google = create(:external_credential, name: provider, credentials: { client_id: client_id, client_secret: client_secret })
  275. request = described_class.request_account_to_link(google.credentials)
  276. expect(request[:authorize_url]).to eq(authorize_url)
  277. end
  278. context 'errors' do
  279. shared_examples 'failed attempt' do
  280. it 'raises an exception' do
  281. expect do
  282. described_class.request_account_to_link(credentials, app_required)
  283. end.to raise_error(Exceptions::UnprocessableEntity, exception_message)
  284. end
  285. end
  286. context 'missing credentials' do
  287. let(:credentials) { nil }
  288. let(:app_required) { true }
  289. let(:exception_message) { 'There is no Google app configured.' }
  290. include_examples 'failed attempt'
  291. end
  292. context 'missing client_id' do
  293. let(:credentials) do
  294. {
  295. client_secret: client_secret
  296. }
  297. end
  298. let(:app_required) { false }
  299. let(:exception_message) { "The required parameter 'client_id' is missing." }
  300. include_examples 'failed attempt'
  301. end
  302. context 'missing client_secret' do
  303. let(:credentials) do
  304. {
  305. client_id: client_id
  306. }
  307. end
  308. let(:app_required) { false }
  309. let(:exception_message) { "The required parameter 'client_secret' is missing." }
  310. include_examples 'failed attempt'
  311. end
  312. end
  313. end
  314. describe '.user_aliases' do
  315. let(:response_status) { 200 }
  316. let(:response_payload) { alias_response_payload }
  317. let(:token) do
  318. {
  319. access_token: access_token,
  320. token_type: 'Bearer'
  321. }
  322. end
  323. before do
  324. stub_request(:get, alias_url).to_return(status: response_status, body: response_payload&.to_json, headers: {})
  325. end
  326. it 'returns the google user email aliases' do
  327. result = described_class.user_aliases(token)
  328. expect(result).to eq([
  329. { name: 'alias1', email: 'alias1@example.com' },
  330. { name: 'alias2', email: 'alias2@example.com' },
  331. { name: 'alias3', email: 'alias3@example.com' }
  332. ])
  333. end
  334. context 'API errors' do
  335. context '401 Unauthorized' do
  336. let(:response_status) { 401 }
  337. let(:response_payload) do
  338. {
  339. error: {
  340. code: 401,
  341. message: 'Invalid Credentials',
  342. errors: [
  343. {
  344. locationType: 'header',
  345. domain: 'global',
  346. message: 'Invalid Credentials',
  347. reason: 'authError',
  348. location: 'Authorization'
  349. }
  350. ]
  351. }
  352. }
  353. end
  354. it 'raises an exception' do
  355. expect do
  356. described_class.user_aliases(token)
  357. end.to raise_error(RuntimeError, 'Request failed! ERROR: Invalid Credentials')
  358. end
  359. end
  360. context '500 Internal Server Error' do
  361. let(:response_status) { 500 }
  362. let(:response_payload) { nil }
  363. it 'raises an exception' do
  364. expect do
  365. described_class.user_aliases(token)
  366. end.to raise_error(RuntimeError, 'Request failed! (code: 500)')
  367. end
  368. end
  369. end
  370. end
  371. describe '.generate_authorize_url' do
  372. it 'generates valid URL' do
  373. url = described_class.generate_authorize_url(client_id)
  374. expect(url).to eq(authorize_url)
  375. end
  376. end
  377. describe '.user_info' do
  378. it 'extracts user information from id_token' do
  379. info = described_class.user_info(id_token)
  380. expect(info[:email]).to eq(primary_email)
  381. end
  382. end
  383. end