diff --git a/Gemfile b/Gemfile index eb0a40b9ad6..e2c44f894c3 100644 --- a/Gemfile +++ b/Gemfile @@ -9,12 +9,10 @@ gem 'rails', '~> 6.1.4' @hostdata_gem ||= { github: '18F/identity-hostdata', tag: 'v3.4.0' } @logging_gem ||= { github: '18F/identity-logging', tag: 'v0.1.0' } @saml_gem ||= { github: '18F/saml_idp', tag: 'v0.14.3-18f' } -@telephony_gem ||= { github: '18f/identity-telephony', tag: 'v0.4.4' } @validations_gem ||= { github: '18F/identity-validations', tag: 'v0.7.1' } gem 'identity-hostdata', @hostdata_gem gem 'identity-logging', @logging_gem -gem 'identity-telephony', @telephony_gem gem 'identity_validations', @validations_gem gem 'saml_idp', @saml_gem @@ -22,6 +20,8 @@ gem 'ahoy_matey', '~> 3.0' gem 'autoprefixer-rails', '~> 10.0' gem 'aws-sdk-kms', '~> 1.4' gem 'aws-sdk-ses', '~> 1.6' +gem 'aws-sdk-pinpoint' +gem 'aws-sdk-pinpointsmsvoice' gem 'base32-crockford' gem 'bootsnap', '~> 1.9.0', require: false gem 'blueprinter', '~> 0.25.3' diff --git a/Gemfile-dev.example b/Gemfile-dev.example index 6d2405224fc..0ca154264e0 100644 --- a/Gemfile-dev.example +++ b/Gemfile-dev.example @@ -17,7 +17,6 @@ FileUtils.cp("Gemfile.lock", "Gemfile-dev.lock") # @hostdata_gem = { path: '../identity-hostdata' } # @idp_functions_gem = { path: '../identity-idp-functions' } # @logging_gem = { path: '../identity-logging' } -# @telephony_gem = { path: '../identity-telephony' } # @validations_gem = { path: '../identity-validations' } # @saml_gem = { path: '../saml_idp' } diff --git a/Gemfile.lock b/Gemfile.lock index 02cf956fc65..8a9ddddd457 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -36,16 +36,6 @@ GIT pkcs11 uuid -GIT - remote: https://github.com/18f/identity-telephony.git - revision: 15e9eb147900e959e130de1bf409ee1814e24ecc - tag: v0.4.4 - specs: - identity-telephony (0.4.4) - aws-sdk-pinpoint - aws-sdk-pinpointsmsvoice - i18n - GEM remote: https://rubygems.org/ specs: @@ -690,6 +680,8 @@ DEPENDENCIES autoprefixer-rails (~> 10.0) aws-sdk-cloudwatchlogs aws-sdk-kms (~> 1.4) + aws-sdk-pinpoint + aws-sdk-pinpointsmsvoice aws-sdk-ses (~> 1.6) axe-core-rspec (~> 4.2) base32-crockford @@ -721,7 +713,6 @@ DEPENDENCIES i18n-tasks (>= 0.9.31) identity-hostdata! identity-logging! - identity-telephony! identity_validations! irb jwt diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index f8e81eaed98..877d474f19d 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -44,8 +44,9 @@ data: # Find translate calls search: ## Paths or `File.find` patterns to search in: - # paths: - # - app/ + paths: + - app/ + - lib/ ## Root directories for relative keys resolution. relative_roots: diff --git a/config/initializers/telephony.rb b/config/initializers/telephony.rb index 3aa01137089..9ac4b393aa9 100644 --- a/config/initializers/telephony.rb +++ b/config/initializers/telephony.rb @@ -1,3 +1,4 @@ +require 'telephony' require 'pinpoint_supported_countries' # rubocop:disable Metrics/BlockLength diff --git a/config/locales/telephony/en.yml b/config/locales/telephony/en.yml new file mode 100644 index 00000000000..4459233a924 --- /dev/null +++ b/config/locales/telephony/en.yml @@ -0,0 +1,51 @@ +--- +en: + telephony: + account_reset_cancellation_notice: Your request to delete your login.gov account has been cancelled. + account_reset_notice: As requested, your login.gov account will be deleted in 24 + hours. Don't want to delete your account? Sign in to your login.gov + account to cancel. + authentication_otp: + sms: |- + %{app_name}: Your security code is %{code}. It expires in %{expiration} minutes. Don't share this code with anyone. + + @%{domain} #%{code} + voice: Hello! Your login.gov one time passcode is, %{code}, again, your passcode + is, %{code}, This code expires in %{expiration} minutes. + confirmation_otp: + sms: |- + %{app_name}: Your security code is %{code}. It expires in %{expiration} minutes. Don't share this code with anyone. + + @%{domain} #%{code} + voice: Hello! Your login.gov one time passcode is, %{code}, again, your passcode + is, %{code}, This code expires in %{expiration} minutes. + doc_auth_link: "%{link} You've requested to verify your identity on a mobile + phone. Please take a photo of your state issued ID." + error: + friendly_message: + duplicate_endpoint: The phone number entered is not valid. + generic: Your security code failed to send. + invalid_calling_area: Calls to that phone number are not supported. Please try + SMS if you have an SMS-capable phone. + invalid_phone_number: The phone number entered is not valid. + opt_out: The phone number entered has opted out of text messages. + permanent_failure: The phone number entered is not valid. + sms_unsupported: The phone number entered doesn't support text messaging. Try + the Phone call option. + temporary_failure: We are experiencing technical difficulties. Please try again later. + throttled: That number is experiencing high message volume. Please try again + later. + timeout: The server took too long to respond. Please try again. + unknown_failure: We are experiencing technical difficulties. Please try again later. + voice_unsupported: Invalid phone number. Check that you've entered the correct + country code or area code. + help_keyword_response: Get help at www.login.gov/help or contact us at www.login.gov/contact. + join_keyword_response: 123456 is your login.gov confirmation code. Use this to + confirm your phone number. This code will expire in 5 minutes. + personal_key_regeneration_notice: A new personal key has been issued for your + login.gov account. If this wasn’t you, reset your password. + personal_key_sign_in_notice: Your personal key was just used to sign into your + login.gov account. If this wasn’t you, reset your password. + stop_keyword_response: You have unsubscribed and will not receive alerts from + login.gov . To resubscribe, sign in or set up an account. Help? + www.login.gov/help diff --git a/config/locales/telephony/es.yml b/config/locales/telephony/es.yml new file mode 100644 index 00000000000..d2538c37d26 --- /dev/null +++ b/config/locales/telephony/es.yml @@ -0,0 +1,58 @@ +--- +es: + telephony: + account_reset_cancellation_notice: Su solicitud para eliminar su cuenta de login.gov ha sido cancelada. + account_reset_notice: Según lo solicitado, su cuenta login.gov se eliminará en + 24 horas. ¿No quieres eliminar tu cuenta? Inicie sesión en su cuenta + login.gov para cancelar. + authentication_otp: + sms: |- + %{app_name}: Su código de seguridad es %{code}. Este código se vence en %{expiration} minutos. No lo comparta con ninguna persona. + + @%{domain} #%{code} + voice: '¡Hola! Su código de acceso de login.gov es, %{code}, nuevamente, su + código de acceso es %{code}, Este código caducará en %{expiration} + minutos.' + confirmation_otp: + sms: |- + %{app_name}: Su código de seguridad es %{code}. Este código se vence en %{expiration} minutos. No lo comparta con ninguna persona. + + @%{domain} #%{code} + voice: '¡Hola! Su código de acceso de login.gov es, %{code}, nuevamente, su + código de acceso es %{code}, Este código caducará en %{expiration} + minutos.' + doc_auth_link: '%{link} Has solicitado verificar tu identidad en un teléfono + móvil. Por favor, tome una foto de la identificación emitida por su + estado' + error: + friendly_message: + duplicate_endpoint: El número de teléfono ingresado no es válido. + generic: Se produjo un error al enviar el código de seguridad. + invalid_calling_area: No se admiten llamadas a ese número de teléfono. Intenta + enviar un SMS si tienes un teléfono que permita enviar SMS. + invalid_phone_number: El número de teléfono ingresado no está en el formato correcto. + opt_out: El número de teléfono ingresado ha sido excluido de los mensajes de + texto. + permanent_failure: El número de teléfono ingresado no es válido. + sms_unsupported: El número de teléfono ingresado no admite mensajes de texto. + Pruebe la opción de llamada telefónica. + temporary_failure: Estamos experimentando dificultades técnicas. Por favor, + inténtelo de nuevo más tarde. + throttled: Ese número está experimentando un alto volumen de mensajes. Por + favor, inténtelo de nuevo más tarde. + timeout: El servidor tardó demasiado en responder. Inténtalo de nuevo. + unknown_failure: Estamos experimentando dificultades técnicas. Por favor, + inténtelo de nuevo más tarde. + voice_unsupported: Numero de telefono invalido. Verifique que haya ingresado el + código de país o de área correcto. + help_keyword_response: Obtenga ayuda en www.login.gov/help o contáctenos en www.login.gov/contact. + join_keyword_response: 123456 es tu código de confirmación de login.gov. Use + esto para confirmar su número de teléfono. Este código caducará en 5 + minutos. + personal_key_regeneration_notice: Se ha emitido una nueva clave personal para tu + cuenta login.gov. Si no eres tú, restablece tu contraseña. + personal_key_sign_in_notice: Su clave personal solo se utilizó para iniciar + sesión en su cuenta login.gov. Si no fue así, reinicie su contraseña. + stop_keyword_response: Ha anulado su suscripción y no recibirá alertas de + login.gov. Para volver a suscribirse, inicie sesión o cree una cuenta. + ¿Necesita ayuda? www.login.gov/help diff --git a/config/locales/telephony/fr.yml b/config/locales/telephony/fr.yml new file mode 100644 index 00000000000..33d266f6861 --- /dev/null +++ b/config/locales/telephony/fr.yml @@ -0,0 +1,61 @@ +--- +fr: + telephony: + account_reset_cancellation_notice: Votre demande de suppression de votre compte login.gov a été annulée. + account_reset_notice: Comme demandé, votre compte login.gov sera supprimé dans + les 24 heures. Vous ne voulez pas supprimer votre compte? Connectez-vous à + votre compte login.gov pour le annuler. + authentication_otp: + sms: |- + %{app_name}: Votre code de sécurité est %{code}. Il est valable pendant %{expiration} minutes. Vous ne devez jamais partager ce code avec personne. + + @%{domain} #%{code} + voice: Bonjour! Votre code de sécurité à utilisation unique de login.gov est, + %{code}, de nouveau, votre code de sécurité est, %{code}, Ce code + expirera dans %{expiration} minutes. + confirmation_otp: + sms: |- + %{app_name}: Votre code de sécurité est %{code}. Il est valable pendant %{expiration} minutes. Vous ne devez jamais partager ce code avec personne. + + @%{domain} #%{code} + voice: Bonjour! Votre code de sécurité à utilisation unique de login.gov est, + %{code}, de nouveau, votre code de sécurité est, %{code}, Ce code + expirera dans %{expiration} minutes. + doc_auth_link: "%{link} Vous avez demandé à vérifier votre identité sur un + téléphone mobile. S'il vous plaît prendre une photo de votre identité + émise par l'état" + error: + friendly_message: + duplicate_endpoint: Le numéro de téléphone entré n'est pas valide. + generic: Échec de l'envoi de votre code de sécurité. + invalid_calling_area: Les appels vers ce numéro de téléphone ne sont pas pris en + charge. Veuillez essayer par SMS si vous possédez un téléphone + disposant de cette fonction. + invalid_phone_number: Le numéro de téléphone saisi n'est pas valide. + opt_out: Le numéro de téléphone entré a désactivé les messages texte. + permanent_failure: Le numéro de téléphone entré n'est pas valide. + sms_unsupported: Le numéro de téléphone saisi ne prend pas en charge les + messages textuels. Veuillez essayer l'option d'appel téléphonique. + temporary_failure: Nous rencontrons des difficultés techniques. Veuillez + réessayer plus tard. + throttled: Ce nombre connaît un volume de messages élevé. Veuillez réessayer + plus tard. + timeout: Le serveur a pris trop de temps pour répondre. Veuillez réessayer. + unknown_failure: Nous rencontrons des difficultés techniques. Veuillez réessayer + plus tard. + voice_unsupported: Numéro de téléphone invalide. Vérifiez que vous avez entré le + bon indicatif international ou régional. + help_keyword_response: Obtenez de l'aide à l'adresse www.login.gov/help ou + contactez-nous à l'adresse www.login.gov/contact. + join_keyword_response: 123456 est votre code de confirmation login.gov. + Utilisez-le pour confirmer votre numéro de téléphone. Ce code expirera + dans 5 minutes. + personal_key_regeneration_notice: Une nouvelle clé personnelle a été émise pour + votre compte login.gov. Si vous ne l'avez pas demandée, réinitialisez + votre mot de passe. + personal_key_sign_in_notice: Votre clé personnelle a été utilisée pour vous + connecter à votre compte login.gov. Si ce n’était pas vous, changez votre + mot de passe. + stop_keyword_response: Vous vous êtes désinscrit et ne recevrez plus d'alertes + de login.gov. Pour vous réabonner, connectez-vous ou créez un compte. + Besoin d'aide? www.login.gov/help diff --git a/lib/telephony.rb b/lib/telephony.rb new file mode 100644 index 00000000000..94a540390e0 --- /dev/null +++ b/lib/telephony.rb @@ -0,0 +1,78 @@ +require 'aws-sdk-pinpoint' +require 'aws-sdk-pinpointsmsvoice' +require 'forwardable' +require 'i18n' +require 'telephony/util' +require 'telephony/alert_sender' +require 'telephony/configuration' +require 'telephony/errors' +require 'telephony/otp_sender' +require 'telephony/phone_number_info' +require 'telephony/response' +require 'telephony/test/call' +require 'telephony/test/message' +require 'telephony/test/error_simulator' +require 'telephony/test/sms_sender' +require 'telephony/test/voice_sender' +require 'telephony/pinpoint/aws_credential_builder' +require 'telephony/pinpoint/sms_sender' +require 'telephony/pinpoint/voice_sender' + +module Telephony + extend SingleForwardable + + def self.config + @config ||= Configuration.new + yield @config if block_given? + @config + end + + def self.send_authentication_otp(to:, otp:, expiration:, channel:, domain:, country_code:) + OtpSender.new( + to: to, + otp: otp, + expiration: expiration, + channel: channel, + domain: domain, + country_code: country_code, + ).send_authentication_otp + end + + def self.send_confirmation_otp(to:, otp:, expiration:, channel:, domain:, country_code:) + OtpSender.new( + to: to, + otp: otp, + expiration: expiration, + channel: channel, + domain: domain, + country_code: country_code, + ).send_confirmation_otp + end + + def self.alert_sender + AlertSender.new + end + + def_delegators :alert_sender, + :send_doc_auth_link, + :send_personal_key_regeneration_notice, + :send_personal_key_sign_in_notice, + :send_join_keyword_response, + :send_stop_keyword_response, + :send_help_keyword_response, + :send_account_reset_notice, + :send_account_reset_cancellation_notice + + # @param [String] phone_number phone number in E.164 format + # @return [PhoneNumberInfo] info about the phone number + def self.phone_info(phone_number) + sender = case Telephony.config.adapter + when :pinpoint + Pinpoint::SmsSender.new + when :test + Test::SmsSender.new + end + + sender.phone_info(phone_number) + end +end diff --git a/lib/telephony/alert_sender.rb b/lib/telephony/alert_sender.rb new file mode 100644 index 00000000000..fa0a7951dce --- /dev/null +++ b/lib/telephony/alert_sender.rb @@ -0,0 +1,97 @@ +module Telephony + class AlertSender + SMS_MAX_LENGTH = 160 + + def send_account_reset_notice(to:, country_code:) + message = I18n.t('telephony.account_reset_notice') + response = adapter.send(message: message, to: to, country_code: country_code) + log_response(response, context: __method__.to_s.gsub(/^send_/, '')) + response + end + + def send_account_reset_cancellation_notice(to:, country_code:) + message = I18n.t('telephony.account_reset_cancellation_notice') + response = adapter.send(message: message, to: to, country_code: country_code) + log_response(response, context: __method__.to_s.gsub(/^send_/, '')) + response + end + + def send_doc_auth_link(to:, link:, country_code:) + message = I18n.t('telephony.doc_auth_link', link: link) + response = adapter.send(message: message, to: to, country_code: country_code) + context = __method__.to_s.gsub(/^send_/, '') + if link.length > SMS_MAX_LENGTH + log_warning("link longer than #{SMS_MAX_LENGTH} characters", context: context) + end + log_response(response, context: context) + response + end + + def send_personal_key_regeneration_notice(to:, country_code:) + message = I18n.t('telephony.personal_key_regeneration_notice') + response = adapter.send(message: message, to: to, country_code: country_code) + log_response(response, context: __method__.to_s.gsub(/^send_/, '')) + response + end + + def send_personal_key_sign_in_notice(to:, country_code:) + message = I18n.t('telephony.personal_key_sign_in_notice') + response = adapter.send(message: message, to: to, country_code: country_code) + log_response(response, context: __method__.to_s.gsub(/^send_/, '')) + response + end + + def send_join_keyword_response(to:, country_code:) + message = I18n.t('telephony.join_keyword_response') + response = adapter.send(message: message, to: to, country_code: country_code) + log_response(response, context: __method__.to_s.gsub(/^send_/, '')) + response + end + + def send_stop_keyword_response(to:, country_code:) + message = I18n.t('telephony.stop_keyword_response') + response = adapter.send(message: message, to: to, country_code: country_code) + log_response(response, context: __method__.to_s.gsub(/^send_/, '')) + response + end + + def send_help_keyword_response(to:, country_code:) + message = I18n.t('telephony.help_keyword_response') + response = adapter.send(message: message, to: to, country_code: country_code) + log_response(response, context: __method__.to_s.gsub(/^send_/, '')) + response + end + + private + + def adapter + case Telephony.config.adapter + when :pinpoint + Pinpoint::SmsSender.new + when :test + Test::SmsSender.new + end + end + + def log_response(response, context:) + extra = { + adapter: Telephony.config.adapter, + channel: :sms, + context: context, + } + output = response.to_h.merge(extra).to_json + Telephony.config.logger.info(output) + end + + def log_warning(alert, context:) + Telephony.config.logger.warn( + { + alert: alert, + adapter: Telephony.config.adapter, + channel: :sms, + context: context, + }.to_json, + ) + end + end +end diff --git a/lib/telephony/configuration.rb b/lib/telephony/configuration.rb new file mode 100644 index 00000000000..14846623f57 --- /dev/null +++ b/lib/telephony/configuration.rb @@ -0,0 +1,77 @@ +require 'logger' + +module Telephony + class PinpointConfiguration + attr_reader :sms_configs, :voice_configs + + def initialize + @sms_configs = [] + @voice_configs = [] + end + + # Adds a new SMS configuration + # @yieldparam [PinpointSmsConfiguration] sms an sms configuration object configure + def add_sms_config + raise 'missing sms configuration block' unless block_given? + sms = PinpointSmsConfiguration.new(region: 'us-west-2') + yield sms + sms_configs << sms + sms + end + + # Adds a new voice configuration + # @yieldparam [PinpointVoiceConfiguration] voice a voice configuration object configure + def add_voice_config + raise 'missing voice configuration block' unless block_given? + voice = PinpointVoiceConfiguration.new(region: 'us-west-2') + yield voice + voice_configs << voice + voice + end + end + + PINPOINT_CONFIGURATION_NAMES = [ + :region, :access_key_id, :secret_access_key, + :credential_role_arn, :credential_role_session_name, :credential_external_id + ].freeze + PinpointVoiceConfiguration = Struct.new( + :longcode_pool, + *PINPOINT_CONFIGURATION_NAMES, + keyword_init: true, + ) + PinpointSmsConfiguration = Struct.new( + :application_id, + :shortcode, + *PINPOINT_CONFIGURATION_NAMES, + keyword_init: true, + ) + + class Configuration + attr_writer :adapter + attr_reader :pinpoint + attr_accessor :logger + attr_accessor :voice_pause_time + attr_accessor :voice_rate + + # rubocop:disable Metrics/MethodLength + def initialize + @adapter = :pinpoint + @logger = Logger.new(STDOUT) + @pinpoint = PinpointConfiguration.new + end + # rubocop:enable Metrics/MethodLength + + def adapter + @adapter.to_sym + end + + # @param [Hash,nil] map + def country_sender_ids=(hash) + @country_sender_ids = hash&.transform_keys(&:to_s) + end + + def country_sender_ids + @country_sender_ids || {} + end + end +end diff --git a/lib/telephony/errors.rb b/lib/telephony/errors.rb new file mode 100644 index 00000000000..7464c4b4adf --- /dev/null +++ b/lib/telephony/errors.rb @@ -0,0 +1,91 @@ +module Telephony + class TelephonyError < StandardError + def friendly_message + I18n.t(friendly_error_message_key) + end + + protected + + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.generic') + 'telephony.error.friendly_message.generic' + end + end + + class InvalidPhoneNumberError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.invalid_phone_number') + 'telephony.error.friendly_message.invalid_phone_number' + end + end + + class InvalidCallingAreaError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.invalid_calling_area') + 'telephony.error.friendly_message.invalid_calling_area' + end + end + + class VoiceUnsupportedError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.voice_unsupported') + 'telephony.error.friendly_message.voice_unsupported' + end + end + + class SmsUnsupportedError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.sms_unsupported') + 'telephony.error.friendly_message.sms_unsupported' + end + end + + class DuplicateEndpointError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.duplicate_endpoint') + 'telephony.error.friendly_message.duplicate_endpoint' + end + end + + class OptOutError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.opt_out') + 'telephony.error.friendly_message.opt_out' + end + end + + class PermanentFailureError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.permanent_failure') + 'telephony.error.friendly_message.permanent_failure' + end + end + + class TemporaryFailureError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.temporary_failure') + 'telephony.error.friendly_message.temporary_failure' + end + end + + class ThrottledError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.throttled') + 'telephony.error.friendly_message.throttled' + end + end + + class TimeoutError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.timeout') + 'telephony.error.friendly_message.timeout' + end + end + + class UnknownFailureError < TelephonyError + def friendly_error_message_key + # i18n-tasks-use t('telephony.error.friendly_message.unknown_failure') + 'telephony.error.friendly_message.unknown_failure' + end + end +end diff --git a/lib/telephony/otp_sender.rb b/lib/telephony/otp_sender.rb new file mode 100644 index 00000000000..2d91abfc6b6 --- /dev/null +++ b/lib/telephony/otp_sender.rb @@ -0,0 +1,107 @@ +module Telephony + class OtpSender + attr_reader :recipient_phone, :otp, :expiration, :channel, :domain, :country_code + + def initialize(to:, otp:, expiration:, channel:, domain:, country_code:) + @recipient_phone = to + @otp = otp + @expiration = expiration + @channel = channel.to_sym + @domain = domain + @country_code = country_code + end + + def send_authentication_otp + response = adapter.send( + message: authentication_message, + to: recipient_phone, + otp: otp, + country_code: country_code, + ) + log_response(response, context: :authentication) + response + end + + def send_confirmation_otp + response = adapter.send( + message: confirmation_message, + to: recipient_phone, + otp: otp, + country_code: country_code, + ) + log_response(response, context: :confirmation) + response + end + + private + + # rubocop:disable all + def adapter + case [Telephony.config.adapter, channel.to_sym] + when [:pinpoint, :sms] + Pinpoint::SmsSender.new + when [:pinpoint, :voice] + Pinpoint::VoiceSender.new + when [:test, :sms] + Test::SmsSender.new + when [:test, :voice] + Test::VoiceSender.new + else + raise "Unknown telephony adapter #{Telephony.config.adapter} for channel #{channel.to_sym}" + end + end + # rubocop:enable all + + def log_response(response, context:) + extra = { + adapter: Telephony.config.adapter, + channel: channel, + context: context, + } + output = response.to_h.merge(extra).to_json + Telephony.config.logger.info(output) + end + + def authentication_message + wrap_in_ssml_if_needed( + I18n.t( + "telephony.authentication_otp.#{channel}", + app_name: APP_NAME, + code: otp_transformed_for_channel, + expiration: expiration, + domain: domain, + ), + ) + end + + def confirmation_message + wrap_in_ssml_if_needed( + I18n.t( + "telephony.confirmation_otp.#{channel}", + app_name: APP_NAME, + code: otp_transformed_for_channel, + expiration: expiration, + domain: domain, + ), + ) + end + + def otp_transformed_for_channel + return otp if channel != :voice + + otp.chars.join(" ") + end + + def wrap_in_ssml_if_needed(message) + return message if channel != :voice + + <<~XML.squish + + + #{message} + + + XML + end + end +end diff --git a/lib/telephony/phone_number_info.rb b/lib/telephony/phone_number_info.rb new file mode 100644 index 00000000000..d4687125106 --- /dev/null +++ b/lib/telephony/phone_number_info.rb @@ -0,0 +1,9 @@ +module Telephony + # @!attribute [r] carrier + # @return [String, nil] the carrier for the phone number + # @!attribute [r] type + # @return [Symbol] returns +:mobile+, +:landline+, +:voip+ or +:unknown+ if there was an error + # @!attribute [r] error + # @return [StandardError, nil] the error looking up the data if there was one + PhoneNumberInfo = Struct.new(:carrier, :type, :error, keyword_init: true) +end diff --git a/lib/telephony/pinpoint/aws_credential_builder.rb b/lib/telephony/pinpoint/aws_credential_builder.rb new file mode 100644 index 00000000000..299ff6b69a1 --- /dev/null +++ b/lib/telephony/pinpoint/aws_credential_builder.rb @@ -0,0 +1,48 @@ +module Telephony + module Pinpoint + class AwsCredentialBuilder + attr_reader :config + + # @param [Telephony::PinpointVoiceConfiguration, Telephony::PinpointSmsConfiguration] config + def initialize(config) + @config = config + end + + def call + if config.credential_role_arn && config.credential_role_session_name + build_assumed_role_credential + elsif config.access_key_id && config.secret_access_key + build_access_key_credential + end + end + + private + + def build_assumed_role_credential + Aws::AssumeRoleCredentials.new( + role_arn: config.credential_role_arn, + role_session_name: config.credential_role_session_name, + external_id: config.credential_external_id, + client: Aws::STS::Client.new(region: config.region), + ) + + # STS makes an HTTP call that can fail + rescue Seahorse::Client::NetworkingError => e + notify_role_failure(error: e, region: config.region) + nil + end + + def build_access_key_credential + Aws::Credentials.new( + config.access_key_id, + config.secret_access_key, + ) + end + + def notify_role_failure(error:, region:) + error_log = { error: error, region: region } + Telephony.config.logger.warn(error_log.to_json) + end + end + end +end diff --git a/lib/telephony/pinpoint/sms_sender.rb b/lib/telephony/pinpoint/sms_sender.rb new file mode 100644 index 00000000000..14d89716069 --- /dev/null +++ b/lib/telephony/pinpoint/sms_sender.rb @@ -0,0 +1,199 @@ +require 'time' + +module Telephony + module Pinpoint + class SmsSender + ERROR_HASH = { + 'DUPLICATE' => DuplicateEndpointError, + 'OPT_OUT' => OptOutError, + 'PERMANENT_FAILURE' => PermanentFailureError, + 'TEMPORARY_FAILURE' => TemporaryFailureError, + 'THROTTLED' => ThrottledError, + 'TIMEOUT' => TimeoutError, + 'UNKNOWN_FAILURE' => UnknownFailureError, + }.freeze + + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength + # @return [Response] + def send(message:, to:, country_code:, otp: nil) + return handle_config_failure if Telephony.config.pinpoint.sms_configs.empty? + + response = nil + Telephony.config.pinpoint.sms_configs.each do |sms_config| + start = Time.zone.now + client = build_client(sms_config) + next if client.nil? + pinpoint_response = client.send_messages( + application_id: sms_config.application_id, + message_request: { + addresses: { + to => { + channel_type: 'SMS', + }, + }, + message_configuration: { + sms_message: { + body: message, + message_type: 'TRANSACTIONAL', + origination_number: sms_config.shortcode, + sender_id: Telephony.config.country_sender_ids[country_code.to_s], + }, + }, + }, + ) + finish = Time.zone.now + response = build_response(pinpoint_response, start: start, finish: finish) + return response if response.success? + notify_pinpoint_failover( + error: response.error, + region: sms_config.region, + extra: response.extra, + ) + rescue Aws::Pinpoint::Errors::InternalServerErrorException, + Aws::Pinpoint::Errors::TooManyRequestsException, + Seahorse::Client::NetworkingError => e + finish = Time.zone.now + response = handle_pinpoint_error(e) + notify_pinpoint_failover( + error: e, + region: sms_config.region, + extra: { + duration_ms: Util.duration_ms(start: start, finish: finish), + }, + ) + end + response || handle_config_failure + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength + + def phone_info(phone_number) + return handle_config_failure if Telephony.config.pinpoint.sms_configs.empty? + + response = nil + error = nil + + Telephony.config.pinpoint.sms_configs.each do |sms_config| + error = nil + client = build_client(sms_config) + next if client.nil? + response = client.phone_number_validate( + number_validate_request: { phone_number: phone_number }, + ) + break if response + rescue Seahorse::Client::NetworkingError => error + notify_pinpoint_failover( + error: error, + region: sms_config.region, + extra: {}, + ) + end + + type = case response&.number_validate_response&.phone_type + when 'MOBILE' + :mobile + when 'LANDLINE' + :landline + when 'VOIP' + :voip + else + :unknown + end + + error ||= unknown_failure_error if !response + + PhoneNumberInfo.new( + type: type, + carrier: response&.number_validate_response&.carrier, + error: error, + ) + end + + # @api private + # @param [PinpointSmsConfig] sms_config + # @return [nil, Aws::Pinpoint::Client] + def build_client(sms_config) + credentials = AwsCredentialBuilder.new(sms_config).call + return if credentials.nil? + Aws::Pinpoint::Client.new( + region: sms_config.region, + retry_limit: 0, + credentials: credentials, + ) + end + + private + + def handle_pinpoint_error(err) + error_message = "#{err.class}: #{err.message}" + + Response.new( + success: false, error: Telephony::TelephonyError.new(error_message), + ) + end + + # rubocop:disable Metrics/MethodLength + def build_response(pinpoint_response, start:, finish:) + message_response_result = pinpoint_response.message_response.result.values.first + + Response.new( + success: success?(message_response_result), + error: error(message_response_result), + extra: { + request_id: pinpoint_response.message_response.request_id, + delivery_status: message_response_result.delivery_status, + message_id: message_response_result.message_id, + status_code: message_response_result.status_code, + status_message: message_response_result.status_message.gsub(/\d/, 'x'), + duration_ms: Util.duration_ms(start: start, finish: finish), + }, + ) + end + # rubocop:enable Metrics/MethodLength + + def success?(message_response_result) + message_response_result.delivery_status == 'SUCCESSFUL' + end + + def error(message_response_result) + return nil if success?(message_response_result) + + status_code = message_response_result.status_code + delivery_status = message_response_result.delivery_status + exception_message = "Pinpoint Error: #{delivery_status} - #{status_code}" + exception_class = ERROR_HASH[delivery_status] || TelephonyError + exception_class.new(exception_message) + end + + def notify_pinpoint_failover(error:, region:, extra:) + response = Response.new( + success: false, + error: error, + extra: extra.merge( + failover: true, + region: region, + channel: 'sms', + ), + ) + Telephony.config.logger.warn(response.to_h.to_json) + end + + def handle_config_failure + response = Response.new( + success: false, + error: unknown_failure_error, + extra: { + channel: 'sms', + }, + ) + + Telephony.config.logger.warn(response.to_h.to_json) + + response + end + + def unknown_failure_error + UnknownFailureError.new('Failed to load AWS config') + end + end + end +end diff --git a/lib/telephony/pinpoint/voice_sender.rb b/lib/telephony/pinpoint/voice_sender.rb new file mode 100644 index 00000000000..04af6aeaa71 --- /dev/null +++ b/lib/telephony/pinpoint/voice_sender.rb @@ -0,0 +1,129 @@ +require 'time' + +module Telephony + module Pinpoint + class VoiceSender + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength + def send(message:, to:, country_code:, otp: nil) + return handle_config_failure if Telephony.config.pinpoint.voice_configs.empty? + + language_code, voice_id = language_code_and_voice_id + + last_error = nil + Telephony.config.pinpoint.voice_configs.each do |voice_config| + start = Time.zone.now + client = build_client(voice_config) + next if client.nil? + response = client.send_voice_message( + content: { + ssml_message: { + text: message, + language_code: language_code, + voice_id: voice_id, + }, + }, + destination_phone_number: to, + origination_phone_number: voice_config.longcode_pool.sample, + ) + finish = Time.zone.now + return Response.new( + success: true, + error: nil, + extra: { + message_id: response.message_id, + duration_ms: Util.duration_ms(start: start, finish: finish), + }, + ) + rescue Aws::PinpointSMSVoice::Errors::ServiceError, + Seahorse::Client::NetworkingError => e + finish = Time.zone.now + last_error = handle_pinpoint_error(e) + notify_pinpoint_failover( + error: e, + region: voice_config.region, + extra: { + message_id: response&.message_id, + duration_ms: Util.duration_ms(start: start, finish: finish), + }, + ) + end + + last_error || handle_config_failure + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/BlockLength + + # @api private + # @param [PinpointVoiceConfiguration] voice_config + # @return [nil, Aws::PinpointSMSVoice::Client] + def build_client(voice_config) + credentials = AwsCredentialBuilder.new(voice_config).call + return if credentials.nil? + + Aws::PinpointSMSVoice::Client.new( + region: voice_config.region, + retry_limit: 0, + credentials: credentials, + ) + end + + private + + def handle_pinpoint_error(err) + request_id = if err.is_a?(Aws::PinpointSMSVoice::Errors::ServiceError) + err&.context&.metadata&.fetch(:request_id, nil) + end + + error_message = "#{err.class}: #{err.message}" + error_class = if err.is_a? Aws::PinpointSMSVoice::Errors::LimitExceededException + Telephony::ThrottledError + else + Telephony::TelephonyError + end + + Response.new( + success: false, error: error_class.new(error_message), extra: { request_id: request_id }, + ) + end + + def notify_pinpoint_failover(error:, region:, extra:) + response = Response.new( + success: false, + error: error, + extra: extra.merge( + failover: true, + region: region, + channel: 'voice', + ), + ) + Telephony.config.logger.warn(response.to_h.to_json) + end + + def language_code_and_voice_id + case I18n.locale.to_sym + when :en + ['en-US', 'Joey'] + when :fr + ['fr-FR', 'Mathieu'] + when :es + ['es-US', 'Miguel'] + else + ['en-US', 'Joey'] + end + end + + def handle_config_failure + response = Response.new( + success: false, + error: UnknownFailureError.new('Failed to load AWS config'), + extra: { + channel: 'sms', + }, + ) + + Telephony.config.logger.warn(response.to_h.to_json) + + response + end + end + end +end diff --git a/lib/telephony/response.rb b/lib/telephony/response.rb new file mode 100644 index 00000000000..7ab61646f71 --- /dev/null +++ b/lib/telephony/response.rb @@ -0,0 +1,30 @@ +module Telephony + class Response + attr_reader :error, :extra + + def initialize(success:, error: nil, extra: {}) + @success = success + @error = error + @extra = extra + end + + def errors + return {} if error.nil? + { + telephony: "#{error.class} - #{error.message}", + } + end + + def success? + @success == true + end + + def to_h + { success: success, errors: errors }.merge!(extra) + end + + private + + attr_reader :success + end +end diff --git a/lib/telephony/test/call.rb b/lib/telephony/test/call.rb new file mode 100644 index 00000000000..a1da22775f8 --- /dev/null +++ b/lib/telephony/test/call.rb @@ -0,0 +1,32 @@ +module Telephony + module Test + class Call + attr_reader :to, :body, :otp, :sent_at + + class << self + def calls + @calls ||= [] + end + + def clear_calls + @calls = [] + end + + def last_otp(phone: nil) + calls.reverse.find do |call| + next false unless phone.nil? || call.to == phone + + true unless call.otp.nil? + end&.otp + end + end + + def initialize(to:, body:, otp:, sent_at: Time.zone.now) + @to = to + @body = body + @otp = otp + @sent_at = sent_at + end + end + end +end diff --git a/lib/telephony/test/error_simulator.rb b/lib/telephony/test/error_simulator.rb new file mode 100644 index 00000000000..bba052320df --- /dev/null +++ b/lib/telephony/test/error_simulator.rb @@ -0,0 +1,17 @@ +module Telephony + module Test + class ErrorSimulator + def error_for_number(number) + cleaned_number = number.gsub(/^\+1/, '').gsub(/\D/, '') + case cleaned_number + when '2255551000' + TelephonyError.new('Simulated telephony error') + when '225555300' + InvalidPhoneNumberError.new('Simulated phone number error') + when '2255552000' + InvalidCallingAreaError.new('Simulated calling area error') + end + end + end + end +end diff --git a/lib/telephony/test/message.rb b/lib/telephony/test/message.rb new file mode 100644 index 00000000000..aeed39313d9 --- /dev/null +++ b/lib/telephony/test/message.rb @@ -0,0 +1,32 @@ +module Telephony + module Test + class Message + attr_reader :to, :body, :otp, :sent_at + + class << self + def messages + @messages ||= [] + end + + def clear_messages + @messages = [] + end + + def last_otp(phone: nil) + messages.reverse.find do |messages| + next false unless phone.nil? || messages.to == phone + + true unless messages.otp.nil? + end&.otp + end + end + + def initialize(to:, body:, otp:, sent_at: Time.zone.now) + @to = to + @body = body + @otp = otp + @sent_at = sent_at + end + end + end +end diff --git a/lib/telephony/test/sms_sender.rb b/lib/telephony/test/sms_sender.rb new file mode 100644 index 00000000000..eeee57de9c5 --- /dev/null +++ b/lib/telephony/test/sms_sender.rb @@ -0,0 +1,45 @@ +module Telephony + module Test + class SmsSender + def send(message:, to:, country_code:, otp: nil) + error = ErrorSimulator.new.error_for_number(to) + if error.nil? + Message.messages.push(Message.new(body: message, to: to, otp: otp)) + success_response + else + Response.new( + success: false, error: error, extra: { request_id: 'fake-message-request-id' }, + ) + end + end + + def phone_info(phone_number) + error = ErrorSimulator.new.error_for_number(phone_number) + case error + when InvalidCallingAreaError + PhoneNumberInfo.new( + type: :voip, + carrier: 'Test VOIP Carrier', + ) + when TelephonyError + PhoneNumberInfo.new( + type: :unknown, + error: error, + ) + else + PhoneNumberInfo.new( + type: :mobile, + carrier: 'Test Mobile Carrier', + ) + end + end + + def success_response + Response.new( + success: true, + extra: { request_id: 'fake-message-request-id', message_id: 'fake-message-id' }, + ) + end + end + end +end diff --git a/lib/telephony/test/voice_sender.rb b/lib/telephony/test/voice_sender.rb new file mode 100644 index 00000000000..61228c94db2 --- /dev/null +++ b/lib/telephony/test/voice_sender.rb @@ -0,0 +1,17 @@ +module Telephony + module Test + class VoiceSender + def send(message:, to:, country_code:, otp: nil) + error = ErrorSimulator.new.error_for_number(to) + if error.nil? + Call.calls.push(Call.new(body: message, to: to, otp: otp)) + Response.new(success: true, extra: { request_id: 'fake-message-request-id' }) + else + Response.new( + success: false, error: error, extra: { request_id: 'fake-message-request-id' }, + ) + end + end + end + end +end diff --git a/lib/telephony/util.rb b/lib/telephony/util.rb new file mode 100644 index 00000000000..396397fb64c --- /dev/null +++ b/lib/telephony/util.rb @@ -0,0 +1,9 @@ +module Telephony + module Util + # @param [Time] start + # @param [Time] finish + def self.duration_ms(start:, finish:) + ((finish.to_f - start.to_f) * 1000.0).to_i + end + end +end diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index e20b012b5ce..5c861ac82f2 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'rails_helper' require 'i18n/tasks' module I18n diff --git a/spec/lib/telephony/alert_sender_spec.rb b/spec/lib/telephony/alert_sender_spec.rb new file mode 100644 index 00000000000..b6d48e12816 --- /dev/null +++ b/spec/lib/telephony/alert_sender_spec.rb @@ -0,0 +1,158 @@ +describe Telephony::AlertSender do + let(:configured_adapter) { :test } + let(:recipient) { '+1 (202) 555-5000' } + + before do + allow(Telephony.config).to receive(:adapter).and_return(configured_adapter) + Telephony::Test::Message.clear_messages + end + + describe 'send_account_reset_notice' do + it 'sends the correct message' do + subject.send_account_reset_notice(to: recipient, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + expect(last_message.to).to eq(recipient) + expect(last_message.body).to eq( + I18n.t('telephony.account_reset_notice'), + ) + end + end + + describe 'send_account_reset_cancellation_notice' do + it 'sends the correct message' do + subject.send_account_reset_cancellation_notice(to: recipient, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + expect(last_message.to).to eq(recipient) + expect(last_message.body).to eq(I18n.t('telephony.account_reset_cancellation_notice')) + end + end + + describe 'send_doc_auth_link' do + let(:link) do + 'https://idp.int.identitysandbox.com/verify/capture-doc/mobile-front-image?token=aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + end + + it 'sends the correct message' do + subject.send_doc_auth_link(to: recipient, link: link, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + expect(last_message.to).to eq(recipient) + expect(last_message.body).to eq(I18n.t('telephony.doc_auth_link', link: link)) + end + + I18n.available_locales.each do |locale| + context "in locale #{locale}" do + around do |ex| + orig_locale = I18n.locale + I18n.locale = locale + ex.run + ensure + I18n.locale = orig_locale + end + + it 'puts the URL in the first 160 characters, so it stays within a single SMS message' do + subject.send_doc_auth_link(to: recipient, link: link, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + first160 = last_message.body[0...160] + expect(first160).to include(link) + end + end + end + + it 'warns if the link is longer than 160 characters' do + long_link = 'a' * 161 + + expect(Telephony.config.logger).to receive(:warn) + + subject.send_doc_auth_link(to: recipient, link: long_link, country_code: 'US') + end + end + + describe 'send_personal_key_regeneration_notice' do + it 'sends the correct message' do + subject.send_personal_key_regeneration_notice(to: recipient, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + expect(last_message.to).to eq(recipient) + expect(last_message.body).to eq(I18n.t('telephony.personal_key_regeneration_notice')) + end + end + + describe 'send_personal_key_sign_in_notice' do + it 'sends the correct message' do + subject.send_personal_key_sign_in_notice(to: recipient, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + expect(last_message.to).to eq(recipient) + expect(last_message.body).to eq(I18n.t('telephony.personal_key_sign_in_notice')) + end + end + + describe 'send_join_keyword_response' do + it 'sends the correct message' do + subject.send_join_keyword_response(to: recipient, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + expect(last_message.to).to eq(recipient) + expect(last_message.body).to eq(I18n.t('telephony.join_keyword_response')) + end + end + + describe 'send_stop_keyword_response' do + it 'sends the correct message' do + subject.send_stop_keyword_response(to: recipient, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + expect(last_message.to).to eq(recipient) + expect(last_message.body).to eq(I18n.t('telephony.stop_keyword_response')) + end + + I18n.available_locales.each do |locale| + context "in locale #{locale}" do + around do |ex| + orig_locale = I18n.locale + I18n.locale = locale + ex.run + ensure + I18n.locale = orig_locale + end + + it 'fits in an SMS messages (160 chars)' do + subject.send_stop_keyword_response(to: recipient, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + expect(last_message.body.size).to be <= 160 + end + end + end + end + + describe 'send_help_keyword_response' do + it 'sends the correct message' do + subject.send_help_keyword_response(to: recipient, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + expect(last_message.to).to eq(recipient) + expect(last_message.body).to eq(I18n.t('telephony.help_keyword_response')) + end + end + + context 'with the pinpoint adapter enabled' do + let(:configured_adapter) { :pinpoint } + + it 'uses the poinpoint adapter to send messages' do + adapter = instance_double(Telephony::Pinpoint::SmsSender) + expect(adapter).to receive(:send).with( + message: I18n.t('telephony.join_keyword_response'), + to: recipient, + country_code: 'US', + ) + expect(Telephony::Pinpoint::SmsSender).to receive(:new).and_return(adapter) + + subject.send_join_keyword_response(to: recipient, country_code: 'US') + end + end +end diff --git a/spec/lib/telephony/otp_sender_spec.rb b/spec/lib/telephony/otp_sender_spec.rb new file mode 100644 index 00000000000..a10db7398f2 --- /dev/null +++ b/spec/lib/telephony/otp_sender_spec.rb @@ -0,0 +1,250 @@ +require 'nokogiri' +require 'rails_helper' + +RSpec.describe Telephony::OtpSender do + before do + Telephony::Test::Message.clear_messages + Telephony::Test::Call.clear_calls + end + + context 'with the test adapter' do + subject do + described_class.new( + to: to, + otp: otp, + expiration: expiration, + channel: channel, + domain: domain, + country_code: country_code, + ) + end + + let(:to) { '+1 (202) 262-1234' } + let(:otp) { '123456' } + let(:expiration) { 5 } + let(:domain) { 'login.gov' } + let(:country_code) { 'US' } + + before do + allow(Telephony.config).to receive(:adapter).and_return(:test) + end + + context 'for SMS' do + let(:channel) { :sms } + + it 'saves the OTP that was sent for authentication' do + subject.send_authentication_otp + + expect(Telephony::Test::Message.last_otp).to eq(otp) + end + + it 'saves the OTP that was sent for confirmation' do + subject.send_confirmation_otp + + expect(Telephony::Test::Message.last_otp).to eq(otp) + end + end + + context 'for Voice' do + let(:channel) { :voice } + + it 'saves the OTP that was sent for authentication' do + subject.send_authentication_otp + + expect(Telephony::Test::Call.last_otp).to eq(otp) + end + + it 'saves the OTP that was sent for confirmation' do + subject.send_confirmation_otp + + expect(Telephony::Test::Call.last_otp).to eq(otp) + end + end + end + + context 'with the pinpoint adapter' do + subject do + described_class.new( + to: to, + otp: otp, + expiration: expiration, + channel: channel, + domain: domain, + country_code: country_code, + ) + end + + let(:to) { '+1 (202) 262-1234' } + let(:otp) { '123456' } + let(:expiration) { 5 } + let(:domain) { 'login.gov' } + let(:country_code) { 'US' } + + before do + allow(Telephony.config).to receive(:adapter).and_return(:pinpoint) + end + + context 'for SMS' do + let(:channel) { :sms } + + it 'sends an authentication OTP with Pinpoint SMS' do + message = "Login.gov: Your security code is 123456. "\ + "It expires in 5 minutes. Don't share this "\ + "code with anyone.\n\n@login.gov #123456" + + adapter = instance_double(Telephony::Pinpoint::SmsSender) + expect(adapter).to receive(:send).with( + message: message, + to: to, + otp: otp, + country_code: 'US', + ) + expect(Telephony::Pinpoint::SmsSender).to receive(:new).and_return(adapter) + + subject.send_authentication_otp + end + + it 'sends a confirmation OTP with Pinpoint SMS' do + message = "Login.gov: Your security code is 123456. It "\ + "expires in 5 minutes. Don't share this code with anyone."\ + "\n\n@login.gov #123456" + + adapter = instance_double(Telephony::Pinpoint::SmsSender) + expect(adapter).to receive(:send).with( + message: message, + to: to, + otp: otp, + country_code: 'US', + ) + expect(Telephony::Pinpoint::SmsSender).to receive(:new).and_return(adapter) + + subject.send_confirmation_otp + end + end + + context 'for voice' do + let(:channel) { :voice } + + it 'sends an authentication OTP with Pinpoint Voice' do + message = <<~XML.squish + + + Hello! Your login.gov one time passcode is, + 1 2 3 + 4 5 6, + again, your passcode is, + 1 2 3 + 4 5 6, + This code expires in 5 minutes. + + + XML + + adapter = instance_double(Telephony::Pinpoint::VoiceSender) + expect(adapter).to receive(:send).with( + message: message, + to: to, + otp: otp, + country_code: country_code, + ) + expect(Telephony::Pinpoint::VoiceSender).to receive(:new).and_return(adapter) + + subject.send_confirmation_otp + end + + it 'sends a confirmation OTP with Pinpoint Voice' do + message = <<~XML.squish + + + Hello! Your login.gov one time passcode is, + 1 2 3 + 4 5 6, + again, your passcode is, + 1 2 3 + 4 5 6, + This code expires in 5 minutes. + + + XML + + adapter = instance_double(Telephony::Pinpoint::VoiceSender) + expect(adapter).to receive(:send).with( + message: message, + to: to, + otp: otp, + country_code: country_code, + ) + expect(Telephony::Pinpoint::VoiceSender).to receive(:new).and_return(adapter) + + subject.send_confirmation_otp + end + + it 'sends valid XML' do + adapter = instance_double(Telephony::Pinpoint::VoiceSender) + expect(adapter).to receive(:send) do |args| + message = args[:message] + expect { Nokogiri::XML(message) { |config| config.strict } }.to_not raise_error + + {} + end + expect(Telephony::Pinpoint::VoiceSender).to receive(:new).and_return(adapter) + + subject.send_confirmation_otp + end + end + end + + describe '#otp_transformed_for_channel' do + let(:country_code) { 'US' } + let(:otp_sender) do + Telephony::OtpSender.new( + to: '+18888675309', + otp: otp, + channel: channel, + expiration: Time.zone.now, + domain: 'login.gov', + country_code: country_code, + ) + end + + subject(:otp_transformed_for_channel) { otp_sender.send(:otp_transformed_for_channel) } + + context 'for voice' do + let(:channel) { :voice } + + context 'with a numeric code' do + let(:otp) { '123456' } + + it 'is the code separated by commas' do + expect(otp_transformed_for_channel). + to eq( + "1 2 3 4 "\ + " 5 6", + ) + end + end + + context 'with an alphanumeric code' do + let(:otp) { 'ABC123' } + + it 'is the code separated by commas' do + expect(otp_transformed_for_channel). + to eq( + "A B C 1 "\ + " 2 3", + ) + end + end + end + + context 'for sms' do + let(:channel) { :sms } + + let(:otp) { 'ABC123' } + + it 'is the code' do + expect(otp_transformed_for_channel).to eq(otp) + end + end + end +end diff --git a/spec/lib/telephony/pinpoint/aws_credential_builder_spec.rb b/spec/lib/telephony/pinpoint/aws_credential_builder_spec.rb new file mode 100644 index 00000000000..282a6aedb34 --- /dev/null +++ b/spec/lib/telephony/pinpoint/aws_credential_builder_spec.rb @@ -0,0 +1,87 @@ +require 'rails_helper' + +describe Telephony::Pinpoint::AwsCredentialBuilder do + include_context 'telephony' + + subject(:credential_builder) { described_class.new(config) } + + let(:credential_role_session_name) { nil } + let(:credential_role_arn) { nil } + let(:credential_external_id) { nil } + let(:access_key_id) { nil } + let(:secret_access_key) { nil } + let(:region) { 'us-west-2' } + + context 'with assumed roles in the config' do + let(:credential_role_session_name) { 'arn:123' } + let(:credential_role_arn) { 'identity-idp' } + let(:credential_external_id) { 'asdf1234' } + + let(:config) do + Telephony::PinpointSmsConfiguration.new( + region: region, + credential_role_session_name: 'arn:123', + credential_role_arn: 'identity-idp', + credential_external_id: 'asdf1234', + ) + end + + it 'returns an assumed role credential' do + sts_client = double(Aws::STS::Client) + allow(Aws::STS::Client).to receive(:new).with(region: region).and_return(sts_client) + expected_credential = instance_double(Aws::AssumeRoleCredentials) + expect(Aws::AssumeRoleCredentials).to receive(:new).with( + role_session_name: credential_role_session_name, + role_arn: credential_role_arn, + external_id: credential_external_id, + client: sts_client, + ).and_return(expected_credential) + + result = credential_builder.call + + expect(result).to eq(expected_credential) + end + + it 'returns nil if STS raises a Seahorse::Client::NetworkingError' do + expected_credential = instance_double(Aws::AssumeRoleCredentials) + expect(Aws::STS::Client).to receive(:new).and_raise( + Seahorse::Client::NetworkingError.new(Net::ReadTimeout.new), + ) + expect(Telephony.config.logger).to receive(:warn) + + result = credential_builder.call + expect(result).to eq(nil) + end + end + + context 'with aws credentials in the config' do + let(:access_key_id) { 'fake-access-key-id' } + let(:secret_access_key) { 'fake-secret-key-id' } + + let(:config) do + Telephony::PinpointVoiceConfiguration.new( + region: region, + access_key_id: access_key_id, + secret_access_key: secret_access_key, + ) + end + + it 'returns a plain old credential object' do + result = credential_builder.call + + expect(result).to be_a(Aws::Credentials) + expect(result.access_key_id).to eq(access_key_id) + expect(result.secret_access_key).to eq(secret_access_key) + end + end + + context 'with no credentials in the config' do + let(:config) { Telephony::PinpointVoiceConfiguration.new(region: region) } + + it 'returns nil' do + result = credential_builder.call + + expect(result).to eq(nil) + end + end +end diff --git a/spec/lib/telephony/pinpoint/sms_sender_spec.rb b/spec/lib/telephony/pinpoint/sms_sender_spec.rb new file mode 100644 index 00000000000..ef91a1117ff --- /dev/null +++ b/spec/lib/telephony/pinpoint/sms_sender_spec.rb @@ -0,0 +1,455 @@ +require 'rails_helper' + +describe Telephony::Pinpoint::SmsSender do + include_context 'telephony' + + subject(:sms_sender) { described_class.new } + let(:sms_config) { Telephony.config.pinpoint.sms_configs.first } + let(:backup_sms_config) { Telephony.config.pinpoint.sms_configs.last } + let(:backup_mock_client) { Pinpoint::MockClient.new(backup_sms_config) } + let(:mock_client) { Pinpoint::MockClient.new(sms_config) } + + # Monkeypatch library class so we can use it for argument matching + class Aws::Credentials + def ==(other) + self.access_key_id == other.access_key_id && + self.secret_access_key == other.secret_access_key + end + end + + describe 'error handling' do + let(:status_code) { 400 } + let(:delivery_status) { 'DUPLICATE' } + let(:raised_error_message) { "Pinpoint Error: #{delivery_status} - #{status_code}" } + + before do + mock_build_client + + Pinpoint::MockClient.message_response_result_status_code = status_code + Pinpoint::MockClient.message_response_result_delivery_status = delivery_status + end + + context 'when endpoint is a duplicate' do + let(:delivery_status) { 'DUPLICATE' } + + it 'raises a duplicate endpoint error' do + response = subject.send(message: 'hello!', to: '+11234567890', country_code: 'US') + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::DuplicateEndpointError.new(raised_error_message)) + expect(response.extra[:delivery_status]).to eq('DUPLICATE') + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + end + + context 'when the user opts out' do + let(:delivery_status) { 'OPT_OUT' } + + it 'raises an opt out error' do + response = subject.send(message: 'hello!', to: '+11234567890', country_code: 'US') + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::OptOutError.new(raised_error_message)) + expect(response.extra[:delivery_status]).to eq('OPT_OUT') + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + end + + context 'when a permanent failure occurs' do + let(:delivery_status) { 'PERMANENT_FAILURE' } + + it 'raises a permanent failure error' do + response = subject.send(message: 'hello!', to: '+11234567890', country_code: 'US') + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::PermanentFailureError.new(raised_error_message)) + expect(response.extra[:delivery_status]).to eq('PERMANENT_FAILURE') + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + end + + context 'when a temporary failure occurs' do + let(:delivery_status) { 'TEMPORARY_FAILURE' } + + it 'raises an opt out error' do + response = subject.send(message: 'hello!', to: '+11234567890', country_code: 'US') + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TemporaryFailureError.new(raised_error_message)) + expect(response.extra[:delivery_status]).to eq('TEMPORARY_FAILURE') + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + end + + context 'when the request is throttled' do + let(:delivery_status) { 'THROTTLED' } + + it 'raises an opt out error' do + response = subject.send(message: 'hello!', to: '+11234567890', country_code: 'US') + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::ThrottledError.new(raised_error_message)) + expect(response.extra[:delivery_status]).to eq('THROTTLED') + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + end + + context 'when the request times out' do + let(:delivery_status) { 'TIMEOUT' } + + it 'raises an opt out error' do + response = subject.send(message: 'hello!', to: '+11234567890', country_code: 'US') + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TimeoutError.new(raised_error_message)) + expect(response.extra[:delivery_status]).to eq('TIMEOUT') + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + end + + context 'when an unkown error occurs' do + let(:delivery_status) { 'UNKNOWN_FAILURE' } + + it 'raises an opt out error' do + response = subject.send(message: 'hello!', to: '+11234567890', country_code: 'US') + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::UnknownFailureError.new(raised_error_message)) + expect(response.extra[:delivery_status]).to eq('UNKNOWN_FAILURE') + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + end + + context 'when the API responds with an unrecognized error' do + let(:delivery_status) { '' } + + it 'raises a generic telephony error' do + response = subject.send(message: 'hello!', to: '+11234567890', country_code: 'US') + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TelephonyError.new(raised_error_message)) + expect(response.extra[:delivery_status]).to eq('') + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + end + + context 'when a timeout exception is raised' do + let(:raised_error_message) { 'Seahorse::Client::NetworkingError: Net::ReadTimeout' } + + it 'handles the exception' do + expect(mock_client).to receive(:send_messages).and_raise( + Seahorse::Client::NetworkingError.new(Net::ReadTimeout.new), + ) + response = subject.send(message: 'hello!', to: '+11234567890', country_code: 'US') + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TelephonyError.new(raised_error_message)) + expect(response.extra[:delivery_status]).to eq nil + expect(response.extra[:request_id]).to eq nil + end + end + end + + describe '#send' do + let(:country_code) { 'US' } + + before do + Telephony.config.country_sender_ids = { + US: 'sender1', + CA: 'sender2', + } + end + + it 'initializes a pinpoint client and uses that to send a message with a shortcode' do + mock_build_client + response = subject.send( + message: 'This is a test!', + to: '+1 (123) 456-7890', + country_code: country_code, + ) + + expected_result = { + application_id: Telephony.config.pinpoint.sms_configs.first.application_id, + message_request: { + addresses: { + '+1 (123) 456-7890' => { channel_type: 'SMS' }, + }, + message_configuration: { + sms_message: { + body: 'This is a test!', + message_type: 'TRANSACTIONAL', + origination_number: '123456', + sender_id: 'sender1', + }, + }, + }, + } + + expect(Pinpoint::MockClient.last_request).to eq(expected_result) + expect(response.success?).to eq(true) + expect(response.error).to eq(nil) + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + + context 'in a country with no sender ID' do + let(:country_code) { 'FR' } + + it 'does not set a sender_id' do + mock_build_client + response = subject.send( + message: 'This is a test!', + to: '+1 (123) 456-7890', + country_code: country_code, + ) + + expect( + Pinpoint::MockClient.last_request.dig( + :message_request, + :message, + :sms_message, + :sender_id, + ), + ).to be_nil + expect(response.success?).to eq(true) + end + end + + context 'with multiple sms configs' do + before do + Telephony.config.pinpoint.add_sms_config do |sms| + sms.region = 'backup-sms-region' + sms.access_key_id = 'fake-pnpoint-access-key-id-sms' + sms.secret_access_key = 'fake-pinpoint-secret-access-key-sms' + sms.application_id = 'backup-sms-application-id' + end + + mock_build_client + mock_build_backup_client + end + + context 'when the first config succeeds' do + it 'only tries one client' do + expect(backup_mock_client).to_not receive(:send_messages) + + response = subject.send( + message: 'This is a test!', + to: '+1 (123) 456-7890', + country_code: 'US', + ) + expect(response.success?).to eq(true) + end + end + + context 'when the first config errors' do + before do + Pinpoint::MockClient.message_response_result_status_code = 400 + Pinpoint::MockClient.message_response_result_delivery_status = 'DUPLICATE' + end + + it 'logs a warning for each failure and tries the other configs' do + expect(Telephony.config.logger).to receive(:warn).exactly(2).times + + response = subject.send( + message: 'This is a test!', + to: '+1 (123) 456-7890', + country_code: 'US', + ) + + expect(response.success?).to eq(false) + end + end + + context 'when the first config raises a timeout exception' do + let(:raised_error_message) { 'Seahorse::Client::NetworkingError: Net::ReadTimeout' } + + it 'logs a warning for each failure and tries the other configs' do + expect(mock_client).to receive(:send_messages).and_raise( + Seahorse::Client::NetworkingError.new( + Net::ReadTimeout.new, + ), + ).once + expect(backup_mock_client).to receive(:send_messages).and_raise( + Seahorse::Client::NetworkingError.new( + Net::ReadTimeout.new, + ), + ).once + + response = subject.send( + message: 'This is a test!', + to: '+1 (123) 456-7890', + country_code: 'US', + ) + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TelephonyError.new(raised_error_message)) + end + end + + context 'when all sms configs fail to build' do + let(:raised_error_message) { 'Failed to load AWS config' } + let(:mock_client) { nil } + let(:backup_mock_client) { nil } + + it 'logs a warning and returns an error' do + expect(Telephony.config.logger).to receive(:warn).once + + response = subject.send( + message: 'This is a test!', + to: '+1 (123) 456-7890', + country_code: 'US', + ) + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::UnknownFailureError.new(raised_error_message)) + end + end + end + + context 'when the exception message contains a phone number' do + let(:phone_numbers) do + Aws::Pinpoint::Types::SendMessagesResponse.new( + message_response: Aws::Pinpoint::Types::MessageResponse.new( + result: { phone: Aws::Pinpoint::Types::MessageResult.new( + delivery_status: 'PERMANENT_FAILURE', + message_id: 'abc', + status_code: 400, + status_message: '+1-555-5555 +15555555 (555)-5555', + updated_token: '', + ) }, + ), + ) + end + + before do + mock_build_client + mock_build_backup_client + + allow(mock_client).to receive(:send_messages).and_return(phone_numbers) + allow(backup_mock_client).to receive(:send_messages).and_return(phone_numbers) + end + + it 'does not include the phone number in the results' do + response = subject.send( + message: 'This is a test!', + to: '+1 (123) 456-7890', + country_code: 'US', + ) + expect(response.extra[:status_message]).to_not match(/\d/) + expect(response.extra[:status_message]).to include('+x-xxx-xxxx +xxxxxxxx (xxx)-xxxx') + end + end + end + + def mock_build_client(client = mock_client) + expect(sms_sender).to receive(:build_client).with(sms_config).and_return(client) + end + + def mock_build_backup_client(client = backup_mock_client) + allow(sms_sender).to receive(:build_client).with(backup_sms_config).and_return(client) + end + + describe '#phone_info' do + let(:phone_number) { '+18888675309' } + let(:pinpoint_client) { Aws::Pinpoint::Client.new(stub_responses: true) } + + subject(:phone_info) do + sms_sender.phone_info(phone_number) + end + + before do + Telephony.config.pinpoint.add_sms_config do |sms| + sms.region = 'backup-sms-region' + sms.access_key_id = 'fake-pnpoint-access-key-id-sms' + sms.secret_access_key = 'fake-pinpoint-secret-access-key-sms' + sms.application_id = 'backup-sms-application-id' + end + + mock_build_client(pinpoint_client) + mock_build_backup_client(pinpoint_client) + end + + context 'successful network requests' do + before do + pinpoint_client.stub_responses( + :phone_number_validate, + number_validate_response: { + phone_type: phone_type, + carrier: 'Example Carrier', + }, + ) + end + + let(:phone_type) { 'MOBILE' } + + it 'has the carrier' do + expect(phone_info.carrier).to eq('Example Carrier') + end + + it 'has a blank error' do + expect(phone_info.error).to be_nil + end + + context 'when the phone number is a mobile number' do + let(:phone_type) { 'MOBILE' } + it { expect(phone_info.type).to eq(:mobile) } + end + + context 'when the phone number is a voip number' do + let(:phone_type) { 'VOIP' } + it { expect(phone_info.type).to eq(:voip) } + end + + context 'when the phone number is a landline number' do + let(:phone_type) { 'LANDLINE' } + it { expect(phone_info.type).to eq(:landline) } + end + + context 'when the phone number is some unhandled type' do + let(:phone_type) { 'NEW_MAGICAL_TYPE' } + it { expect(phone_info.type).to eq(:unknown) } + end + end + + context 'when the first config raises a timeout exception' do + let(:phone_type) { 'VOIP' } + + before do + pinpoint_client.stub_responses( + :phone_number_validate, [ + Seahorse::Client::NetworkingError.new(Timeout::Error.new), + { number_validate_response: { phone_type: phone_type } }, + ] + ) + end + + it 'logs a warning for each failure and tries the other configs' do + expect(Telephony.config.logger).to receive(:warn).exactly(1).times + + expect(phone_info.type).to eq(:voip) + expect(phone_info.error).to be_nil + end + end + + context 'when all configs raise errors' do + before do + pinpoint_client.stub_responses( + :phone_number_validate, + Seahorse::Client::NetworkingError.new(Timeout::Error.new), + ) + end + + it 'logs a warning for each failure and returns unknown' do + expect(Telephony.config.logger).to receive(:warn).exactly(2).times + + expect(phone_info.type).to eq(:unknown) + expect(phone_info.error).to be_kind_of(Seahorse::Client::NetworkingError) + end + end + + context 'when all sms configs fail to build' do + let(:pinpoint_client) { nil } + + it 'returns unknown' do + expect(phone_info.type).to eq(:unknown) + expect(phone_info.error).to be_kind_of(Telephony::UnknownFailureError) + end + end + end +end diff --git a/spec/lib/telephony/pinpoint/voice_sender_spec.rb b/spec/lib/telephony/pinpoint/voice_sender_spec.rb new file mode 100644 index 00000000000..7fafea5c086 --- /dev/null +++ b/spec/lib/telephony/pinpoint/voice_sender_spec.rb @@ -0,0 +1,244 @@ +require 'rails_helper' + +describe Telephony::Pinpoint::VoiceSender do + include_context 'telephony' + + subject(:voice_sender) { described_class.new } + + let(:pinpoint_client) { Aws::PinpointSMSVoice::Client.new(stub_responses: true) } + let(:voice_config) { Telephony.config.pinpoint.voice_configs.first } + + let(:backup_pinpoint_client) { Aws::PinpointSMSVoice::Client.new(stub_responses: true) } + let(:backup_voice_config) { Telephony.config.pinpoint.voice_configs.last } + + def mock_build_client + allow(voice_sender). + to receive(:build_client).with(voice_config).and_return(pinpoint_client) + end + + def mock_build_backup_client + allow(voice_sender). + to receive(:build_client).with(backup_voice_config).and_return(backup_pinpoint_client) + end + + describe '#send' do + let(:pinpoint_response) do + double(message_id: 'fake-message-id') + end + let(:message) { 'This is a test!' } + let(:sending_phone) { '+12223334444' } + let(:recipient_phone) { '+1 (123) 456-7890' } + let(:expected_message) do + { + content: { + ssml_message: { + text: message, + language_code: 'en-US', + voice_id: 'Joey', + }, + }, + destination_phone_number: recipient_phone, + origination_phone_number: sending_phone, + } + end + + before do + # More deterministic sending phone + Telephony.config.pinpoint.voice_configs.first.longcode_pool = [sending_phone] + + mock_build_client + end + + it 'initializes a pinpoint sms and voice client and uses that to send a message' do + expect(pinpoint_client).to receive(:send_voice_message). + with(expected_message). + and_return(pinpoint_response) + + response = voice_sender.send(message: message, to: recipient_phone, country_code: 'US') + + expect(response.success?).to eq(true) + expect(response.extra[:message_id]).to eq('fake-message-id') + end + + context 'when the current locale is spanish' do + before do + allow(I18n).to receive(:locale).and_return(:es) + end + + it 'calls the user with a spanish voice' do + expected_message[:content][:ssml_message][:language_code] = 'es-US' + expected_message[:content][:ssml_message][:voice_id] = 'Miguel' + expect(pinpoint_client).to receive(:send_voice_message). + with(expected_message). + and_return(pinpoint_response) + + response = voice_sender.send(message: message, to: recipient_phone, country_code: 'US') + + expect(response.success?).to eq(true) + expect(response.extra[:message_id]).to eq('fake-message-id') + end + end + + context 'when the current locale is french' do + before do + allow(I18n).to receive(:locale).and_return(:fr) + end + + it 'calls the user with a french voice' do + expected_message[:content][:ssml_message][:language_code] = 'fr-FR' + expected_message[:content][:ssml_message][:voice_id] = 'Mathieu' + expect(pinpoint_client).to receive(:send_voice_message). + with(expected_message). + and_return(pinpoint_response) + + response = voice_sender.send(message: message, to: recipient_phone, country_code: 'US') + + expect(response.success?).to eq(true) + expect(response.extra[:message_id]).to eq('fake-message-id') + end + end + + context 'when pinpoint responds with a limit exceeded response' do + it 'returns a telephony error' do + exception = Aws::PinpointSMSVoice::Errors::LimitExceededException.new( + Seahorse::Client::RequestContext.new, + 'This is a test message', + ) + expect(pinpoint_client).to receive(:send_voice_message).and_raise(exception) + + response = voice_sender.send(message: message, to: recipient_phone, country_code: 'US') + + error_message = + 'Aws::PinpointSMSVoice::Errors::LimitExceededException: This is a test message' + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::ThrottledError.new(error_message)) + end + end + + context 'when pinpoint responds with an internal service error' do + it 'returns a telephony error' do + exception = Aws::PinpointSMSVoice::Errors::InternalServiceErrorException.new( + Seahorse::Client::RequestContext.new, + 'This is a test message', + ) + expect(pinpoint_client).to receive(:send_voice_message).and_raise(exception) + + response = voice_sender.send(message: message, to: recipient_phone, country_code: 'US') + + error_message = + 'Aws::PinpointSMSVoice::Errors::InternalServiceErrorException: This is a test message' + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TelephonyError.new(error_message)) + end + end + + context 'when pinpoint responds with a generic error' do + it 'returns a telephony error' do + exception = Aws::PinpointSMSVoice::Errors::BadRequestException.new( + Seahorse::Client::RequestContext.new, + 'This is a test message', + ) + expect(pinpoint_client).to receive(:send_voice_message).and_raise(exception) + + response = voice_sender.send(message: message, to: recipient_phone, country_code: 'US') + + error_message = + 'Aws::PinpointSMSVoice::Errors::BadRequestException: This is a test message' + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TelephonyError.new(error_message)) + end + end + + context 'when pinpoint raises a timeout exception' do + it 'rescues the exception and returns an error' do + exception = Seahorse::Client::NetworkingError.new(Net::ReadTimeout.new) + expect(pinpoint_client). + to receive(:send_voice_message).and_raise(exception) + + response = voice_sender.send(message: message, to: recipient_phone, country_code: 'US') + + error_message = 'Seahorse::Client::NetworkingError: Net::ReadTimeout' + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TelephonyError.new(error_message)) + end + end + + context 'with multiple voice configs' do + before do + Telephony.config.pinpoint.add_voice_config do |voice| + voice.region = 'backup-region' + voice.access_key_id = 'fake-pinpoint-access-key-id-voice' + voice.secret_access_key = 'fake-pinpoint-secret-access-key-voice' + voice.longcode_pool = [backup_longcode] + end + + mock_build_backup_client + end + + let(:backup_longcode) { '+18881112222' } + + context 'when the first config succeeds' do + before do + expect(pinpoint_client).to receive(:send_voice_message). + with(expected_message). + and_return(pinpoint_response) + + expect(backup_pinpoint_client).to_not receive(:send_voice_message) + end + + it 'only tries one client' do + response = voice_sender.send(message: message, to: recipient_phone, country_code: 'US') + expect(response.success?).to eq(true) + expect(response.extra[:message_id]).to eq('fake-message-id') + end + end + + context 'when the first config errors' do + before do + # first config errors + exception = Aws::PinpointSMSVoice::Errors::BadRequestException.new( + Seahorse::Client::RequestContext.new, + 'This is a test message', + ) + expect(pinpoint_client).to receive(:send_voice_message).and_raise(exception) + + # second config succeeds + expected_message[:origination_phone_number] = backup_longcode + expect(backup_pinpoint_client).to receive(:send_voice_message). + with(expected_message). + and_return(pinpoint_response) + end + + it 'logs a warning and tries the other configs' do + expect(Telephony.config.logger).to receive(:warn) + + response = voice_sender.send(message: message, to: recipient_phone, country_code: 'US') + expect(response.success?).to eq(true) + expect(response.extra[:message_id]).to eq('fake-message-id') + end + end + end + + context 'when all voice configs fail to build' do + let(:raised_error_message) { 'Failed to load AWS config' } + let(:pinpoint_client) { nil } + let(:backup_pinpoint_client) { nil } + + it 'logs a warning and returns an error' do + expect(Telephony.config.logger).to receive(:warn) + + response = subject.send( + message: 'This is a test!', + to: '+1 (123) 456-7890', + country_code: 'US', + ) + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::UnknownFailureError.new(raised_error_message)) + end + end + end +end diff --git a/spec/lib/telephony/response_spec.rb b/spec/lib/telephony/response_spec.rb new file mode 100644 index 00000000000..4c3c350dbba --- /dev/null +++ b/spec/lib/telephony/response_spec.rb @@ -0,0 +1,48 @@ +describe Telephony::Response do + context 'for a successful response' do + subject { described_class.new(success: true, extra: { test: '1234' }) } + + it 'is successful' do + expect(subject.success?).to eq(true) + end + + it 'returns an empty errors hash' do + expect(subject.errors).to eq({}) + end + + it 'can be serialized into a hash' do + hash = subject.to_h + + expect(hash).to eq( + success: true, + errors: {}, + test: '1234', + ) + end + end + + context 'for a failed response' do + let(:error) { StandardError.new('hello') } + subject { described_class.new(success: false, error: error, extra: { test: '1234' }) } + + it 'is not successful' do + expect(subject.success?).to eq(false) + end + + it 'returns an errors hash' do + expect(subject.errors).to eq( + telephony: 'StandardError - hello', + ) + end + + it 'can be serialized into a hash' do + hash = subject.to_h + + expect(hash).to eq( + success: false, + errors: { telephony: 'StandardError - hello' }, + test: '1234', + ) + end + end +end diff --git a/spec/lib/telephony/telephony_spec.rb b/spec/lib/telephony/telephony_spec.rb new file mode 100644 index 00000000000..1f733ddcb64 --- /dev/null +++ b/spec/lib/telephony/telephony_spec.rb @@ -0,0 +1,35 @@ +require 'rails_helper' + +RSpec.describe Telephony do + include_context 'telephony' + + describe '.phone_info' do + let(:phone_number) { '+18888675309' } + subject(:phone_info) { Telephony.phone_info(phone_number) } + + context 'with test adapter' do + before { Telephony.config { |c| c.adapter = :test } } + + it 'uses the test adapter' do + expect(phone_info.type).to eq(:mobile) + end + end + + context 'with pinpoint adapter' do + before do + Telephony.config { |c| c.adapter = :pinpoint } + Aws.config[:pinpoint] = { + stub_responses: { + phone_number_validate: { + number_validate_response: { phone_type: 'VOIP' }, + }, + }, + } + end + + it 'uses the pinpoint adapter' do + expect(phone_info.type).to eq(:voip) + end + end + end +end diff --git a/spec/lib/telephony/test/call_spec.rb b/spec/lib/telephony/test/call_spec.rb new file mode 100644 index 00000000000..fa7b9d147bd --- /dev/null +++ b/spec/lib/telephony/test/call_spec.rb @@ -0,0 +1,99 @@ +describe Telephony::Test::Call do + let(:body) { 'The code is 1, 2, 3, 4, 5, 6' } + let(:otp) { '123456' } + + subject { described_class.new(to: '+1 (555) 555-5000', body: body, otp: otp) } + + describe '#otp' do + context 'the call contains an OTP' do + it 'returns the OTP' do + expect(subject.otp).to eq('123456') + end + end + + context 'the call does not contain an OTP' do + let(:body) { 'this is a plain old alert' } + let(:otp) { nil } + + it 'returns nil' do + expect(subject.otp).to eq(nil) + end + end + end + + describe '.last_otp' do + before do + described_class.clear_calls + [ + described_class.new( + to: '+1 (555) 1111', + body: 'A, B, C, 1, 2, 3 is the code', + otp: 'ABC123', + ), + described_class.new( + to: '+1 (555) 2222', + body: 'A, B, C, D, E, F is the code', + otp: 'ABCDEF', + ), + described_class.new( + to: '+1 (555) 5000', + body: '1, 1, 1, 1, 1, 1 is the code', + otp: '111111', + ), + described_class.new( + to: '+1 (555) 5000', + body: '2, 2, 2, 2, 2, 2 is the code', + otp: '222222', + ), + described_class.new( + to: '+1 (555) 5000', + body: 'plain alert', + otp: nil, + ), + described_class.new( + to: '+1 (555) 4000', + body: '3, 3, 3, 3, 3, 3 is the code', + otp: '333333', + ), + described_class.new( + to: '+1 (555) 4000', + body: 'plain alert', + otp: nil, + ), + ].each do |call| + described_class.calls.push(call) + end + end + + context 'with a phone number' do + it 'returns the most recent OTP for that phone number' do + result = described_class.last_otp(phone: '+1 (555) 5000') + + expect(result).to eq('222222') + end + end + + context 'without a phone number' do + it 'returns the most recent OTP for any phone number' do + result = described_class.last_otp + + expect(result).to eq('333333') + end + end + + context 'when there have been no calls' do + it 'returns nil' do + described_class.clear_calls + + expect(described_class.last_otp).to eq(nil) + end + end + + context 'with alphanumeric OTPs' do + it 'returns the most recent ones' do + expect(described_class.last_otp(phone: '+1 (555) 1111')).to eq('ABC123') + expect(described_class.last_otp(phone: '+1 (555) 2222')).to eq('ABCDEF') + end + end + end +end diff --git a/spec/lib/telephony/test/message_spec.rb b/spec/lib/telephony/test/message_spec.rb new file mode 100644 index 00000000000..bac29ace84a --- /dev/null +++ b/spec/lib/telephony/test/message_spec.rb @@ -0,0 +1,71 @@ +describe Telephony::Test::Message do + let(:body) { 'The code is 123456' } + let(:otp) { '123456' } + + subject { described_class.new(to: '+1 (555) 555-5000', body: body, otp: otp) } + + describe '#otp' do + context 'the message contains an OTP' do + it 'returns the OTP' do + expect(subject.otp).to eq('123456') + end + end + + context 'the message does not contain an OTP' do + let(:body) { 'this is a plain old alert' } + let(:otp) { nil } + + it 'returns nil' do + expect(subject.otp).to eq(nil) + end + end + end + + describe '.last_otp' do + before do + described_class.clear_messages + [ + described_class.new(to: '+1 (555) 1111', body: 'ABC123 is the code', otp: 'ABC123'), + described_class.new(to: '+1 (555) 2222', body: 'ABCDEF is the code', otp: 'ABCDEF'), + described_class.new(to: '+1 (555) 5000', body: '111111 is the code', otp: '111111'), + described_class.new(to: '+1 (555) 5000', body: '222222 is the code', otp: '222222'), + described_class.new(to: '+1 (555) 5000', body: 'plain alert', otp: nil), + described_class.new(to: '+1 (555) 4000', body: '333333 is the code', otp: '333333'), + described_class.new(to: '+1 (555) 4000', body: 'plain alert', otp: nil), + ].each do |message| + described_class.messages.push(message) + end + end + + context 'with a phone number' do + it 'returns the most recent OTP for that phone number' do + result = described_class.last_otp(phone: '+1 (555) 5000') + + expect(result).to eq('222222') + end + end + + context 'without a phone number' do + it 'returns the most recent OTP for any phone number' do + result = described_class.last_otp + + expect(result).to eq('333333') + end + end + + context 'when there have been no messages' do + it 'returns nil' do + described_class.clear_messages + + expect(described_class.last_otp).to eq(nil) + end + end + + context 'with alphanumeric OTPs' do + it 'returns the most recent ones' do + expect(described_class.last_otp(phone: '+1 (555) 1111')).to eq('ABC123') + expect(described_class.last_otp(phone: '+1 (555) 2222')).to eq('ABCDEF') + end + end + end +end diff --git a/spec/lib/telephony/test/sms_sender_spec.rb b/spec/lib/telephony/test/sms_sender_spec.rb new file mode 100644 index 00000000000..86580e514a2 --- /dev/null +++ b/spec/lib/telephony/test/sms_sender_spec.rb @@ -0,0 +1,97 @@ +require 'rails_helper' + +describe Telephony::Test::SmsSender do + include_context 'telephony' + + before do + Telephony::Test::Message.clear_messages + end + + subject(:sms_sender) { Telephony::Test::SmsSender.new } + + describe '#send' do + it 'adds the message to the message stack' do + message_body = 'This is a test' + phone = '+1 (202) 555-5000' + + response = subject.send(message: message_body, to: phone, country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + + expect(response.success?).to eq(true) + expect(response.error).to eq(nil) + expect(response.extra[:request_id]).to eq('fake-message-request-id') + expect(response.extra[:message_id]).to eq('fake-message-id') + expect(last_message.body).to eq(message_body) + expect(last_message.to).to eq(phone) + end + + it 'simulates a telephony error' do + response = subject.send(message: 'test', to: '+1 (225) 555-1000', country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TelephonyError.new('Simulated telephony error')) + expect(response.extra[:request_id]).to eq('fake-message-request-id') + expect(last_message).to eq(nil) + end + + it 'simulates an invalid phone number error' do + response = subject.send(message: 'test', to: '+1 (225) 555-300', country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + pp response + expect(response.success?).to eq(false) + expect(response.error).to eq( + Telephony::InvalidPhoneNumberError.new('Simulated phone number error'), + ) + expect(response.extra[:request_id]).to eq('fake-message-request-id') + expect(last_message).to eq(nil) + end + + it 'simulates an invalid calling area error' do + response = subject.send(message: 'test', to: '+1 (225) 555-2000', country_code: 'US') + + last_message = Telephony::Test::Message.messages.last + + expect(response.success?).to eq(false) + expect(response.error).to eq( + Telephony::InvalidCallingAreaError.new('Simulated calling area error'), + ) + expect(response.extra[:request_id]).to eq('fake-message-request-id') + expect(last_message).to eq(nil) + end + end + + describe '#phone_info' do + subject(:phone_info) { sms_sender.phone_info(phone_number) } + + context 'with a phone number that does not generate errors' do + let(:phone_number) { '+18888675309' } + it 'has a successful response' do + expect(phone_info.type).to eq(:mobile) + expect(phone_info.carrier).to eq('Test Mobile Carrier') + expect(phone_info.error).to be_nil + end + end + + context 'generating a voip phone number' do + let(:phone_number) { '+12255552000' } + it 'has an error response' do + expect(phone_info.type).to eq(:voip) + expect(phone_info.carrier).to eq('Test VOIP Carrier') + expect(phone_info.error).to be_nil + end + end + + context 'generating an error response' do + let(:phone_number) { '+12255551000' } + it 'has an error response' do + expect(phone_info.type).to eq(:unknown) + expect(phone_info.carrier).to be_nil + expect(phone_info.error).to be_kind_of(StandardError) + end + end + end +end diff --git a/spec/lib/telephony/test/voice_sender_spec.rb b/spec/lib/telephony/test/voice_sender_spec.rb new file mode 100644 index 00000000000..fbc744d4fe1 --- /dev/null +++ b/spec/lib/telephony/test/voice_sender_spec.rb @@ -0,0 +1,46 @@ +describe Telephony::Test::VoiceSender do + before do + Telephony::Test::Call.clear_calls + end + + describe '#send' do + it 'adds the call to the call stack' do + call_body = 'This is a test' + phone = '+1 (202) 555-5000' + + response = subject.send(message: call_body, to: phone, country_code: 'US') + + last_call = Telephony::Test::Call.calls.last + + expect(last_call.body).to eq(call_body) + expect(last_call.to).to eq(phone) + expect(response.success?).to eq(true) + expect(response.error).to eq(nil) + expect(response.extra[:request_id]).to eq('fake-message-request-id') + end + + it 'simulates a telephony error' do + response = subject.send(message: 'test', to: '+1 (225) 555-1000', country_code: 'US') + + last_call = Telephony::Test::Call.calls.last + + expect(response.success?).to eq(false) + expect(response.error).to eq(Telephony::TelephonyError.new('Simulated telephony error')) + expect(response.extra[:request_id]).to eq('fake-message-request-id') + expect(last_call).to eq(nil) + end + + it 'simulates an invalid calling area error' do + response = subject.send(message: 'test', to: '+1 (225) 555-2000', country_code: 'US') + + last_call = Telephony::Test::Call.calls.last + + expect(response.success?).to eq(false) + expect(response.error).to eq( + Telephony::InvalidCallingAreaError.new('Simulated calling area error'), + ) + expect(response.extra[:request_id]).to eq('fake-message-request-id') + expect(last_call).to eq(nil) + end + end +end diff --git a/spec/lib/telephony/util_spec.rb b/spec/lib/telephony/util_spec.rb new file mode 100644 index 00000000000..1e71d7a1b64 --- /dev/null +++ b/spec/lib/telephony/util_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +RSpec.describe Telephony::Util do + describe '.duration_ms' do + it 'is the duration in whole milliseconds between two times' do + start = Time.zone.at(1590609718.1231) + finish = Time.zone.at(1590609719.999) + + expect(Telephony::Util.duration_ms(start: start, finish: finish)).to eq(1875) + end + end +end diff --git a/spec/support/pinpoint_mock_client.rb b/spec/support/pinpoint_mock_client.rb new file mode 100644 index 00000000000..3e4b00e4afb --- /dev/null +++ b/spec/support/pinpoint_mock_client.rb @@ -0,0 +1,59 @@ +module Pinpoint + class MockClient + include ::RSpec::Matchers + + class << self + attr_accessor :last_request + attr_accessor :message_response_request_id + attr_accessor :message_response_result_status_code + attr_accessor :message_response_result_delivery_status + attr_accessor :message_response_result_status_message + attr_accessor :message_response_result_message_id + + def reset! + self.last_request = nil + self.message_response_request_id = 'fake-message-request-id' + self.message_response_result_status_code = 200 + self.message_response_result_delivery_status = 'SUCCESSFUL' + self.message_response_result_message_id = 'fake-message-id' + self.message_response_result_status_message = "MessageId: "\ + "#{self.message_response_result_message_id}" + end + end + + Response = Struct.new(:message_response) + MessageResponse = Struct.new(:result, :request_id) + MessageResponseResult = Struct.new( + :status_code, + :delivery_status, + :status_message, + :message_id, + ) + + attr_reader :config + + def initialize(config) + @config = config + end + + def send_messages(request) + expect(request[:application_id]).to eq(config.application_id) + + self.class.last_request = request + + addresses = request.dig(:message_request, :addresses).keys + expect(addresses.length).to eq(1) + recipient_phone = addresses.first + + result_hash = { + recipient_phone => MessageResponseResult.new( + self.class.message_response_result_status_code, + self.class.message_response_result_delivery_status, + self.class.message_response_result_status_message, + self.class.message_response_result_message_id, + ), + } + Response.new(MessageResponse.new(result_hash, self.class.message_response_request_id)) + end + end +end diff --git a/spec/support/shared_contexts/telephony.rb b/spec/support/shared_contexts/telephony.rb new file mode 100644 index 00000000000..e0b71f43020 --- /dev/null +++ b/spec/support/shared_contexts/telephony.rb @@ -0,0 +1,42 @@ +require 'telephony' + +def telephony_use_default_config! + # Setup some default configs + Telephony.config do |c| + c.logger = Logger.new(nil) + + c.voice_pause_time = '0.5s' + c.voice_rate = 'slow' + + c.pinpoint.add_sms_config do |sms| + sms.region = 'fake-pinpoint-region-sms' + sms.access_key_id = 'fake-pnpoint-access-key-id-sms' + sms.secret_access_key = 'fake-pinpoint-secret-access-key-sms' + sms.application_id = 'fake-pinpoint-application-id-sms' + sms.shortcode = '123456' + end + + c.pinpoint.add_voice_config do |voice| + voice.region = 'fake-pinpoint-region-voice' + voice.access_key_id = 'fake-pinpoint-access-key-id-voice' + voice.secret_access_key = 'fake-pinpoint-secret-access-key-voice' + voice.longcode_pool = ['+12223334444', '+15556667777'] + end + end +end + +RSpec.shared_context 'telephony' do + before do + Pinpoint::MockClient.reset! + end + + around do |ex| + old_config = Telephony.config.dup + Telephony.instance_variable_set(:@config, nil) + telephony_use_default_config! + + ex.run + ensure + Telephony.instance_variable_set(:@config, old_config) + end +end