Commit dbac37d4 authored by Matthias Käppler's avatar Matthias Käppler

Merge branch 'qmnguyen0711/1263-convert-mailroom-to-use-webhook' into 'master'

Implement an Internal API to handle mailroom webhook

See merge request gitlab-org/gitlab!76724
parents 300635e8 456f617c
......@@ -228,6 +228,10 @@ production: &base
# client_id: "YOUR-CLIENT-ID"
# client_secret: "YOUR-CLIENT-SECRET"
# File that contains the shared secret key for verifying access for mailroom's incoming_email.
# Default is '.gitlab_mailroom_secret' relative to Rails.root (i.e. root of the GitLab app).
# secret_file: /home/git/gitlab/.gitlab_mailroom_secret
## Consolidated object store config
## This will only take effect if the object_store sections are not defined
## within the types (e.g. artifacts, lfs, etc.).
......
:mailboxes:
<%
require_relative "../lib/gitlab/mail_room" unless defined?(Gitlab::MailRoom)
Gitlab::MailRoom.enabled_configs.each do |config|
Gitlab::MailRoom.enabled_configs.each do |_key, config|
%>
-
:host: <%= config[:host].to_json %>
......
......@@ -299,6 +299,7 @@ module API
mount ::API::Internal::Lfs
mount ::API::Internal::Pages
mount ::API::Internal::Kubernetes
mount ::API::Internal::MailRoom
version 'v3', using: :path do
# Although the following endpoints are kept behind V3 namespace,
......
# frozen_string_literal: true
module API
# This internal endpoint receives webhooks sent from the MailRoom component.
# This component constantly listens to configured email accounts. When it
# finds any incoming email or service desk email, it makes a POST request to
# this endpoint. The target mailbox type is indicated in the request path.
# The email raw content is attached to the request body.
#
# For more information, please visit https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/644
module Internal
class MailRoom < ::API::Base
feature_category :service_desk
before do
authenticate_gitlab_mailroom_request!
end
helpers do
def authenticate_gitlab_mailroom_request!
unauthorized! unless Gitlab::MailRoom::Authenticator.verify_api_request(headers, params[:mailbox_type])
end
end
namespace 'internal' do
namespace 'mail_room' do
params do
requires :mailbox_type, type: String,
desc: 'The destination mailbox type configuration. Must either be incoming_email or service_desk_email'
end
post "/*mailbox_type" do
worker = Gitlab::MailRoom.worker_for(params[:mailbox_type])
begin
worker.perform_async(request.body.read)
rescue Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError => e
status 400
break { success: false, message: e.message }
end
status 200
{ success: true }
end
end
end
end
end
end
......@@ -13,26 +13,38 @@ module Gitlab
module ClassMethods
include Gitlab::Utils::StrongMemoize
def decode_jwt_for_issuer(issuer, encoded_message)
JWT.decode(
encoded_message,
secret,
true,
{ iss: issuer, verify_iss: true, algorithm: 'HS256' }
)
def decode_jwt(encoded_message, jwt_secret = secret, issuer: nil, iat_after: nil)
options = { algorithm: 'HS256' }
options = options.merge(iss: issuer, verify_iss: true) if issuer.present?
options = options.merge(verify_iat: true) if iat_after.present?
decoded_message = JWT.decode(encoded_message, jwt_secret, true, options)
payload = decoded_message[0]
if iat_after.present?
raise JWT::DecodeError, "JWT iat claim is missing" if payload['iat'].blank?
iat = payload['iat'].to_i
raise JWT::ExpiredSignature, 'Token has expired' if iat < iat_after.to_i
end
decoded_message
end
def secret
strong_memoize(:secret) do
Base64.strict_decode64(File.read(secret_path).chomp).tap do |bytes|
raise "#{secret_path} does not contain #{SECRET_LENGTH} bytes" if bytes.length != SECRET_LENGTH
end
read_secret(secret_path)
end
end
def read_secret(path)
Base64.strict_decode64(File.read(path).chomp).tap do |bytes|
raise "#{path} does not contain #{SECRET_LENGTH} bytes" if bytes.length != SECRET_LENGTH
end
end
def write_secret
def write_secret(path = secret_path)
bytes = SecureRandom.random_bytes(SECRET_LENGTH)
File.open(secret_path, 'w:BINARY', 0600) do |f|
File.open(path, 'w:BINARY', 0600) do |f|
f.chmod(0600) # If the file already existed, the '0600' passed to 'open' above was a no-op.
f.write(Base64.strict_encode64(bytes))
end
......
......@@ -11,7 +11,7 @@ module Gitlab
class << self
def verify_api_request(request_headers)
decode_jwt_for_issuer(JWT_ISSUER, request_headers[INTERNAL_API_REQUEST_HEADER])
decode_jwt(request_headers[INTERNAL_API_REQUEST_HEADER], issuer: JWT_ISSUER)
rescue JWT::DecodeError
nil
end
......
......@@ -25,7 +25,7 @@ module Gitlab
# Email specific configuration which is merged with configuration
# fetched from YML config file.
ADDRESS_SPECIFIC_CONFIG = {
MAILBOX_SPECIFIC_CONFIGS = {
incoming_email: {
queue: 'email_receiver',
worker: 'EmailReceiverWorker'
......@@ -38,7 +38,15 @@ module Gitlab
class << self
def enabled_configs
@enabled_configs ||= configs.select { |config| enabled?(config) }
@enabled_configs ||= configs.select { |_key, config| enabled?(config) }
end
def enabled_mailbox_types
enabled_configs.keys.map(&:to_s)
end
def worker_for(mailbox_type)
MAILBOX_SPECIFIC_CONFIGS.try(:[], mailbox_type.to_sym).try(:[], :worker).try(:safe_constantize)
end
private
......@@ -48,7 +56,7 @@ module Gitlab
end
def configs
ADDRESS_SPECIFIC_CONFIG.keys.map { |key| fetch_config(key) }
MAILBOX_SPECIFIC_CONFIGS.to_h { |key, _value| [key, fetch_config(key)] }
end
def fetch_config(config_key)
......@@ -63,7 +71,7 @@ module Gitlab
def merged_configs(config_key)
yml_config = load_yaml.fetch(config_key, {})
specific_config = ADDRESS_SPECIFIC_CONFIG.fetch(config_key, {})
specific_config = MAILBOX_SPECIFIC_CONFIGS.fetch(config_key, {})
DEFAULT_CONFIG.merge(specific_config, yml_config) do |_key, oldval, newval|
newval.nil? ? oldval : newval
end
......
# frozen_string_literal: true
module Gitlab
module MailRoom
class Authenticator
include JwtAuthenticatable
SecretConfigurationError = Class.new(StandardError)
INTERNAL_API_REQUEST_HEADER = 'Gitlab-Mailroom-Api-Request'
INTERNAL_API_REQUEST_JWT_ISSUER = 'gitlab-mailroom'
# Only allow token generated within the last 5 minutes
EXPIRATION = 5.minutes
class << self
def verify_api_request(request_headers, mailbox_type)
mailbox_type = mailbox_type.to_sym
return false if enabled_configs[mailbox_type].blank?
decode_jwt(
request_headers[INTERNAL_API_REQUEST_HEADER],
secret(mailbox_type),
issuer: INTERNAL_API_REQUEST_JWT_ISSUER, iat_after: Time.current - EXPIRATION
)
rescue JWT::DecodeError => e
::Gitlab::AppLogger.warn("Fail to decode MailRoom JWT token: #{e.message}") if Rails.env.development?
false
end
def secret(mailbox_type)
strong_memoize("jwt_secret_#{mailbox_type}".to_sym) do
secret_path = enabled_configs[mailbox_type][:secret_file]
raise SecretConfigurationError, "#{mailbox_type}'s secret_file configuration is missing" if secret_path.blank?
begin
read_secret(secret_path)
rescue StandardError => e
raise SecretConfigurationError, "Fail to read #{mailbox_type}'s secret: #{e.message}"
end
end
end
def enabled_configs
Gitlab::MailRoom.enabled_configs
end
end
end
end
end
......@@ -116,7 +116,7 @@ module Gitlab
jwt_token = params[param_key]
raise "Empty JWT param: #{param_key}" if jwt_token.blank?
payload = Gitlab::Workhorse.decode_jwt(jwt_token).first
payload = Gitlab::Workhorse.decode_jwt_with_issuer(jwt_token).first
raise "Invalid JWT payload: not a Hash" unless payload.is_a?(Hash)
upload_params = payload.fetch(JWT_PARAM_FIXED_KEY, {})
......@@ -172,7 +172,7 @@ module Gitlab
encoded_message = env.delete(RACK_ENV_KEY)
return @app.call(env) if encoded_message.blank?
message = ::Gitlab::Workhorse.decode_jwt(encoded_message)[0]
message = ::Gitlab::Workhorse.decode_jwt_with_issuer(encoded_message)[0]
::Gitlab::Middleware::Multipart::Handler.new(env, message).with_open_files do
@app.call(env)
......
......@@ -10,7 +10,7 @@ module Gitlab
class << self
def verify_api_request(request_headers)
decode_jwt_for_issuer('gitlab-pages', request_headers[INTERNAL_API_REQUEST_HEADER])
decode_jwt(request_headers[INTERNAL_API_REQUEST_HEADER], issuer: 'gitlab-pages')
rescue JWT::DecodeError
false
end
......
......@@ -203,11 +203,11 @@ module Gitlab
end
def verify_api_request!(request_headers)
decode_jwt(request_headers[INTERNAL_API_REQUEST_HEADER])
decode_jwt_with_issuer(request_headers[INTERNAL_API_REQUEST_HEADER])
end
def decode_jwt(encoded_message)
decode_jwt_for_issuer('gitlab-workhorse', encoded_message)
def decode_jwt_with_issuer(encoded_message)
decode_jwt(encoded_message, issuer: 'gitlab-workhorse')
end
def secret_path
......
......@@ -18,8 +18,9 @@ RSpec.describe 'mail_room.yml' do
result = Gitlab::Popen.popen_with_detail(%W(ruby -rerb -e #{cmd}), absolute_path('config'), vars)
output = result.stdout
errors = result.stderr
status = result.status
raise "Error interpreting #{mailroom_config_path}: #{output}" unless status == 0
raise "Error interpreting #{mailroom_config_path}: #{output}\n#{errors}" unless status == 0
YAML.safe_load(output, permitted_classes: [Symbol])
end
......
......@@ -14,17 +14,12 @@ RSpec.describe Gitlab::JwtAuthenticatable do
end
before do
begin
File.delete(test_class.secret_path)
rescue Errno::ENOENT
end
FileUtils.rm_f(test_class.secret_path)
test_class.write_secret
end
describe '.secret' do
subject(:secret) { test_class.secret }
shared_examples 'reading secret from the secret path' do
it 'returns 32 bytes' do
expect(secret).to be_a(String)
expect(secret.length).to eq(32)
......@@ -32,62 +27,170 @@ RSpec.describe Gitlab::JwtAuthenticatable do
end
it 'accepts a trailing newline' do
File.open(test_class.secret_path, 'a') { |f| f.write "\n" }
File.open(secret_path, 'a') { |f| f.write "\n" }
expect(secret.length).to eq(32)
end
it 'raises an exception if the secret file cannot be read' do
File.delete(test_class.secret_path)
File.delete(secret_path)
expect { secret }.to raise_exception(Errno::ENOENT)
end
it 'raises an exception if the secret file contains the wrong number of bytes' do
File.truncate(test_class.secret_path, 0)
File.truncate(secret_path, 0)
expect { secret }.to raise_exception(RuntimeError)
end
end
describe '.secret' do
it_behaves_like 'reading secret from the secret path' do
subject(:secret) { test_class.secret }
let(:secret_path) { test_class.secret_path }
end
end
describe '.read_secret' do
it_behaves_like 'reading secret from the secret path' do
subject(:secret) { test_class.read_secret(secret_path) }
let(:secret_path) { test_class.secret_path }
end
end
describe '.write_secret' do
it 'uses mode 0600' do
expect(File.stat(test_class.secret_path).mode & 0777).to eq(0600)
context 'without an input' do
it 'uses mode 0600' do
expect(File.stat(test_class.secret_path).mode & 0777).to eq(0600)
end
it 'writes base64 data' do
bytes = Base64.strict_decode64(File.read(test_class.secret_path))
expect(bytes).not_to be_empty
end
end
it 'writes base64 data' do
bytes = Base64.strict_decode64(File.read(test_class.secret_path))
context 'with an input' do
let(:another_path) do
Rails.root.join('tmp', 'tests', '.jwt_another_shared_secret')
end
expect(bytes).not_to be_empty
after do
File.delete(another_path)
rescue Errno::ENOENT
end
it 'uses mode 0600' do
test_class.write_secret(another_path)
expect(File.stat(another_path).mode & 0777).to eq(0600)
end
it 'writes base64 data' do
test_class.write_secret(another_path)
bytes = Base64.strict_decode64(File.read(another_path))
expect(bytes).not_to be_empty
end
end
end
describe '.decode_jwt_for_issuer' do
let(:payload) { { 'iss' => 'test_issuer' } }
describe '.decode_jwt' do |decode|
let(:payload) { {} }
context 'use included class secret' do
it 'accepts a correct header' do
encoded_message = JWT.encode(payload, test_class.secret, 'HS256')
expect { test_class.decode_jwt(encoded_message) }.not_to raise_error
end
it 'raises an error when the JWT is not signed' do
encoded_message = JWT.encode(payload, nil, 'none')
expect { test_class.decode_jwt(encoded_message) }.to raise_error(JWT::DecodeError)
end
it 'accepts a correct header' do
encoded_message = JWT.encode(payload, test_class.secret, 'HS256')
it 'raises an error when the header is signed with the wrong secret' do
encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256')
expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.not_to raise_error
expect { test_class.decode_jwt(encoded_message) }.to raise_error(JWT::DecodeError)
end
end
it 'raises an error when the JWT is not signed' do
encoded_message = JWT.encode(payload, nil, 'none')
context 'use an input secret' do
let(:another_secret) { 'another secret' }
it 'accepts a correct header' do
encoded_message = JWT.encode(payload, another_secret, 'HS256')
expect { test_class.decode_jwt(encoded_message, another_secret) }.not_to raise_error
end
expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError)
it 'raises an error when the JWT is not signed' do
encoded_message = JWT.encode(payload, nil, 'none')
expect { test_class.decode_jwt(encoded_message, another_secret) }.to raise_error(JWT::DecodeError)
end
it 'raises an error when the header is signed with the wrong secret' do
encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256')
expect { test_class.decode_jwt(encoded_message, another_secret) }.to raise_error(JWT::DecodeError)
end
end
it 'raises an error when the header is signed with the wrong secret' do
encoded_message = JWT.encode(payload, 'wrongsecret', 'HS256')
context 'issuer option' do
let(:payload) { { 'iss' => 'test_issuer' } }
it 'returns decoded payload if issuer is correct' do
encoded_message = JWT.encode(payload, test_class.secret, 'HS256')
payload = test_class.decode_jwt(encoded_message, issuer: 'test_issuer')
expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError)
expect(payload[0]).to match a_hash_including('iss' => 'test_issuer')
end
it 'raises an error when the issuer is incorrect' do
payload['iss'] = 'somebody else'
encoded_message = JWT.encode(payload, test_class.secret, 'HS256')
expect { test_class.decode_jwt(encoded_message, issuer: 'test_issuer') }.to raise_error(JWT::DecodeError)
end
end
it 'raises an error when the issuer is incorrect' do
payload['iss'] = 'somebody else'
encoded_message = JWT.encode(payload, test_class.secret, 'HS256')
context 'iat_after option' do
it 'returns decoded payload if iat is valid' do
freeze_time do
encoded_message = JWT.encode(payload.merge(iat: (Time.current - 10.seconds).to_i), test_class.secret, 'HS256')
payload = test_class.decode_jwt(encoded_message, iat_after: Time.current - 20.seconds)
expect(payload[0]).to match a_hash_including('iat' => be_a(Integer))
end
end
it 'raises an error if iat is invalid' do
encoded_message = JWT.encode(payload.merge(iat: 'wrong'), test_class.secret, 'HS256')
expect { test_class.decode_jwt_for_issuer('test_issuer', encoded_message) }.to raise_error(JWT::DecodeError)
expect { test_class.decode_jwt(encoded_message, iat_after: true) }.to raise_error(JWT::DecodeError)
end
it 'raises an error if iat is absent' do
encoded_message = JWT.encode(payload, test_class.secret, 'HS256')
expect { test_class.decode_jwt(encoded_message, iat_after: true) }.to raise_error(JWT::DecodeError)
end
it 'raises an error if iat is too far in the past' do
freeze_time do
encoded_message = JWT.encode(payload.merge(iat: (Time.current - 30.seconds).to_i), test_class.secret, 'HS256')
expect do
test_class.decode_jwt(encoded_message, iat_after: Time.current - 20.seconds)
end.to raise_error(JWT::ExpiredSignature, 'Token has expired')
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::MailRoom::Authenticator do
let(:yml_config) do
{
enabled: true,
address: 'address@example.com'
}
end
let(:incoming_email_secret_path) { '/path/to/incoming_email_secret' }
let(:incoming_email_config) { yml_config.merge(secret_file: incoming_email_secret_path) }
let(:service_desk_email_secret_path) { '/path/to/service_desk_email_secret' }
let(:service_desk_email_config) { yml_config.merge(secret_file: service_desk_email_secret_path) }
let(:configs) do
{
incoming_email: incoming_email_config,
service_desk_email: service_desk_email_config
}
end
before do
allow(Gitlab::MailRoom).to receive(:enabled_configs).and_return(configs)
described_class.clear_memoization(:jwt_secret_incoming_email)
described_class.clear_memoization(:jwt_secret_service_desk_email)
end
after do
described_class.clear_memoization(:jwt_secret_incoming_email)
described_class.clear_memoization(:jwt_secret_service_desk_email)
end
around do |example|
freeze_time do
example.run
end
end
describe '#verify_api_request' do
let(:incoming_email_secret) { SecureRandom.hex(16) }
let(:service_desk_email_secret) { SecureRandom.hex(16) }
let(:payload) { { iss: described_class::INTERNAL_API_REQUEST_JWT_ISSUER, iat: (Time.current - 5.minutes + 1.second).to_i } }
before do
allow(described_class).to receive(:secret).with(:incoming_email).and_return(incoming_email_secret)
allow(described_class).to receive(:secret).with(:service_desk_email).and_return(service_desk_email_secret)
end
context 'verify a valid token' do
it 'returns the decoded payload' do
encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256')
headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
expect(described_class.verify_api_request(headers, 'incoming_email')[0]).to match a_hash_including(
"iss" => "gitlab-mailroom",
"iat" => be_a(Integer)
)
encoded_token = JWT.encode(payload, service_desk_email_secret, 'HS256')
headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
expect(described_class.verify_api_request(headers, 'service_desk_email')[0]).to match a_hash_including(
"iss" => "gitlab-mailroom",
"iat" => be_a(Integer)
)
end
end
context 'verify an invalid token' do
it 'returns false' do
encoded_token = JWT.encode(payload, 'wrong secret', 'HS256')
headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false)
end
end
context 'verify a valid token but wrong mailbox type' do
it 'returns false' do
encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256')
headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
expect(described_class.verify_api_request(headers, 'service_desk_email')).to eq(false)
end
end
context 'verify a valid token but wrong issuer' do
let(:payload) { { iss: 'invalid_issuer' } }
it 'returns false' do
encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256')
headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false)
end
end
context 'verify a valid token but expired' do
let(:payload) { { iss: described_class::INTERNAL_API_REQUEST_JWT_ISSUER, iat: (Time.current - 5.minutes - 1.second).to_i } }
it 'returns false' do
encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256')
headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false)
end
end
context 'verify a valid token but wrong header field' do
it 'returns false' do
encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256')
headers = { 'a-wrong-header' => encoded_token }
expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false)
end
end
context 'verify headers for a disabled mailbox type' do
let(:configs) { { service_desk_email: service_desk_email_config } }
it 'returns false' do
encoded_token = JWT.encode(payload, incoming_email_secret, 'HS256')
headers = { described_class::INTERNAL_API_REQUEST_HEADER => encoded_token }
expect(described_class.verify_api_request(headers, 'incoming_email')).to eq(false)
end
end
context 'verify headers for a non-existing mailbox type' do
it 'returns false' do
headers = { described_class::INTERNAL_API_REQUEST_HEADER => 'something' }
expect(described_class.verify_api_request(headers, 'invalid_mailbox_type')).to eq(false)
end
end
end
describe '#secret' do
let(:incoming_email_secret) { SecureRandom.hex(16) }
let(:service_desk_email_secret) { SecureRandom.hex(16) }
context 'the secret is valid' do
before do
allow(described_class).to receive(:read_secret).with(incoming_email_secret_path).and_return(incoming_email_secret).once
allow(described_class).to receive(:read_secret).with(service_desk_email_secret_path).and_return(service_desk_email_secret).once
end
it 'returns the memorized secret from a file' do
expect(described_class.secret(:incoming_email)).to eql(incoming_email_secret)
# The second call does not trigger secret read again
expect(described_class.secret(:incoming_email)).to eql(incoming_email_secret)
expect(described_class).to have_received(:read_secret).with(incoming_email_secret_path).once
expect(described_class.secret(:service_desk_email)).to eql(service_desk_email_secret)
# The second call does not trigger secret read again
expect(described_class.secret(:service_desk_email)).to eql(service_desk_email_secret)
expect(described_class).to have_received(:read_secret).with(service_desk_email_secret_path).once
end
end
context 'the secret file is not configured' do
let(:incoming_email_config) { yml_config }
it 'raises a SecretConfigurationError exception' do
expect do
described_class.secret(:incoming_email)
end.to raise_error(described_class::SecretConfigurationError, "incoming_email's secret_file configuration is missing")
end
end
context 'the secret file not found' do
before do
allow(described_class).to receive(:read_secret).with(incoming_email_secret_path).and_raise(Errno::ENOENT)
end
it 'raises a SecretConfigurationError exception' do
expect do
described_class.secret(:incoming_email)
end.to raise_error(described_class::SecretConfigurationError, "Fail to read incoming_email's secret: No such file or directory")
end
end
end
end
......@@ -30,6 +30,7 @@ RSpec.describe Gitlab::MailRoom do
end
before do
allow(described_class).to receive(:load_yaml).and_return(configs)
described_class.instance_variable_set(:@enabled_configs, nil)
end
......@@ -38,10 +39,6 @@ RSpec.describe Gitlab::MailRoom do
end
describe '#enabled_configs' do
before do
allow(described_class).to receive(:load_yaml).and_return(configs)
end
context 'when both email and address is set' do
it 'returns email configs' do
expect(described_class.enabled_configs.size).to eq(2)
......@@ -79,7 +76,7 @@ RSpec.describe Gitlab::MailRoom do
let(:custom_config) { { enabled: true, address: 'address@example.com' } }
it 'overwrites missing values with the default' do
expect(described_class.enabled_configs.first[:port]).to eq(Gitlab::MailRoom::DEFAULT_CONFIG[:port])
expect(described_class.enabled_configs.each_value.first[:port]).to eq(Gitlab::MailRoom::DEFAULT_CONFIG[:port])
end
end
......@@ -88,7 +85,7 @@ RSpec.describe Gitlab::MailRoom do
it 'returns only encoming_email' do
expect(described_class.enabled_configs.size).to eq(1)
expect(described_class.enabled_configs.first[:worker]).to eq('EmailReceiverWorker')
expect(described_class.enabled_configs.each_value.first[:worker]).to eq('EmailReceiverWorker')
end
end
......@@ -100,11 +97,12 @@ RSpec.describe Gitlab::MailRoom do
end
it 'sets redis config' do
config = described_class.enabled_configs.first
expect(config[:redis_url]).to eq('localhost')
expect(config[:redis_db]).to eq(99)
expect(config[:sentinels]).to eq('yes, them')
config = described_class.enabled_configs.each_value.first
expect(config).to include(
redis_url: 'localhost',
redis_db: 99,
sentinels: 'yes, them'
)
end
end
......@@ -113,7 +111,7 @@ RSpec.describe Gitlab::MailRoom do
let(:custom_config) { { log_path: 'tiny_log.log' } }
it 'expands the log path to an absolute value' do
new_path = Pathname.new(described_class.enabled_configs.first[:log_path])
new_path = Pathname.new(described_class.enabled_configs.each_value.first[:log_path])
expect(new_path.absolute?).to be_truthy
end
end
......@@ -122,9 +120,48 @@ RSpec.describe Gitlab::MailRoom do
let(:custom_config) { { log_path: '/dev/null' } }
it 'leaves the path as-is' do
expect(described_class.enabled_configs.first[:log_path]).to eq '/dev/null'
expect(described_class.enabled_configs.each_value.first[:log_path]).to eq '/dev/null'
end
end
end
end
describe '#enabled_mailbox_types' do
context 'when all mailbox types are enabled' do
it 'returns the mailbox types' do
expect(described_class.enabled_mailbox_types).to match(%w[incoming_email service_desk_email])
end
end
context 'when an mailbox_types is disabled' do
let(:incoming_email_config) { yml_config.merge(enabled: false) }
it 'returns the mailbox types' do
expect(described_class.enabled_mailbox_types).to match(%w[service_desk_email])
end
end
context 'when email is disabled' do
let(:custom_config) { { enabled: false } }
it 'returns an empty array' do
expect(described_class.enabled_mailbox_types).to match_array([])
end
end
end
describe '#worker_for' do
context 'matched mailbox types' do
it 'returns the constantized worker class' do
expect(described_class.worker_for('incoming_email')).to eql(EmailReceiverWorker)
expect(described_class.worker_for('service_desk_email')).to eql(ServiceDeskEmailReceiverWorker)
end
end
context 'non-existing mailbox_type' do
it 'returns nil' do
expect(described_class.worker_for('another_mailbox_type')).to be(nil)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Internal::MailRoom do
let(:base_configs) do
{
enabled: true,
address: 'address@example.com',
port: 143,
ssl: false,
start_tls: false,
mailbox: 'inbox',
idle_timeout: 60,
log_path: Rails.root.join('log', 'mail_room_json.log').to_s,
expunge_deleted: false
}
end
let(:enabled_configs) do
{
incoming_email: base_configs.merge(
secure_file: Rails.root.join('tmp', 'tests', '.incoming_email_secret').to_s
),
service_desk_email: base_configs.merge(
secure_file: Rails.root.join('tmp', 'tests', '.service_desk_email').to_s
)
}
end
let(:auth_payload) { { 'iss' => Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_JWT_ISSUER, 'iat' => (Time.now - 10.seconds).to_i } }
let(:incoming_email_secret) { 'incoming_email_secret' }
let(:service_desk_email_secret) { 'service_desk_email_secret' }
let(:email_content) { fixture_file("emails/commands_in_reply.eml") }
before do
allow(Gitlab::MailRoom::Authenticator).to receive(:secret).with(:incoming_email).and_return(incoming_email_secret)
allow(Gitlab::MailRoom::Authenticator).to receive(:secret).with(:service_desk_email).and_return(service_desk_email_secret)
allow(Gitlab::MailRoom).to receive(:enabled_configs).and_return(enabled_configs)
end
around do |example|
freeze_time do
example.run
end
end
describe "POST /internal/mail_room/*mailbox_type" do
context 'handle incoming_email successfully' do
let(:auth_headers) do
jwt_token = JWT.encode(auth_payload, incoming_email_secret, 'HS256')
{ Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token }
end
it 'schedules a EmailReceiverWorker job with raw email content' do
Sidekiq::Testing.fake! do
expect do
post api("/internal/mail_room/incoming_email"), headers: auth_headers, params: email_content
end.to change { EmailReceiverWorker.jobs.size }.by(1)
end
expect(response).to have_gitlab_http_status(:ok)
job = EmailReceiverWorker.jobs.last
expect(job).to match a_hash_including('args' => [email_content])
end
end
context 'handle service_desk_email successfully' do
let(:auth_headers) do
jwt_token = JWT.encode(auth_payload, service_desk_email_secret, 'HS256')
{ Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token }
end
it 'schedules a ServiceDeskEmailReceiverWorker job with raw email content' do
Sidekiq::Testing.fake! do
expect do
post api("/internal/mail_room/service_desk_email"), headers: auth_headers, params: email_content
end.to change { ServiceDeskEmailReceiverWorker.jobs.size }.by(1)
end
expect(response).to have_gitlab_http_status(:ok)
job = ServiceDeskEmailReceiverWorker.jobs.last
expect(job).to match a_hash_including('args' => [email_content])
end
end
context 'email content exceeds limit' do
let(:auth_headers) do
jwt_token = JWT.encode(auth_payload, incoming_email_secret, 'HS256')
{ Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token }
end
before do
allow(EmailReceiverWorker).to receive(:perform_async).and_raise(
Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError.new(EmailReceiverWorker, email_content.bytesize, email_content.bytesize - 1)
)
end
it 'responds with 400 bad request' do
Sidekiq::Testing.fake! do
expect do
post api("/internal/mail_room/incoming_email"), headers: auth_headers, params: email_content
end.not_to change { EmailReceiverWorker.jobs.size }
end
expect(response).to have_gitlab_http_status(:bad_request)
expect(Gitlab::Json.parse(response.body)).to match a_hash_including(
{ "success" => false, "message" => "EmailReceiverWorker job exceeds payload size limit" }
)
end
end
context 'not authenticated' do
it 'responds with 401 Unauthorized' do
post api("/internal/mail_room/incoming_email")
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'wrong token authentication' do
let(:auth_headers) do
jwt_token = JWT.encode(auth_payload, 'wrongsecret', 'HS256')
{ Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token }
end
it 'responds with 401 Unauthorized' do
post api("/internal/mail_room/incoming_email"), headers: auth_headers
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'wrong mailbox type authentication' do
let(:auth_headers) do
jwt_token = JWT.encode(auth_payload, service_desk_email_secret, 'HS256')
{ Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token }
end
it 'responds with 401 Unauthorized' do
post api("/internal/mail_room/incoming_email"), headers: auth_headers
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'not supported mailbox type' do
let(:auth_headers) do
jwt_token = JWT.encode(auth_payload, incoming_email_secret, 'HS256')
{ Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token }
end
it 'responds with 401 Unauthorized' do
post api("/internal/mail_room/invalid_mailbox_type"), headers: auth_headers
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
context 'not enabled mailbox type' do
let(:enabled_configs) do
{
incoming_email: base_configs.merge(
secure_file: Rails.root.join('tmp', 'tests', '.incoming_email_secret').to_s
)
}
end
let(:auth_headers) do
jwt_token = JWT.encode(auth_payload, service_desk_email_secret, 'HS256')
{ Gitlab::MailRoom::Authenticator::INTERNAL_API_REQUEST_HEADER => jwt_token }
end
it 'responds with 401 Unauthorized' do
post api("/internal/mail_room/service_desk_email"), headers: auth_headers
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment