diff --git a/app/models/in_person_enrollment.rb b/app/models/in_person_enrollment.rb index 46fb9cb6ba9..a3c93490af1 100644 --- a/app/models/in_person_enrollment.rb +++ b/app/models/in_person_enrollment.rb @@ -8,6 +8,9 @@ class InPersonEnrollment < ApplicationRecord primary_key: 'issuer', inverse_of: :in_person_enrollments, optional: true + + has_one :notification_phone_configuration, dependent: :destroy, inverse_of: :in_person_enrollment + enum status: { establishing: 0, pending: 1, diff --git a/app/models/notification_phone_configuration.rb b/app/models/notification_phone_configuration.rb new file mode 100644 index 00000000000..0dac0f13f7f --- /dev/null +++ b/app/models/notification_phone_configuration.rb @@ -0,0 +1,20 @@ +class NotificationPhoneConfiguration < ApplicationRecord + include EncryptableAttribute + + belongs_to :in_person_enrollment, inverse_of: :notification_phone_configuration + validates :encrypted_phone, presence: true + + encrypted_attribute(name: :phone) + + def formatted_phone + PhoneFormatter.format(phone) + end + + def masked_phone + PhoneFormatter.mask(phone) + end + + def friendly_name + :phone + end +end diff --git a/app/models/phone_configuration.rb b/app/models/phone_configuration.rb index 7cd2ad3b1e6..4220d35fbf4 100644 --- a/app/models/phone_configuration.rb +++ b/app/models/phone_configuration.rb @@ -9,14 +9,11 @@ class PhoneConfiguration < ApplicationRecord enum delivery_preference: { sms: 0, voice: 1 } def formatted_phone - Phonelib.parse(phone).international + PhoneFormatter.format(phone) end def masked_phone - return '' if phone.blank? - - formatted = Phonelib.parse(phone).national - formatted[0..-5].gsub(/\d/, '*') + formatted[-4..-1] + PhoneFormatter.mask(phone) end def selection_presenters diff --git a/app/services/phone_formatter.rb b/app/services/phone_formatter.rb index f1d7cff9e39..0e2c3618473 100644 --- a/app/services/phone_formatter.rb +++ b/app/services/phone_formatter.rb @@ -5,4 +5,11 @@ def self.format(phone, country_code: nil) country_code = DEFAULT_COUNTRY if country_code.nil? && !phone&.start_with?('+') Phonelib.parse(phone, country_code)&.international end + + def self.mask(phone) + return '' if phone.blank? + + formatted = Phonelib.parse(phone).national + formatted[0..-5].gsub(/\d/, '*') + formatted[-4..-1] + end end diff --git a/db/primary_migrate/20230627213457_add_notification_phone_configurations_table.rb b/db/primary_migrate/20230627213457_add_notification_phone_configurations_table.rb new file mode 100644 index 00000000000..f0872ada61c --- /dev/null +++ b/db/primary_migrate/20230627213457_add_notification_phone_configurations_table.rb @@ -0,0 +1,9 @@ +class AddNotificationPhoneConfigurationsTable < ActiveRecord::Migration[7.0] + def change + create_table :notification_phone_configurations do |t| + t.references :in_person_enrollment, null: false, index: { name: 'index_notification_phone_configurations_on_enrollment_id', unique: true } + t.text :encrypted_phone, null: false, comment: 'Encrypted phone number to send notifications to' + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index e22bef9f3fc..0e254d69d95 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_06_22_142018) do +ActiveRecord::Schema[7.0].define(version: 2023_06_27_213457) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pgcrypto" @@ -371,6 +371,14 @@ t.index ["issuer", "year_month", "user_id"], name: "index_monthly_auth_counts_on_issuer_and_year_month_and_user_id", unique: true end + create_table "notification_phone_configurations", force: :cascade do |t| + t.bigint "in_person_enrollment_id", null: false + t.text "encrypted_phone", null: false, comment: "Encrypted phone number to send notifications to" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["in_person_enrollment_id"], name: "index_notification_phone_configurations_on_enrollment_id", unique: true + end + create_table "partner_account_statuses", force: :cascade do |t| t.string "name", null: false t.integer "order", null: false diff --git a/spec/factories/notification_phone_configurations.rb b/spec/factories/notification_phone_configurations.rb new file mode 100644 index 00000000000..45363c51ee5 --- /dev/null +++ b/spec/factories/notification_phone_configurations.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + Faker::Config.locale = :en + + factory :notification_phone_configuration do + phone { '+1 202-555-1212' } + in_person_enrollment { association :in_person_enrollment } + end +end diff --git a/spec/models/notification_phone_configuration_spec.rb b/spec/models/notification_phone_configuration_spec.rb new file mode 100644 index 00000000000..3ea42510d8a --- /dev/null +++ b/spec/models/notification_phone_configuration_spec.rb @@ -0,0 +1,62 @@ +require 'rails_helper' + +RSpec.describe NotificationPhoneConfiguration do + describe 'Associations' do + it { is_expected.to belong_to(:in_person_enrollment) } + it { is_expected.to validate_presence_of(:encrypted_phone) } + end + + let(:phone) { '+1 703 555 1212' } + + let(:notification_phone_configuration) { create(:notification_phone_configuration, phone: phone) } + + describe 'creation' do + it 'stores an encrypted form of the phone number' do + expect(notification_phone_configuration.encrypted_phone).to_not be_blank + end + end + + describe 'encrypted attributes' do + it 'decrypts phone' do + expect(notification_phone_configuration.phone).to eq phone + end + + context 'with unnormalized phone' do + let(:phone) { ' 555 555 5555 ' } + let(:normalized_phone) { '555 555 5555' } + + it 'normalizes phone' do + expect(notification_phone_configuration.phone).to eq normalized_phone + end + end + end + + describe '#masked_phone' do + let(:notification_phone_configuration) do + build(:notification_phone_configuration, phone: phone) + end + let(:phone) { '+1 703 555 1212' } + + subject(:masked_phone) { notification_phone_configuration.masked_phone } + + it 'masks the phone number, leaving the last 4 digits' do + expect(masked_phone).to eq('(***) ***-1212') + end + + context 'with a blank phone number' do + let(:phone) { ' ' } + + it 'is the empty string' do + expect(masked_phone).to eq('') + end + end + + context 'with an international number' do + let(:phone) { '+212 636-023853' } + + it 'keeps the groupings and leaves the last 4 digits' do + expect(masked_phone).to eq('****-**3853') + end + end + end +end diff --git a/spec/services/phone_formatter_spec.rb b/spec/services/phone_formatter_spec.rb index b9547138989..bfbb67ed78f 100644 --- a/spec/services/phone_formatter_spec.rb +++ b/spec/services/phone_formatter_spec.rb @@ -56,4 +56,24 @@ expect(formatted_phone).to be_nil end end + + describe '#mask' do + it 'masks all but the last four digits' do + phone = '+1 703 555 1212' + masked_phone = PhoneFormatter.mask(phone) + expect(masked_phone).to eq('(***) ***-1212') + end + + it 'masks all but the last four digits of formatted international numbers' do + phone = '+212 636-023853' + masked_phone = PhoneFormatter.mask(phone) + expect(masked_phone).to eq('****-**3853') + end + + it 'returns an empty string for a blank phone number' do + phone = ' ' + masked_phone = PhoneFormatter.mask(phone) + expect(masked_phone).to eq('') + end + end end