- Fixes #3292: Unable to manage Microsoft365/Google Channel accounts if XOAUTH2 token can't get refreshed.

- Fixes #3294: Failing XOAUTH2 Channel (Gmail / Office365) token refresh causes following Channels not to be fetched.
This commit is contained in:
Thorsten Eckel 2020-11-20 11:54:09 +01:00
parent 21708969fd
commit b7a4cc6d6a
5 changed files with 145 additions and 56 deletions

View file

@ -8,7 +8,6 @@ class Channel < ApplicationModel
store :options
store :preferences
after_initialize :refresh_xoauth2!
after_create :email_address_check
after_update :email_address_check
after_destroy :email_address_check
@ -48,7 +47,8 @@ fetch one account
adapter_options = options[:inbound][:options]
end
begin
refresh_xoauth2!
driver_class = self.class.driver_class(adapter)
driver_instance = driver_class.new
return if !force && !driver_instance.fetchable?(self)
@ -69,7 +69,6 @@ fetch one account
save!
false
end
end
=begin
@ -250,14 +249,17 @@ send via account
adapter = options[:outbound][:adapter]
adapter_options = options[:outbound][:options]
end
result = nil
begin
refresh_xoauth2!
driver_class = self.class.driver_class(adapter)
driver_instance = driver_class.new
result = driver_instance.send(adapter_options, params, notification)
self.status_out = 'ok'
self.last_log_out = ''
save!
result
rescue => e
error = "Can't use Channel::Driver::#{adapter.to_classname}: #{e.inspect}"
logger.error error
@ -267,8 +269,6 @@ send via account
save!
raise error
end
result
end
=begin
@ -338,6 +338,7 @@ get instance of channel driver
def refresh_xoauth2!
return if options.dig(:auth, :type) != 'XOAUTH2'
return if ApplicationHandleInfo.current == 'application_server'
result = ExternalCredential.refresh_token(options[:auth][:provider], options[:auth])
@ -347,16 +348,10 @@ get instance of channel driver
return if new_record?
# ATTENTION: We don't want to execute any other callbacks here
# because `after_initialize` leaks the current scope of the Channel class
# as described here: https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-new
# which leads to unexpected effects like:
# Channel.where(area: 'Google::Account').limit(1).find_each { |c| puts Channel.all.to_sql }
# => "SELECT "channels".* FROM "channels" WHERE "channels"."area" = 'Google::Account'"
update_column(:options, options) # rubocop:disable Rails/SkipsModelValidations
save!
rescue => e
logger.error e
raise "Failed to refresh XOAUTH2 access_token of provider '#{options[:auth][:provider]}'! #{e.inspect}"
raise "Failed to refresh XOAUTH2 access_token of provider '#{options[:auth][:provider]}': #{e.message}"
end
private

View file

@ -1,9 +1,5 @@
FactoryBot.define do
factory :channel do
# ensure the `refresh_xoauth2!` `after_initialize` callback gets executed
# https://stackoverflow.com/questions/5916162/problem-with-factory-girl-association-and-after-initialize#comment51639005_28057070
initialize_with { new(attributes) }
area { 'Email::Dummy' }
group { ::Group.find(1) }
active { true }
@ -16,13 +12,23 @@ FactoryBot.define do
area { 'Email::Account' }
options do
{
inbound: {
inbound: inbound,
outbound: outbound,
}
end
transient do
inbound do
{
adapter: 'null', options: {}
},
outbound: {
}
end
outbound do
{
adapter: 'sendmail'
}
}
end
end
end

View file

@ -2,10 +2,94 @@ require 'rails_helper'
RSpec.describe Channel, type: :model do
describe '.fetch' do
describe '#refresh_xoauth2! fails' do
let(:channel) { create(:channel, area: 'SomeXOAUTH2::Account', options: { adapter: 'DummyXOAUTH2', auth: { type: 'XOAUTH2' } }) }
before do
allow(ExternalCredential).to receive(:refresh_token).and_raise(RuntimeError)
end
it 'changes Channel status to error' do
expect { described_class.fetch }.to change { channel.reload.status_in }.to('error')
end
end
context 'when one adapter fetch fails' do
let(:failing_adapter_class) do
Class.new(Channel::Driver::Null) do
def fetchable?(*)
true
end
def fetch(*)
raise 'some error'
end
end
end
let(:dummy_adapter_class) do
Class.new(Channel::Driver::Null) do
def fetchable?(*)
true
end
end
end
let(:failing_channel) do
create(:email_channel, inbound: {
adapter: 'failing',
options: {}
})
end
let(:other_channel) do
create(:email_channel, inbound: {
adapter: 'dummy',
options: {}
})
end
before do
allow(described_class).to receive(:driver_class).with('dummy').and_return(dummy_adapter_class)
allow(described_class).to receive(:driver_class).with('failing').and_return(failing_adapter_class)
failing_channel
other_channel
end
it 'adds error flag to the failing Channel' do
expect { described_class.fetch }.to change { failing_channel.reload.preferences[:last_fetch] }.and change { failing_channel.reload.status_in }.to('error')
end
it 'fetches others anyway' do
expect { described_class.fetch }.to change { other_channel.reload.preferences[:last_fetch] }.and change { other_channel.reload.status_in }.to('ok')
end
end
end
context 'when authentication type is XOAUTH2' do
shared_examples 'common XOAUTH2' do
context 'when token refresh fails' do
let(:exception) { DummyExternalCredentialsBackendError.new('something unexpected happened here') }
before do
stub_const('DummyExternalCredentialsBackendError', Class.new(StandardError))
allow(ExternalCredential).to receive(:refresh_token).and_raise(exception)
end
it 'raises RuntimeError' do
expect { channel.refresh_xoauth2! }.to raise_exception(RuntimeError, /#{exception.message}/)
end
end
context 'when non-XOAUTH2 channels are present' do
let!(:email_address) { create(:email_address, channel: create(:channel, area: 'Some::Other')) }

View file

@ -1,6 +1,8 @@
require 'rails_helper'
RSpec.describe 'Gmail XOAUTH2' do # rubocop:disable RSpec/DescribeClass
let(:channel) { create(:google_channel) }
let(:channel) do
create(:google_channel).tap(&:refresh_xoauth2!)
end
before do
required_envs = %w[GMAIL_REFRESH_TOKEN GMAIL_CLIENT_ID GMAIL_CLIENT_SECRET GMAIL_USER]

View file

@ -1,6 +1,8 @@
require 'rails_helper'
RSpec.describe 'Microsoft365 XOAUTH2' do # rubocop:disable RSpec/DescribeClass
let(:channel) { create(:microsoft365_channel) }
let(:channel) do
create(:microsoft365_channel).tap(&:refresh_xoauth2!)
end
before do
required_envs = %w[MICROSOFT365_REFRESH_TOKEN MICROSOFT365_CLIENT_ID MICROSOFT365_CLIENT_SECRET MICROSOFT365_USER]