microsoft_graph_spec.rb 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. # Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
  2. require 'rails_helper'
  3. RSpec.describe 'Manage > Channels > Microsoft 365 Graph Email', type: :system do
  4. let(:client_id) { SecureRandom.uuid }
  5. let(:client_secret) { SecureRandom.urlsafe_base64(40) }
  6. let(:client_tenant) { SecureRandom.uuid }
  7. let(:callback_url) { "#{Setting.get('http_type')}://#{Setting.get('fqdn')}#{Rails.configuration.api_path}/external_credentials/microsoft_graph/callback" }
  8. context 'without an existing app configuration' do
  9. before do
  10. visit '#channels/microsoft_graph'
  11. end
  12. it 'creates a new app configuration' do
  13. find('.btn--success', text: 'Connect Microsoft 365 App').click
  14. in_modal do
  15. fill_in 'client_id', with: client_id
  16. fill_in 'client_secret', with: client_secret
  17. fill_in 'client_tenant', with: client_tenant
  18. check_input_field_value('callback_url', callback_url)
  19. check_copy_to_clipboard_text('callback_url', callback_url)
  20. click_on 'Submit'
  21. end
  22. expect(ExternalCredential.last).to have_attributes(
  23. name: 'microsoft_graph',
  24. credentials: include({
  25. 'client_id' => client_id,
  26. 'client_secret' => client_secret,
  27. 'client_tenant' => client_tenant,
  28. })
  29. )
  30. end
  31. end
  32. context 'with an existing app configuration' do
  33. let(:external_credential) { create(:microsoft_graph_credential) }
  34. before do
  35. external_credential
  36. end
  37. context 'when adding an account' do
  38. let(:shared_mailbox) { Faker::Internet.unique.email }
  39. before do
  40. visit '#channels/microsoft_graph'
  41. end
  42. it 'shows mailbox type dialog' do
  43. find('.btn--success', text: 'Add Account').click
  44. in_modal do
  45. check_select_field_value('mailbox_type', 'user')
  46. expect(page).to have_no_css('[name="shared_mailbox"]')
  47. set_select_field_value('mailbox_type', 'shared')
  48. click_on 'Authenticate'
  49. expect(page).to have_validation_message_for('[name="shared_mailbox"]')
  50. set_input_field_value('shared_mailbox', shared_mailbox)
  51. # We stop short of redirecting to the Microsoft login page.
  52. # click_on 'Authenticate'
  53. end
  54. end
  55. end
  56. context 'when editing an account' do
  57. let(:channel) { create(:microsoft_graph_channel, group: group1, inbound_options: { 'folder_id' => folder_id1, 'keep_on_server' => true }, active: false) }
  58. let(:group1) { create(:group) }
  59. let(:group2) { create(:group) }
  60. let(:folder_id1) { Base64.strict_encode64(Faker::Crypto.unique.sha256) }
  61. let(:folder_id2) { Base64.strict_encode64(Faker::Crypto.unique.sha256) }
  62. let(:folders) do
  63. [
  64. {
  65. 'id' => folder_id1,
  66. 'displayName' => Faker::Lorem.unique.word,
  67. 'childFolders' => [],
  68. },
  69. {
  70. 'id' => folder_id2,
  71. 'displayName' => Faker::Lorem.unique.word,
  72. 'childFolders' => [],
  73. },
  74. ]
  75. end
  76. before do
  77. channel && group2
  78. allow_any_instance_of(Channel).to receive(:refresh_xoauth2!).and_return(true)
  79. allow_any_instance_of(MicrosoftGraph).to receive(:get_message_folders_tree).and_return(folders)
  80. allow(EmailHelper::Probe).to receive(:inbound).and_return({ result: 'ok' })
  81. visit '#channels/microsoft_graph'
  82. find('.js-editInbound', text: 'Edit').click
  83. end
  84. it 'displays inbound configuration dialog' do
  85. in_modal do
  86. # TODO: Re-enable when the tree select filter mechanism is fixed to account for primitive values.
  87. # check_tree_select_field_value('group_id', group1.id.to_s)
  88. check_tree_select_field_value('options::folder_id', folder_id1)
  89. check_select_field_value('options::keep_on_server', 'true')
  90. set_tree_select_value('group_id', group2.id.to_s)
  91. set_tree_select_value('options::folder_id', folder_id2)
  92. set_select_field_label('options::keep_on_server', 'no')
  93. click_on 'Save'
  94. end
  95. expect(channel.reload).to have_attributes(
  96. group_id: group2.id,
  97. options: include({
  98. 'inbound' => include({
  99. 'options' => include({
  100. 'folder_id' => folder_id2,
  101. 'keep_on_server' => false,
  102. }),
  103. }),
  104. }),
  105. )
  106. end
  107. end
  108. context 'when editing destination group' do
  109. let(:channel) { create(:microsoft_graph_channel, group: group1, active: false) }
  110. let(:group1) { create(:group) }
  111. let(:group2) { create(:group) }
  112. let(:folders) { [] }
  113. before do
  114. channel && group2
  115. allow_any_instance_of(Channel).to receive(:refresh_xoauth2!).and_return(true)
  116. allow_any_instance_of(MicrosoftGraph).to receive(:get_message_folders_tree).and_return(folders)
  117. allow(EmailHelper::Probe).to receive(:inbound).and_return({ result: 'ok' })
  118. visit '#channels/microsoft_graph'
  119. find('.js-channelGroupChange', text: group1.name).click
  120. end
  121. it 'displays destination group dialog' do
  122. in_modal do
  123. # TODO: Re-enable when the tree select filter mechanism is fixed to account for primitive values.
  124. # check_tree_select_field_value('group_id', group1.id.to_s)
  125. set_tree_select_value('group_id', group2.id.to_s)
  126. click_on 'Submit'
  127. end
  128. expect(channel.reload).to have_attributes(group_id: group2.id)
  129. end
  130. end
  131. context 'when toggling an account' do
  132. let(:channel) { create(:microsoft_graph_channel, active: false) }
  133. before do
  134. channel
  135. visit '#channels/microsoft_graph'
  136. end
  137. it 'switches channel between enabled and disabled state' do
  138. find('.js-enable', text: 'Enable').click
  139. expect(channel.reload.active).to be(true)
  140. find('.js-disable', text: 'Disable').click
  141. expect(channel.reload.active).to be(false)
  142. end
  143. end
  144. context 'when deleting an account' do
  145. let(:channel) { create(:microsoft_graph_channel, active: false) }
  146. let(:email_address) { create(:email_address, email: channel.options.dig('inbound', 'options', 'user'), channel: channel) }
  147. before do
  148. channel && email_address
  149. visit '#channels/microsoft_graph'
  150. find('.js-delete', text: 'Delete').click
  151. end
  152. it 'destroys the channel and the associated email address' do
  153. in_modal do
  154. click_on 'Yes'
  155. end
  156. expect { channel.reload }.to raise_error(ActiveRecord::RecordNotFound)
  157. expect(page).to have_content('Notice: Unassigned email addresses, assign them to a channel or delete them.')
  158. find('.js-emailAddressDelete').click
  159. in_modal do
  160. click_on 'Delete'
  161. end
  162. expect { email_address.reload }.to raise_error(ActiveRecord::RecordNotFound)
  163. end
  164. end
  165. context 'when being redirected by a successful auth flow' do
  166. let(:channel) { create(:microsoft_graph_channel, active: false) }
  167. let(:group) { create(:group) }
  168. let(:folder_id) { Base64.strict_encode64(Faker::Crypto.unique.sha256) }
  169. let(:folders) do
  170. [
  171. {
  172. 'id' => folder_id,
  173. 'displayName' => Faker::Lorem.unique.word,
  174. 'childFolders' => [],
  175. },
  176. ]
  177. end
  178. before do
  179. channel && group
  180. allow_any_instance_of(Channel).to receive(:refresh_xoauth2!).and_return(true)
  181. allow_any_instance_of(MicrosoftGraph).to receive(:get_message_folders_tree).and_return(folders)
  182. allow(EmailHelper::Probe).to receive(:inbound).and_return({ result: 'ok' })
  183. visit "#channels/microsoft_graph/#{channel.id}"
  184. end
  185. it 'displays inbound configuration dialog' do
  186. in_modal do
  187. # TODO: Re-enable when the tree select filter mechanism is fixed to account for primitive values.
  188. # check_tree_select_field_value('group_id', Group.first.id.to_s)
  189. check_tree_select_field_value('options::folder_id', '')
  190. set_tree_select_value('group_id', group.id.to_s)
  191. set_tree_select_value('options::folder_id', folder_id)
  192. set_select_field_label('options::keep_on_server', 'yes')
  193. click_on 'Save'
  194. end
  195. expect(channel.reload).to have_attributes(
  196. group_id: group.id,
  197. options: include({
  198. 'inbound' => include({
  199. 'options' => include({
  200. 'folder_id' => folder_id,
  201. 'keep_on_server' => true,
  202. }),
  203. }),
  204. }),
  205. )
  206. end
  207. end
  208. context 'when being redirected with a wrong user' do
  209. let(:email_address) { Faker::Internet.unique.email }
  210. let(:channel) { create(:microsoft_graph_channel, microsoft_user: email_address, active: false) }
  211. before do
  212. visit "#channels/microsoft_graph/error/user_mismatch/channel/#{channel.id}"
  213. end
  214. it 'displays user mismatch dialog' do
  215. in_modal do
  216. expect(page).to have_content('The entered user for reauthentication differs from the user that was used for setting up your Microsoft365 channel initially.')
  217. expect(page).to have_content('To avoid fetching an incorrect Microsoft365 mailbox, the reauthentication process was aborted.')
  218. expect(page).to have_content('Please start the reauthentication again and enter the correct credentials.')
  219. expect(page).to have_content("Current User: #{email_address}")
  220. click_on 'Close'
  221. end
  222. end
  223. end
  224. context 'when being redirected with an email address already in use' do
  225. let(:email_address) { Faker::Internet.unique.email }
  226. before do
  227. visit "#channels/microsoft_graph/error/duplicate_email_address/param/#{CGI.escapeURIComponent(email_address)}"
  228. end
  229. it 'displays duplicate email address dialog' do
  230. in_modal do
  231. expect(page).to have_content("The email address #{email_address} is already in use by another account.")
  232. click_on 'Close'
  233. end
  234. end
  235. end
  236. context 'when the API throws an error' do
  237. let(:channel) { create(:microsoft_graph_channel, active: false) }
  238. let(:error) do
  239. {
  240. message: 'The mailbox is either inactive, soft-deleted, or is hosted on-premise.',
  241. code: 'MailboxNotEnabledForRESTAPI',
  242. }
  243. end
  244. before do
  245. channel
  246. allow_any_instance_of(Channel).to receive(:refresh_xoauth2!).and_return(true)
  247. allow_any_instance_of(MicrosoftGraph).to receive(:get_message_folders_tree).and_raise(MicrosoftGraph::ApiError, error)
  248. visit '#channels/microsoft_graph'
  249. find('.js-editInbound', text: 'Edit').click
  250. end
  251. it 'displays original error message and a helpful hint' do
  252. in_modal do
  253. expect(page).to have_content("#{error[:message]} (#{error[:code]})")
  254. expect(page).to have_content('Did you verify that the user has access to the mailbox? Or consider removing this channel and switch to using a different mailbox type.')
  255. click_on 'Cancel & Go Back'
  256. end
  257. end
  258. end
  259. end
  260. def check_copy_to_clipboard_text(field_name, clipboard_text)
  261. find(".js-copy[data-target-field='#{field_name}']").click
  262. # Add a temporary text input element to the page, so we can paste the clipboard text into it and compare the value.
  263. # Programmatic clipboard management requires extra browser permissions and does not work in all of them.
  264. page.execute_script "$('<input name=\"clipboard_#{field_name}\" type=\"text\" class=\"form-control\">').insertAfter($('input[name=#{field_name}]'));"
  265. input_field = find("input[name='clipboard_#{field_name}']")
  266. .send_keys('')
  267. .click
  268. .send_keys([magic_key, 'v'])
  269. expect(input_field.value).to eq(clipboard_text)
  270. page.execute_script "$('input[name=\"clipboard_#{field_name}\"]').addClass('is-hidden');"
  271. end
  272. end