From d507760578c6b50417ed526e20bd8cd18a3e7d00 Mon Sep 17 00:00:00 2001 From: Vladislav Trotsenko Date: Tue, 4 Jun 2019 13:46:33 +0300 Subject: [PATCH] Feature/domain whitelist blacklist (#43) * Error key in lower_snake_case * Add whitelist, blacklist check * Update reek config * Rename Skip to DomainListMatch * Add Configuration#validation_type_for= argument type validation * Add attr_accessors Configuration#whitelisted_domains, Configuration#blacklisted_domains * Update shared examples * Update core, Validator#run tests * Add DomainListMatch tests * Update Gemfile * Update gem version * Update readme --- .reek.yml | 5 ++ Gemfile.lock | 2 +- README.md | 26 ++++++++-- lib/truemail/configuration.rb | 27 ++++++++-- lib/truemail/core.rb | 2 +- lib/truemail/validate/base.rb | 2 +- lib/truemail/validate/domain_list_match.rb | 30 +++++++++++ lib/truemail/validate/skip.rb | 11 ---- lib/truemail/validator.rb | 9 +++- lib/truemail/version.rb | 2 +- .../shared_examples/has_attr_accessor.rb | 2 + .../sets_default_configuration.rb | 2 + spec/truemail/configuration_spec.rb | 51 ++++++++++++++++++- spec/truemail/core_spec.rb | 2 +- .../validate/domain_list_match_spec.rb | 37 ++++++++++++++ spec/truemail/validate/skip_spec.rb | 17 ------- spec/truemail/validator_spec.rb | 28 ++++++++-- 17 files changed, 206 insertions(+), 49 deletions(-) create mode 100644 lib/truemail/validate/domain_list_match.rb delete mode 100644 lib/truemail/validate/skip.rb create mode 100644 spec/truemail/validate/domain_list_match_spec.rb delete mode 100644 spec/truemail/validate/skip_spec.rb diff --git a/.reek.yml b/.reek.yml index d3a9f6f..3cba45b 100644 --- a/.reek.yml +++ b/.reek.yml @@ -31,6 +31,7 @@ detectors: - Truemail::Validate::Mx#null_mx? - Truemail::Validate::Mx#a_record - Truemail::Audit::Base#verifier_domain + - Truemail::Configuration#domain_matcher ControlParameter: exclude: @@ -42,3 +43,7 @@ detectors: exclude: - Truemail::Validate::Smtp#not_includes_user_not_found_errors - Truemail::GenerateEmailHelper#prepare_user_name + + NilCheck: + exclude: + - Truemail::Validator#result_not_changed? diff --git a/Gemfile.lock b/Gemfile.lock index ccedc2a..8ddc39e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - truemail (0.2.0) + truemail (1.0.0) GEM remote: https://rubygems.org/ diff --git a/README.md b/README.md index 11e2c31..71cd9ed 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ The Truemail gem helps you validate emails by regex pattern, presence of domain - Configurable validator, validate only what you need - Zero runtime dependencies +- Has whitelist/blacklist - Has simple SMTP debugger - 100% test coverage @@ -74,10 +75,18 @@ Truemail.configure do |config| config.connection_attempts = 3 # Optional parameter. You can predefine which type of validation will be used for domains. - # Also you can skip validation by domain. Available validation types: :regex, :mx, :smtp, :skip + # Also you can skip validation by domain. Available validation types: :regex, :mx, :smtp # This configuration will be used over current or default validation type parameter # All of validations for 'somedomain.com' will be processed with mx validation only - config.validation_type_for = { 'somedomain.com' => :mx, 'otherdomain.com' => :skip } + config.validation_type_for = { 'somedomain.com' => :regex, 'otherdomain.com' => :mx } + + # Optional parameter. Validation of email which contains whitelisted domain always will + # return true. Other validations will not processed even if it was defined in validation_type_for + config.whitelisted_domains = ['somedomain1.com', 'somedomain2.com'] + + # Optional parameter. Validation of email which contains whitelisted domain always will + # return false. Other validations will not processed even if it was defined in validation_type_for + config.blacklisted_domains = ['somedomain1.com', 'somedomain2.com'] # Optional parameter. This option will be parse bodies of SMTP errors. It will be helpful # if SMTP server does not return an exact answer that the email does not exist @@ -100,6 +109,8 @@ Truemail.configuration @response_timeout=1, @connection_attempts=3, @validation_type_by_domain={}, + @whitelisted_domains=[], + @blacklisted_domains=[], @verifier_domain="somedomain.com", @verifier_email="verifier@example.com" @smtp_safe_check=true> @@ -123,6 +134,8 @@ Truemail.configuration @response_timeout=4, @connection_attempts=1, @validation_type_by_domain={}, + @whitelisted_domains=[], + @blacklisted_domains=[], @verifier_domain="somedomain.com", @verifier_email="verifier@example.com", @smtp_safe_check=true> @@ -281,6 +294,8 @@ Truemail.validate('email@example.com') @connection_attempts=2, @smtp_safe_check=false, @validation_type_by_domain={}, + @whitelisted_domains=[], + @blacklisted_domains=[], @verifier_domain="example.com", @verifier_email="verifier@example.com">, @email="email@example.com", @@ -335,6 +350,8 @@ Truemail.validate('email@example.com') @connection_attempts=2, @smtp_safe_check=true, @validation_type_by_domain={}, + @whitelisted_domains=[], + @blacklisted_domains=[], @verifier_domain="example.com", @verifier_email="verifier@example.com">, @email="email@example.com", @@ -373,6 +390,8 @@ Truemail.validate('email@example.com') @connection_attempts=2, @smtp_safe_check=true, @validation_type_by_domain={}, + @whitelisted_domains=[], + @blacklisted_domains=[], @verifier_domain="example.com", @verifier_email="verifier@example.com">, @email="email@example.com", @@ -447,8 +466,7 @@ end --- ## ToDo -1. Gem compatibility with Ruby 2.3 -2. Fail validations logger +Fail validations logger ## Contributing diff --git a/lib/truemail/configuration.rb b/lib/truemail/configuration.rb index 5e584bb..0d8d424 100644 --- a/lib/truemail/configuration.rb +++ b/lib/truemail/configuration.rb @@ -13,7 +13,9 @@ class Configuration :connection_timeout, :response_timeout, :connection_attempts, - :validation_type_by_domain + :validation_type_by_domain, + :whitelisted_domains, + :blacklisted_domains attr_accessor :smtp_safe_check @@ -26,6 +28,8 @@ def initialize @response_timeout = Truemail::Configuration::DEFAULT_RESPONSE_TIMEOUT @connection_attempts = Truemail::Configuration::DEFAULT_CONNECTION_ATTEMPTS @validation_type_by_domain = {} + @whitelisted_domains = [] + @blacklisted_domains = [] @smtp_safe_check = false end @@ -59,6 +63,13 @@ def validation_type_for=(settings) validation_type_by_domain.merge!(settings) end + %i[whitelisted_domains blacklisted_domains].each do |method| + define_method("#{method}=") do |argument| + raise ArgumentError.new(argument, __method__) unless argument.is_a?(Array) && check_domain_list(argument) + instance_variable_set(:"@#{method}", argument) + end + end + def complete? !!verifier_email end @@ -74,17 +85,25 @@ def default_verifier_domain self.verifier_domain ||= verifier_email[Truemail::RegexConstant::REGEX_EMAIL_PATTERN, 3] end + def domain_matcher + ->(domain) { Truemail::RegexConstant::REGEX_DOMAIN_PATTERN.match?(domain.to_s) } + end + def check_domain(domain) - raise Truemail::ArgumentError.new(domain, 'domain') unless - Truemail::RegexConstant::REGEX_DOMAIN_PATTERN.match?(domain.to_s) + raise Truemail::ArgumentError.new(domain, 'domain') unless domain_matcher.call(domain) + end + + def check_domain_list(domains) + domains.all?(&domain_matcher) end def check_validation_type(validation_type) raise Truemail::ArgumentError.new(validation_type, 'validation type') unless - Truemail::Validator::VALIDATION_TYPES.include?(validation_type) + Truemail::Validator::VALIDATION_TYPES.include?(validation_type) end def validate_validation_type(settings) + raise Truemail::ArgumentError.new(settings, 'hash with settings') unless settings.is_a?(Hash) settings.each do |domain, validation_type| check_domain(domain) check_validation_type(validation_type) diff --git a/lib/truemail/core.rb b/lib/truemail/core.rb index 410a042..58cd6e3 100644 --- a/lib/truemail/core.rb +++ b/lib/truemail/core.rb @@ -30,8 +30,8 @@ module Audit end module Validate - require 'truemail/validate/skip' require 'truemail/validate/base' + require 'truemail/validate/domain_list_match' require 'truemail/validate/regex' require 'truemail/validate/mx' require 'truemail/validate/smtp' diff --git a/lib/truemail/validate/base.rb b/lib/truemail/validate/base.rb index 4eba200..2ed7545 100644 --- a/lib/truemail/validate/base.rb +++ b/lib/truemail/validate/base.rb @@ -6,7 +6,7 @@ class Base < Truemail::Worker private def add_error(message) - result.errors[self.class.name.split('::').last.downcase.to_sym] = message + result.errors[self.class.name.split('::').last.gsub(/(?<=.)(?=[A-Z])/, '_').downcase.to_sym] = message end def mail_servers diff --git a/lib/truemail/validate/domain_list_match.rb b/lib/truemail/validate/domain_list_match.rb new file mode 100644 index 0000000..f205f0b --- /dev/null +++ b/lib/truemail/validate/domain_list_match.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Truemail + module Validate + class DomainListMatch < Truemail::Validate::Base + ERROR = 'blacklisted email' + + def run + return success(true) if whitelisted_domain? + return unless blacklisted_domain? + success(false) + add_error(Truemail::Validate::DomainListMatch::ERROR) + end + + private + + def email_domain + @email_domain ||= result.email[Truemail::RegexConstant::REGEX_DOMAIN_FROM_EMAIL, 1] + end + + def whitelisted_domain? + configuration.whitelisted_domains.include?(email_domain) + end + + def blacklisted_domain? + configuration.blacklisted_domains.include?(email_domain) + end + end + end +end diff --git a/lib/truemail/validate/skip.rb b/lib/truemail/validate/skip.rb deleted file mode 100644 index ede8476..0000000 --- a/lib/truemail/validate/skip.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Truemail - module Validate - class Skip < Truemail::Worker - def run - success(true) - end - end - end -end diff --git a/lib/truemail/validator.rb b/lib/truemail/validator.rb index 0eaf200..a0ef60b 100644 --- a/lib/truemail/validator.rb +++ b/lib/truemail/validator.rb @@ -3,7 +3,7 @@ module Truemail class Validator RESULT_ATTRS = %i[success email domain mail_servers errors smtp_debug].freeze - VALIDATION_TYPES = %i[regex mx smtp skip].freeze + VALIDATION_TYPES = %i[regex mx smtp].freeze Result = Struct.new(*RESULT_ATTRS, keyword_init: true) do def initialize(errors: {}, mail_servers: [], **args) @@ -21,12 +21,17 @@ def initialize(email, with: :smtp) end def run - Truemail::Validate.const_get(validation_type.capitalize).check(result) + Truemail::Validate::DomainListMatch.check(result) + Truemail::Validate.const_get(validation_type.capitalize).check(result) if result_not_changed? self end private + def result_not_changed? + result.success.nil? + end + def select_validation_type(email, current_validation_type) domain = email[Truemail::RegexConstant::REGEX_EMAIL_PATTERN, 3] Truemail.configuration.validation_type_by_domain[domain] || current_validation_type diff --git a/lib/truemail/version.rb b/lib/truemail/version.rb index 718c800..732dc94 100644 --- a/lib/truemail/version.rb +++ b/lib/truemail/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Truemail - VERSION = '0.2.0' + VERSION = '1.0.0' end diff --git a/spec/support/shared_examples/has_attr_accessor.rb b/spec/support/shared_examples/has_attr_accessor.rb index 4a71a54..112195c 100644 --- a/spec/support/shared_examples/has_attr_accessor.rb +++ b/spec/support/shared_examples/has_attr_accessor.rb @@ -10,6 +10,8 @@ module Truemail connection_timeout response_timeout connection_attempts + whitelisted_domains + blacklisted_domains smtp_safe_check ].each do |attribute| it "has attr_accessor :#{attribute}" do diff --git a/spec/support/shared_examples/sets_default_configuration.rb b/spec/support/shared_examples/sets_default_configuration.rb index 621544d..f6df6e8 100644 --- a/spec/support/shared_examples/sets_default_configuration.rb +++ b/spec/support/shared_examples/sets_default_configuration.rb @@ -11,6 +11,8 @@ module Truemail expect(configuration_instance.response_timeout).to eq(Truemail::Configuration::DEFAULT_RESPONSE_TIMEOUT) expect(configuration_instance.connection_attempts).to eq(Truemail::Configuration::DEFAULT_CONNECTION_ATTEMPTS) expect(configuration_instance.validation_type_by_domain).to eq({}) + expect(configuration_instance.whitelisted_domains).to eq([]) + expect(configuration_instance.blacklisted_domains).to eq([]) expect(configuration_instance.smtp_safe_check).to be(false) end end diff --git a/spec/truemail/configuration_spec.rb b/spec/truemail/configuration_spec.rb index 123d606..a5b0711 100644 --- a/spec/truemail/configuration_spec.rb +++ b/spec/truemail/configuration_spec.rb @@ -35,6 +35,8 @@ expect(configuration_instance.response_timeout).to eq(2) expect(configuration_instance.connection_attempts).to eq(2) expect(configuration_instance.validation_type_by_domain).to eq({}) + expect(configuration_instance.whitelisted_domains).to eq([]) + expect(configuration_instance.blacklisted_domains).to eq([]) expect(configuration_instance.smtp_safe_check).to be(false) end @@ -51,6 +53,8 @@ .and not_change(configuration_instance, :connection_timeout) .and not_change(configuration_instance, :response_timeout) .and not_change(configuration_instance, :validation_type_by_domain) + .and not_change(configuration_instance, :whitelisted_domains) + .and not_change(configuration_instance, :blacklisted_domains) .and not_change(configuration_instance, :smtp_safe_check) configuration_instance_expectaions @@ -69,6 +73,8 @@ .and not_change(configuration_instance, :connection_timeout) .and not_change(configuration_instance, :response_timeout) .and not_change(configuration_instance, :validation_type_by_domain) + .and not_change(configuration_instance, :whitelisted_domains) + .and not_change(configuration_instance, :blacklisted_domains) .and not_change(configuration_instance, :smtp_safe_check) configuration_instance_expectaions @@ -87,6 +93,8 @@ .and not_change(configuration_instance, :connection_timeout) .and not_change(configuration_instance, :response_timeout) .and not_change(configuration_instance, :validation_type_by_domain) + .and not_change(configuration_instance, :whitelisted_domains) + .and not_change(configuration_instance, :blacklisted_domains) .and not_change(configuration_instance, :smtp_safe_check) configuration_instance_expectaions @@ -230,7 +238,7 @@ describe '#validation_type_for=' do context 'with valid validation type attributes' do let(:domains_config) do - (1..4).map { FFaker::Internet.unique.domain_name }.zip(%i[regex mx smtp skip]).to_h + (1..3).map { FFaker::Internet.unique.domain_name }.zip(%i[regex mx smtp]).to_h end it 'sets validation type for domain' do @@ -240,6 +248,15 @@ end end + context 'with invalid settings type' do + let(:invalid_argument) { [] } + + specify do + expect { configuration_instance.validation_type_for = invalid_argument } + .to raise_error(Truemail::ArgumentError, "#{invalid_argument} is not a valid hash with settings") + end + end + context 'with invalid domain' do let(:domain) { 'not_valid_domain' } @@ -260,6 +277,38 @@ end end + %i[whitelisted_domains= blacklisted_domains=].each do |domain_list_type| + describe "##{domain_list_type}" do + let(:domains_list) { (1..3).map { FFaker::Internet.unique.domain_name } } + + context "with valid #{domain_list_type} parameter type and context" do + it 'sets whitelisted domains list' do + expect { configuration_instance.public_send(domain_list_type, domains_list) } + .to change(configuration_instance, domain_list_type[0...-1].to_sym) + .from([]).to(domains_list) + end + end + + context "with invalid #{domain_list_type} parameter type" do + let(:wrong_parameter_type) { 'not_array' } + + specify do + expect { configuration_instance.public_send(domain_list_type, wrong_parameter_type) } + .to raise_error(Truemail::ArgumentError, "#{wrong_parameter_type} is not a valid #{domain_list_type}") + end + end + + context 'with invalid whitelisted_domains= parameter context' do + let(:wrong_parameter_context) { ['not_domain', 123] } + + specify do + expect { configuration_instance.public_send(domain_list_type, wrong_parameter_context) } + .to raise_error(Truemail::ArgumentError, "#{wrong_parameter_context} is not a valid #{domain_list_type}") + end + end + end + end + describe '#smtp_safe_check=' do it 'sets smtp safe check' do expect { configuration_instance.smtp_safe_check = true } diff --git a/spec/truemail/core_spec.rb b/spec/truemail/core_spec.rb index 4f85e55..8c08f74 100644 --- a/spec/truemail/core_spec.rb +++ b/spec/truemail/core_spec.rb @@ -136,8 +136,8 @@ module Truemail RSpec.describe Truemail::Validate do describe 'defined constants' do - specify { expect(described_class).to be_const_defined(:Skip) } specify { expect(described_class).to be_const_defined(:Base) } + specify { expect(described_class).to be_const_defined(:DomainListMatch) } specify { expect(described_class).to be_const_defined(:Regex) } specify { expect(described_class).to be_const_defined(:Mx) } specify { expect(described_class).to be_const_defined(:Smtp) } diff --git a/spec/truemail/validate/domain_list_match_spec.rb b/spec/truemail/validate/domain_list_match_spec.rb new file mode 100644 index 0000000..6595c49 --- /dev/null +++ b/spec/truemail/validate/domain_list_match_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Truemail::Validate::DomainListMatch do + describe '.check' do + subject(:list_match_validator) { described_class.check(result_instance) } + + let(:email) { FFaker::Internet.email } + let(:domain) { email[Truemail::RegexConstant::REGEX_DOMAIN_FROM_EMAIL, 1] } + let(:result_instance) { Truemail::Validator::Result.new(email: email) } + + context 'when email domain in white list' do + specify do + allow(Truemail).to receive_message_chain(:configuration, :whitelisted_domains).and_return([domain]) + allow(Truemail).to receive_message_chain(:configuration, :blacklisted_domains).and_return([]) + expect { list_match_validator }.to change(result_instance, :success).from(nil).to(true) + end + end + + context 'when email domain in black list' do + specify do + allow(Truemail).to receive_message_chain(:configuration, :whitelisted_domains).and_return([]) + allow(Truemail).to receive_message_chain(:configuration, :blacklisted_domains).and_return([domain]) + expect { list_match_validator } + .to change(result_instance, :success).from(nil).to(false) + .and change(result_instance, :errors).from({}).to({ domain_list_match: Truemail::Validate::DomainListMatch::ERROR }) + end + end + + context 'when email domain not on both lists' do + specify do + allow(Truemail).to receive_message_chain(:configuration, :whitelisted_domains).and_return([]) + allow(Truemail).to receive_message_chain(:configuration, :blacklisted_domains).and_return([]) + expect { list_match_validator }.not_to change(result_instance, :success) + end + end + end +end diff --git a/spec/truemail/validate/skip_spec.rb b/spec/truemail/validate/skip_spec.rb deleted file mode 100644 index 23c5a0a..0000000 --- a/spec/truemail/validate/skip_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Truemail::Validate::Skip do - describe '.check' do - subject(:skip_validator) { described_class.check(result_instance) } - - let(:result_instance) { Truemail::Validator::Result.new(email: FFaker::Internet.email) } - - specify do - expect { skip_validator }.to change(result_instance, :success).from(nil).to(true) - end - - it 'returns true' do - expect(skip_validator).to be(true) - end - end -end diff --git a/spec/truemail/validator_spec.rb b/spec/truemail/validator_spec.rb index b53d1b4..31ce6c0 100644 --- a/spec/truemail/validator_spec.rb +++ b/spec/truemail/validator_spec.rb @@ -47,14 +47,32 @@ module Truemail end describe '#run' do - subject(:validator_instance) { described_class.new(email, with: validation_type).run } + subject(:validator_instance_run) { validator_instance.run } + let(:validator_instance) { described_class.new(email, with: validation_type) } let(:validation_type) { :regex } - it 'calls predefined validation class' do - allow(Truemail::Validate::Regex).to receive(:check).and_return(true) - expect(validator_instance).to be_an_instance_of(Truemail::Validator) - expect(Truemail::Validate::Regex).to have_received(:check) + before do + allow(Truemail::Validate::DomainListMatch).to receive(:check) + allow(validator_instance).to receive(:result_not_changed?).and_return(condition) + end + + context 'when email not in whitelist/blacklist' do + let(:condition) { true } + + it 'calls predefined validation class' do + expect(Truemail::Validate::Regex).to receive(:check) + expect(validator_instance_run).to be_an_instance_of(Truemail::Validator) + end + end + + context 'when email in the whitelist/blacklist' do + let(:condition) { false } + + it 'calls predefined validation class' do + expect(Truemail::Validate::Regex).not_to receive(:check) + expect(validator_instance_run).to be_an_instance_of(Truemail::Validator) + end end end