# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ require 'rails_helper' RSpec.describe 'Manage > Channels > Microsoft 365 Graph Email', type: :system do let(:client_id) { SecureRandom.uuid } let(:client_secret) { SecureRandom.urlsafe_base64(40) } let(:client_tenant) { SecureRandom.uuid } let(:callback_url) { "#{Setting.get('http_type')}://#{Setting.get('fqdn')}#{Rails.configuration.api_path}/external_credentials/microsoft_graph/callback" } context 'without an existing app configuration' do before do visit '#channels/microsoft_graph' end it 'creates a new app configuration' do find('.btn--success', text: 'Connect Microsoft 365 App').click in_modal do fill_in 'client_id', with: client_id fill_in 'client_secret', with: client_secret fill_in 'client_tenant', with: client_tenant check_input_field_value('callback_url', callback_url) check_copy_to_clipboard_text('callback_url', callback_url) click_on 'Submit' end expect(ExternalCredential.last).to have_attributes( name: 'microsoft_graph', credentials: include({ 'client_id' => client_id, 'client_secret' => client_secret, 'client_tenant' => client_tenant, }) ) end end context 'with an existing app configuration' do let(:external_credential) { create(:microsoft_graph_credential) } before do external_credential end context 'when adding an account' do let(:shared_mailbox) { Faker::Internet.unique.email } before do visit '#channels/microsoft_graph' end it 'shows mailbox type dialog' do find('.btn--success', text: 'Add Account').click in_modal do check_select_field_value('mailbox_type', 'user') expect(page).to have_no_css('[name="shared_mailbox"]') set_select_field_value('mailbox_type', 'shared') click_on 'Authenticate' expect(page).to have_validation_message_for('[name="shared_mailbox"]') set_input_field_value('shared_mailbox', shared_mailbox) # We stop short of redirecting to the Microsoft login page. # click_on 'Authenticate' end end end context 'when editing an account' do let(:channel) { create(:microsoft_graph_channel, group: group1, inbound_options: { 'folder_id' => folder_id1, 'keep_on_server' => true }, active: false) } let(:group1) { create(:group) } let(:group2) { create(:group) } let(:folder_id1) { Base64.strict_encode64(Faker::Crypto.unique.sha256) } let(:folder_id2) { Base64.strict_encode64(Faker::Crypto.unique.sha256) } let(:folders) do [ { 'id' => folder_id1, 'displayName' => Faker::Lorem.unique.word, 'childFolders' => [], }, { 'id' => folder_id2, 'displayName' => Faker::Lorem.unique.word, 'childFolders' => [], }, ] end before do channel && group2 allow_any_instance_of(Channel).to receive(:refresh_xoauth2!).and_return(true) allow_any_instance_of(MicrosoftGraph).to receive(:get_message_folders_tree).and_return(folders) allow(EmailHelper::Probe).to receive(:inbound).and_return({ result: 'ok' }) visit '#channels/microsoft_graph' find('.js-editInbound', text: 'Edit').click end it 'displays inbound configuration dialog' do in_modal do # TODO: Re-enable when the tree select filter mechanism is fixed to account for primitive values. # check_tree_select_field_value('group_id', group1.id.to_s) check_tree_select_field_value('options::folder_id', folder_id1) check_select_field_value('options::keep_on_server', 'true') set_tree_select_value('group_id', group2.id.to_s) set_tree_select_value('options::folder_id', folder_id2) set_select_field_label('options::keep_on_server', 'no') click_on 'Save' end expect(channel.reload).to have_attributes( group_id: group2.id, options: include({ 'inbound' => include({ 'options' => include({ 'folder_id' => folder_id2, 'keep_on_server' => false, }), }), }), ) end end context 'when editing destination group' do let(:channel) { create(:microsoft_graph_channel, group: group1, active: false) } let(:group1) { create(:group) } let(:group2) { create(:group) } let(:folders) { [] } before do channel && group2 allow_any_instance_of(Channel).to receive(:refresh_xoauth2!).and_return(true) allow_any_instance_of(MicrosoftGraph).to receive(:get_message_folders_tree).and_return(folders) allow(EmailHelper::Probe).to receive(:inbound).and_return({ result: 'ok' }) visit '#channels/microsoft_graph' find('.js-channelGroupChange', text: group1.name).click end it 'displays destination group dialog' do in_modal do # TODO: Re-enable when the tree select filter mechanism is fixed to account for primitive values. # check_tree_select_field_value('group_id', group1.id.to_s) set_tree_select_value('group_id', group2.id.to_s) click_on 'Submit' end expect(channel.reload).to have_attributes(group_id: group2.id) end end context 'when toggling an account' do let(:channel) { create(:microsoft_graph_channel, active: false) } before do channel visit '#channels/microsoft_graph' end it 'switches channel between enabled and disabled state' do find('.js-enable', text: 'Enable').click expect(channel.reload.active).to be(true) find('.js-disable', text: 'Disable').click expect(channel.reload.active).to be(false) end end context 'when deleting an account' do let(:channel) { create(:microsoft_graph_channel, active: false) } let(:email_address) { create(:email_address, email: channel.options.dig('inbound', 'options', 'user'), channel: channel) } before do channel && email_address visit '#channels/microsoft_graph' find('.js-delete', text: 'Delete').click end it 'destroys the channel and the associated email address' do in_modal do click_on 'Yes' end expect { channel.reload }.to raise_error(ActiveRecord::RecordNotFound) expect(page).to have_content('Notice: Unassigned email addresses, assign them to a channel or delete them.') find('.js-emailAddressDelete').click in_modal do click_on 'Delete' end expect { email_address.reload }.to raise_error(ActiveRecord::RecordNotFound) end end context 'when being redirected by a successful auth flow' do let(:channel) { create(:microsoft_graph_channel, active: false) } let(:group) { create(:group) } let(:folder_id) { Base64.strict_encode64(Faker::Crypto.unique.sha256) } let(:folders) do [ { 'id' => folder_id, 'displayName' => Faker::Lorem.unique.word, 'childFolders' => [], }, ] end before do channel && group allow_any_instance_of(Channel).to receive(:refresh_xoauth2!).and_return(true) allow_any_instance_of(MicrosoftGraph).to receive(:get_message_folders_tree).and_return(folders) allow(EmailHelper::Probe).to receive(:inbound).and_return({ result: 'ok' }) visit "#channels/microsoft_graph/#{channel.id}" end it 'displays inbound configuration dialog' do in_modal do # TODO: Re-enable when the tree select filter mechanism is fixed to account for primitive values. # check_tree_select_field_value('group_id', Group.first.id.to_s) check_tree_select_field_value('options::folder_id', '') set_tree_select_value('group_id', group.id.to_s) set_tree_select_value('options::folder_id', folder_id) set_select_field_label('options::keep_on_server', 'yes') click_on 'Save' end expect(channel.reload).to have_attributes( group_id: group.id, options: include({ 'inbound' => include({ 'options' => include({ 'folder_id' => folder_id, 'keep_on_server' => true, }), }), }), ) end end context 'when being redirected with a wrong user' do let(:email_address) { Faker::Internet.unique.email } let(:channel) { create(:microsoft_graph_channel, microsoft_user: email_address, active: false) } before do visit "#channels/microsoft_graph/error/user_mismatch/channel/#{channel.id}" end it 'displays user mismatch dialog' do in_modal do expect(page).to have_content('The entered user for reauthentication differs from the user that was used for setting up your Microsoft365 channel initially.') expect(page).to have_content('To avoid fetching an incorrect Microsoft365 mailbox, the reauthentication process was aborted.') expect(page).to have_content('Please start the reauthentication again and enter the correct credentials.') expect(page).to have_content("Current User: #{email_address}") click_on 'Close' end end end context 'when being redirected with an email address already in use' do let(:email_address) { Faker::Internet.unique.email } before do visit "#channels/microsoft_graph/error/duplicate_email_address/param/#{CGI.escapeURIComponent(email_address)}" end it 'displays duplicate email address dialog' do in_modal do expect(page).to have_content("The email address #{email_address} is already in use by another account.") click_on 'Close' end end end context 'when the API throws an error' do let(:channel) { create(:microsoft_graph_channel, active: false) } let(:error) do { message: 'The mailbox is either inactive, soft-deleted, or is hosted on-premise.', code: 'MailboxNotEnabledForRESTAPI', } end before do channel allow_any_instance_of(Channel).to receive(:refresh_xoauth2!).and_return(true) allow_any_instance_of(MicrosoftGraph).to receive(:get_message_folders_tree).and_raise(MicrosoftGraph::ApiError, error) visit '#channels/microsoft_graph' find('.js-editInbound', text: 'Edit').click end it 'displays original error message and a helpful hint' do in_modal do expect(page).to have_content("#{error[:message]} (#{error[:code]})") 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.') click_on 'Cancel & Go Back' end end end end def check_copy_to_clipboard_text(field_name, clipboard_text) find(".js-copy[data-target-field='#{field_name}']").click # Add a temporary text input element to the page, so we can paste the clipboard text into it and compare the value. # Programmatic clipboard management requires extra browser permissions and does not work in all of them. page.execute_script "$('').insertAfter($('input[name=#{field_name}]'));" input_field = find("input[name='clipboard_#{field_name}']") .send_keys('') .click .send_keys([magic_key, 'v']) expect(input_field.value).to eq(clipboard_text) page.execute_script "$('input[name=\"clipboard_#{field_name}\"]').addClass('is-hidden');" end end