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