diff --git a/app/assets/stylesheets/components/_btn.scss b/app/assets/stylesheets/components/_btn.scss
index e84a3215c71..5d787d15582 100644
--- a/app/assets/stylesheets/components/_btn.scss
+++ b/app/assets/stylesheets/components/_btn.scss
@@ -32,6 +32,17 @@
}
}
+.btn-danger {
+ background-color: $red;
+ border: 1px solid $red;
+ border-radius: $border-radius-lg;
+ color: $white;
+
+ &:hover {
+ background-color: $red;
+ }
+}
+
.btn-wide {
box-sizing: border-box;
min-width: 220px;
diff --git a/app/controllers/concerns/two_factor_authenticatable.rb b/app/controllers/concerns/two_factor_authenticatable.rb
index 8c629e994aa..5cd5333f2e1 100644
--- a/app/controllers/concerns/two_factor_authenticatable.rb
+++ b/app/controllers/concerns/two_factor_authenticatable.rb
@@ -269,7 +269,7 @@ def decorated_user
def reenter_phone_number_path
locale = LinkLocaleResolver.locale
- if MfaContext.new(current_user).phone_configurations.any?
+ if MfaPolicy.new(current_user).two_factor_enabled?
manage_phone_path(locale: locale)
else
phone_setup_path(locale: locale)
@@ -277,7 +277,7 @@ def reenter_phone_number_path
end
def confirmation_for_phone_change?
- confirmation_context? && MfaContext.new(current_user).phone_configurations.any?
+ confirmation_context? && MfaPolicy.new(current_user).two_factor_enabled?
end
def presenter_for_two_factor_authentication_method
diff --git a/app/controllers/users/phones_controller.rb b/app/controllers/users/phones_controller.rb
index e0abe63c159..0d33f537179 100644
--- a/app/controllers/users/phones_controller.rb
+++ b/app/controllers/users/phones_controller.rb
@@ -20,6 +20,20 @@ def update
end
end
+ def delete
+ result = TwoFactorAuthentication::PhoneDeletionForm.new(
+ current_user, phone_configuration
+ ).submit
+ analytics.track_event(Analytics::PHONE_DELETION_REQUESTED, result.to_h)
+ if result.success?
+ flash[:success] = t('two_factor_authentication.phone.delete.success')
+ else
+ flash[:error] = t('two_factor_authentication.phone.delete.failure')
+ end
+
+ redirect_to account_url
+ end
+
private
# we only allow editing of the first configuration since we'll eventually be
diff --git a/app/controllers/users/reset_passwords_controller.rb b/app/controllers/users/reset_passwords_controller.rb
index 9ef11a5284f..6c44c98fead 100644
--- a/app/controllers/users/reset_passwords_controller.rb
+++ b/app/controllers/users/reset_passwords_controller.rb
@@ -1,4 +1,3 @@
-# rubocop:disable Metrics/ClassLength
module Users
class ResetPasswordsController < Devise::PasswordsController
include RecaptchaConcern
@@ -132,4 +131,3 @@ def assert_reset_token_passed
end
end
end
-# rubocop:enable Metrics/ClassLength
diff --git a/app/forms/two_factor_authentication/phone_deletion_form.rb b/app/forms/two_factor_authentication/phone_deletion_form.rb
new file mode 100644
index 00000000000..7c0d4a0c862
--- /dev/null
+++ b/app/forms/two_factor_authentication/phone_deletion_form.rb
@@ -0,0 +1,48 @@
+module TwoFactorAuthentication
+ class PhoneDeletionForm
+ include ActiveModel::Model
+
+ attr_reader :user, :configuration
+
+ validates :user, multiple_mfa_options: true
+ validates :configuration, allow_nil: true, owned_by_user: true
+
+ def initialize(user, configuration)
+ @user = user
+ @configuration = configuration
+ end
+
+ def submit
+ success = configuration.blank? || valid? && configuration_destroyed
+
+ FormResponse.new(success: success, errors: errors.messages, extra: extra_analytics_attributes)
+ end
+
+ private
+
+ def extra_analytics_attributes
+ {
+ configuration_present: configuration.present?,
+ configuration_id: configuration&.id,
+ configuration_owner: configuration&.user&.uuid,
+ mfa_method_counts: MfaContext.new(user.reload).enabled_two_factor_configuration_counts_hash,
+ }
+ end
+
+ def configuration_destroyed
+ if configuration.destroy != false
+ user.phone_configurations.reload
+ update_remember_device_revoked_at
+ true
+ else
+ errors.add(:configuration, :not_destroyed, message: 'cannot delete phone')
+ false
+ end
+ end
+
+ def update_remember_device_revoked_at
+ attributes = { remember_device_revoked_at: Time.zone.now }
+ UpdateUser.new(user: user, attributes: attributes).call
+ end
+ end
+end
diff --git a/app/models/auth_app_configuration.rb b/app/models/auth_app_configuration.rb
index 11a24ce424f..f95bd58d2d3 100644
--- a/app/models/auth_app_configuration.rb
+++ b/app/models/auth_app_configuration.rb
@@ -22,4 +22,8 @@ def selection_presenters
def friendly_name
:auth_app
end
+
+ def self.selection_presenters(set)
+ set.flat_map(&:selection_presenters)
+ end
end
diff --git a/app/models/personal_key_configuration.rb b/app/models/personal_key_configuration.rb
index b09571a3402..836eaf6f2c0 100644
--- a/app/models/personal_key_configuration.rb
+++ b/app/models/personal_key_configuration.rb
@@ -18,4 +18,8 @@ def selection_presenters
[]
end
end
+
+ def self.selection_presenters(set)
+ set.flat_map(&:selection_presenters)
+ end
end
diff --git a/app/models/phone_configuration.rb b/app/models/phone_configuration.rb
index 649617fc714..f448f2be588 100644
--- a/app/models/phone_configuration.rb
+++ b/app/models/phone_configuration.rb
@@ -24,4 +24,8 @@ def selection_presenters
def friendly_name
:phone
end
+
+ def self.selection_presenters(set)
+ set.flat_map(&:selection_presenters)
+ end
end
diff --git a/app/models/piv_cac_configuration.rb b/app/models/piv_cac_configuration.rb
index 065f9155390..f433ec0abd6 100644
--- a/app/models/piv_cac_configuration.rb
+++ b/app/models/piv_cac_configuration.rb
@@ -26,4 +26,8 @@ def selection_presenters
def friendly_name
:piv_cac
end
+
+ def self.selection_presenters(set)
+ set.flat_map(&:selection_presenters)
+ end
end
diff --git a/app/models/webauthn_configuration.rb b/app/models/webauthn_configuration.rb
index a066b5231c9..1d06ca25992 100644
--- a/app/models/webauthn_configuration.rb
+++ b/app/models/webauthn_configuration.rb
@@ -17,4 +17,12 @@ def selection_presenters
def friendly_name
:webauthn
end
+
+ def self.selection_presenters(set)
+ if set.any?
+ set.first.selection_presenters
+ else
+ []
+ end
+ end
end
diff --git a/app/presenters/two_factor_login_options_presenter.rb b/app/presenters/two_factor_login_options_presenter.rb
index 090d0e896c3..bf773e51baa 100644
--- a/app/presenters/two_factor_login_options_presenter.rb
+++ b/app/presenters/two_factor_login_options_presenter.rb
@@ -39,7 +39,7 @@ def options
# webauthn keys and phones. However, we only want to show one of each option
# during login, except for phones, where we want to allow the user to choose
# which MFA-enabled phone they want to use.
- all_sms_and_voice_options_plus_one_of_each_remaining_option(configurations)
+ configurations.group_by(&:class).flat_map { |klass, set| klass.selection_presenters(set) }
end
def should_display_account_reset_or_cancel_link?
@@ -53,27 +53,6 @@ def account_reset_or_cancel_link
private
- def all_sms_and_voice_options_plus_one_of_each_remaining_option(configurations)
- presenters = configurations.flat_map(&:selection_presenters)
- presenters_for_phone_options(presenters) + presenters_for_options_that_are_not_phone(presenters)
- end
-
- def presenters_for_options_that_are_not_phone(presenters)
- presenters.select { |presenter| !phone_presenter_class?(presenter.class) }.uniq(&:class)
- end
-
- def presenters_for_phone_options(presenters)
- presenters.select { |presenter| phone_presenter_class?(presenter.class) }
- end
-
- def phone_presenter_class?(class_name)
- phone_classes = [
- TwoFactorAuthentication::SmsSelectionPresenter,
- TwoFactorAuthentication::VoiceSelectionPresenter,
- ]
- phone_classes.include?(class_name)
- end
-
def account_reset_link
t('two_factor_authentication.account_reset.text_html',
link: @view.link_to(
diff --git a/app/services/analytics.rb b/app/services/analytics.rb
index 3dbb706b283..f6de31e616c 100644
--- a/app/services/analytics.rb
+++ b/app/services/analytics.rb
@@ -112,6 +112,7 @@ def browser
PASSWORD_RESET_VISIT = 'Password Reset: Email Form Visited'.freeze
PERSONAL_KEY_VIEWED = 'Personal Key Viewed'.freeze
PHONE_CHANGE_REQUESTED = 'Phone Number Change: requested'.freeze
+ PHONE_DELETION_REQUESTED = 'Phone Number Deletion: requested'.freeze
PROFILE_ENCRYPTION_INVALID = 'Profile Encryption: Invalid'.freeze
PROFILE_PERSONAL_KEY_CREATE = 'Profile: Created new personal key'.freeze
RATE_LIMIT_TRIGGERED = 'Rate Limit Triggered'.freeze
diff --git a/app/validators/multiple_mfa_options_validator.rb b/app/validators/multiple_mfa_options_validator.rb
new file mode 100644
index 00000000000..02db3cc02aa
--- /dev/null
+++ b/app/validators/multiple_mfa_options_validator.rb
@@ -0,0 +1,7 @@
+class MultipleMfaOptionsValidator < ActiveModel::EachValidator
+ # :reek:UtilityFunction
+ def validate_each(record, attribute, value)
+ return if MfaPolicy.new(value).multiple_factors_enabled?
+ record.errors.add attribute, 'must have multiple MFA configurations'
+ end
+end
diff --git a/app/validators/owned_by_user_validator.rb b/app/validators/owned_by_user_validator.rb
new file mode 100644
index 00000000000..918898ef27e
--- /dev/null
+++ b/app/validators/owned_by_user_validator.rb
@@ -0,0 +1,7 @@
+class OwnedByUserValidator < ActiveModel::EachValidator
+ # :reek:UtilityFunction
+ def validate_each(record, attribute, value)
+ return if value.user&.id == record.user&.id
+ record.errors.add attribute, 'must be owned by the user'
+ end
+end
diff --git a/app/view_models/account_show.rb b/app/view_models/account_show.rb
index 61ca568900a..1909a4c9a31 100644
--- a/app/view_models/account_show.rb
+++ b/app/view_models/account_show.rb
@@ -44,6 +44,10 @@ def edit_action_partial
'accounts/actions/edit_action_button'
end
+ def manage_action_partial
+ 'accounts/actions/manage_action_button'
+ end
+
def pii_partial
if decrypted_pii.present?
'accounts/pii'
diff --git a/app/views/accounts/_phone.html.slim b/app/views/accounts/_phone.html.slim
index 93b25b5435d..a159b29c771 100644
--- a/app/views/accounts/_phone.html.slim
+++ b/app/views/accounts/_phone.html.slim
@@ -11,6 +11,6 @@
.col.col-8.sm-6.truncate
= phone_configuration.phone
.col.col-4.sm-6.right-align
- = render @view_model.edit_action_partial,
+ = render @view_model.manage_action_partial,
path: manage_phone_url,
name: t('account.index.phone')
diff --git a/app/views/accounts/actions/_manage_action_button.html.slim b/app/views/accounts/actions/_manage_action_button.html.slim
new file mode 100644
index 00000000000..ec0a7dc8fd6
--- /dev/null
+++ b/app/views/accounts/actions/_manage_action_button.html.slim
@@ -0,0 +1,4 @@
+= link_to path do
+ span.hide
+ = name
+ = t('forms.buttons.manage')
diff --git a/app/views/users/phones/edit.html.slim b/app/views/users/phones/edit.html.slim
index e72bfa54792..58e1ca307b5 100644
--- a/app/views/users/phones/edit.html.slim
+++ b/app/views/users/phones/edit.html.slim
@@ -16,7 +16,13 @@ h1.h3.my0 = t('headings.edit_info.phone')
= f.input :phone, as: :tel, label: false, required: true,
input_html: { class: 'phone col-8 mb4' }
= render 'users/shared/otp_delivery_preference_selection'
- = f.button :submit, t('forms.buttons.submit.confirm_change')
+ = f.button :submit, t('forms.buttons.submit.confirm_change'), class: 'btn-wide'
+- if MfaPolicy.new(current_user).multiple_factors_enabled? && @user_phone_form.phone.present?
+ br
+ .sm-col-8.mb3
+ = button_to t('forms.phone.buttons.delete'), manage_phone_url, \
+ class: 'btn btn-danger btn-wide', \
+ method: :delete
= render 'shared/cancel', link: account_path
= stylesheet_link_tag 'intl-tel-number/intlTelInput'
diff --git a/app/views/users/shared/_otp_delivery_preference_selection.html.slim b/app/views/users/shared/_otp_delivery_preference_selection.html.slim
index 5f2da83e73e..e739e91ad1f 100644
--- a/app/views/users/shared/_otp_delivery_preference_selection.html.slim
+++ b/app/views/users/shared/_otp_delivery_preference_selection.html.slim
@@ -15,5 +15,3 @@
class: :otp_delivery_preference_voice
span.indicator
= t('two_factor_authentication.otp_delivery_preference.voice')
- p.mb0.mt1
- = link_to t('links.two_factor_authentication.app_option'), authenticator_setup_path
diff --git a/config/locales/forms/en.yml b/config/locales/forms/en.yml
index b31adb5660d..8a7411bed5a 100644
--- a/config/locales/forms/en.yml
+++ b/config/locales/forms/en.yml
@@ -10,6 +10,7 @@ en:
disable: Disable
edit: Edit
enable: Enable
+ manage: Manage
resend_confirmation: Resend confirmation instructions
send_security_code: Send code
submit:
@@ -34,6 +35,9 @@ en:
instructions: Please confirm you have a copy of your personal key by entering
it below.
title: Enter your personal key
+ phone:
+ buttons:
+ delete: Remove Phone
piv_cac_mfa:
submit: Present PIV/CAC card
piv_cac_setup:
diff --git a/config/locales/forms/es.yml b/config/locales/forms/es.yml
index 6e4b8d5054e..cca50b57715 100644
--- a/config/locales/forms/es.yml
+++ b/config/locales/forms/es.yml
@@ -10,6 +10,7 @@ es:
disable: Suspender
edit: Editar
enable: Permitir
+ manage: Administrar
resend_confirmation: Reenviar instrucciones de confirmación
send_security_code: Enviar código
submit:
@@ -34,6 +35,9 @@ es:
instructions: Confirme que tiene una copia de su clave personal ingresándola
a continuación.
title: Ingrese su clave personal
+ phone:
+ buttons:
+ delete: Eliminar el teléfono
piv_cac_mfa:
submit: Presentar tarjeta PIV/CAC
piv_cac_setup:
diff --git a/config/locales/forms/fr.yml b/config/locales/forms/fr.yml
index 7430578d567..faca7d38bb8 100644
--- a/config/locales/forms/fr.yml
+++ b/config/locales/forms/fr.yml
@@ -10,6 +10,7 @@ fr:
disable: Désactiver
edit: Modifier
enable: Activer
+ manage: Administrer
resend_confirmation: Envoyer les instructions de confirmation de nouveau
send_security_code: Envoyer le code
submit:
@@ -34,6 +35,9 @@ fr:
instructions: Veuillez confirmer que vous avez une copie de votre clé personnelle
en l'entrant ci-dessous.
title: Entrez votre clé personnelle
+ phone:
+ buttons:
+ delete: Supprimer le numéro de teléfono
piv_cac_mfa:
submit: Veuillez présenter une carte PIV/CAC
piv_cac_setup:
diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml
index a68a940c652..bb563307eaa 100644
--- a/config/locales/headings/en.yml
+++ b/config/locales/headings/en.yml
@@ -24,7 +24,7 @@ en:
edit_info:
email: Change your email
password: Change your password
- phone: Enter your new phone number
+ phone: Manage your phone number
lock_failure: Here's what you can do
passwords:
change: Change your password
diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml
index 2b6b8bce1bf..58bfd4defc4 100644
--- a/config/locales/headings/es.yml
+++ b/config/locales/headings/es.yml
@@ -24,7 +24,7 @@ es:
edit_info:
email: Cambie su email
password: Cambie su contraseña
- phone: Ingrese su nuevo número de teléfono
+ phone: Administrar su número de teléfono
lock_failure: Esto es lo que puedes hacer
passwords:
change: Cambie su contraseña
diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml
index e7243aa0721..9377c7c3812 100644
--- a/config/locales/headings/fr.yml
+++ b/config/locales/headings/fr.yml
@@ -24,7 +24,7 @@ fr:
edit_info:
email: Changez votre courriel
password: Changez votre mot de passe
- phone: Entrez votre nouveau numéro de téléphone
+ phone: Administrer votre numéro de téléphone
lock_failure: Voici ce que vous pouvez faire
passwords:
change: Changez votre mot de passe
diff --git a/config/locales/links/en.yml b/config/locales/links/en.yml
index 49f65c605bb..d1eccaa4b41 100644
--- a/config/locales/links/en.yml
+++ b/config/locales/links/en.yml
@@ -24,7 +24,6 @@ en:
sign_in: Sign in
sign_out: Sign out
two_factor_authentication:
- app_option: Use an authentication application instead.
get_another_code: Get another code
what_is_totp: What is an authentication app?
what_is_webauthn: What is a hardware security key?
diff --git a/config/locales/links/es.yml b/config/locales/links/es.yml
index 70297bd60dc..f881af0cebb 100644
--- a/config/locales/links/es.yml
+++ b/config/locales/links/es.yml
@@ -24,7 +24,6 @@ es:
sign_in: Iniciar sesión
sign_out: Cerrar sesión
two_factor_authentication:
- app_option: Use una aplicación de autenticación en su lugar.
get_another_code: Obtener otro código
what_is_totp: "¿Qué es una app de autenticación?"
what_is_webauthn: "¿Qué es una clave de seguridad de hardware?"
diff --git a/config/locales/links/fr.yml b/config/locales/links/fr.yml
index 769acfe5e98..0687dc486eb 100644
--- a/config/locales/links/fr.yml
+++ b/config/locales/links/fr.yml
@@ -24,7 +24,6 @@ fr:
sign_in: Connexion
sign_out: Déconnexion
two_factor_authentication:
- app_option: Utilisez une application d'authentification à la place.
get_another_code: Obtenir un autre code
what_is_totp: Qu'est-ce qu'une application d'authentification?
what_is_webauthn: Qu'est-ce qu'une clé de sécurité physique?
diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml
index f6863e09b93..4bbb6917392 100644
--- a/config/locales/two_factor_authentication/en.yml
+++ b/config/locales/two_factor_authentication/en.yml
@@ -63,6 +63,10 @@ en:
personal_key_header_text: Enter your personal key
personal_key_prompt: You can use this personal key once. After you enter it, you'll
be provided a new key.
+ phone:
+ delete:
+ success: Your phone has been removed.
+ failure: Unable to remove your phone.
phone_fallback:
question: Don't have access to your phone right now?
phone_sms_info_html: We'll text a security code each time you sign in.
diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml
index 8c8963965de..b8577ae719c 100644
--- a/config/locales/two_factor_authentication/es.yml
+++ b/config/locales/two_factor_authentication/es.yml
@@ -66,6 +66,10 @@ es:
personal_key_header_text: Ingrese su clave personal
personal_key_prompt: Puede usar esta clave personal una vez. Después de ingresarlo,
se le dará una nueva clave.
+ phone:
+ delete:
+ success: Su teléfono ha sido eliminado.
+ failure: No se puede eliminar el teléfono.
phone_fallback:
question: "¿No tiene acceso a su teléfono ahora mismo?"
phone_sms_info_html: Le enviaremos un mensaje de texto con un código de seguridad
diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml
index f1de001a6f9..0bac68a2235 100644
--- a/config/locales/two_factor_authentication/fr.yml
+++ b/config/locales/two_factor_authentication/fr.yml
@@ -65,6 +65,10 @@ fr:
personal_key_header_text: Entrez votre clé personnelle
personal_key_prompt: Vous pouvez utiliser cette clé personnelle une fois seulement.
Une fois que vous l'entrez, vous recevrez une nouvelle clé.
+ phone:
+ delete:
+ success: Votre numéro de téléphone a été supprimé.
+ failure: Impossible de supprimer votre numéro de téléphone.
phone_fallback:
question: Vous n'avez pas accès à votre téléphone maintenant?
phone_sms_info_html: Nous vous enverrons un code de sécurité chaque fois
diff --git a/config/routes.rb b/config/routes.rb
index 3cb3c5deebf..7157e22e204 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -137,6 +137,7 @@
patch '/manage/password' => 'users/passwords#update'
get '/manage/phone' => 'users/phones#edit'
match '/manage/phone' => 'users/phones#update', via: %i[patch put]
+ delete '/manage/phone' => 'users/phones#delete'
get '/manage/personal_key' => 'users/personal_keys#show', as: :manage_personal_key
post '/account/personal_key' => 'users/personal_keys#create', as: :create_new_personal_key
post '/manage/personal_key' => 'users/personal_keys#update'
diff --git a/spec/controllers/users/phones_controller_spec.rb b/spec/controllers/users/phones_controller_spec.rb
index a10ee8b3fb2..4f2cbb5b602 100644
--- a/spec/controllers/users/phones_controller_spec.rb
+++ b/spec/controllers/users/phones_controller_spec.rb
@@ -116,4 +116,104 @@
end
end
end
+
+ describe '#delete' do
+ before(:each) do
+ stub_analytics
+ allow(@analytics).to receive(:track_event)
+ end
+
+ context 'user has no phone' do
+ let(:user) { create(:user) }
+
+ let(:extra_analytics) do
+ { configuration_id: nil,
+ configuration_owner: nil,
+ configuration_present: false,
+ errors: {},
+ mfa_method_counts: {},
+ success: true }
+ end
+
+ it 'redirects without an error' do
+ stub_sign_in(user)
+
+ extra = extra_analytics
+
+ delete :delete
+
+ expect(@analytics).to have_received(:track_event).
+ with(Analytics::PHONE_DELETION_REQUESTED, extra)
+ expect(response).to redirect_to(account_url)
+ end
+ end
+
+ context 'user has only a phone' do
+ let(:user) { create(:user, :signed_up) }
+
+ let(:extra_analytics) do
+ { configuration_id: user.phone_configurations.first.id,
+ configuration_owner: user.uuid,
+ configuration_present: true,
+ errors: { user: ['must have multiple MFA configurations'] },
+ mfa_method_counts: { phone: 1 },
+ success: false }
+ end
+
+ it 'redirects without an error' do
+ stub_sign_in(user)
+
+ extra = extra_analytics
+
+ delete :delete
+
+ expect(@analytics).to have_received(:track_event).
+ with(Analytics::PHONE_DELETION_REQUESTED, extra)
+ expect(response).to redirect_to(account_url)
+ end
+
+ it 'leaves the phone' do
+ stub_sign_in(user)
+
+ delete :delete
+
+ user.phone_configurations.reload
+ expect(user.phone_configurations.count).to eq 1
+ end
+ end
+
+ context 'user has more than one mfa option' do
+ let(:user) { create(:user, :signed_up, :with_piv_or_cac) }
+
+ let(:extra_analytics) do
+ { configuration_id: user.phone_configurations.first.id,
+ configuration_owner: user.uuid,
+ configuration_present: true,
+ errors: {},
+ mfa_method_counts: { piv_cac: 1 },
+ success: true }
+ end
+
+ it 'redirects without an error' do
+ stub_sign_in(user)
+
+ extra = extra_analytics
+
+ delete :delete
+
+ expect(@analytics).to have_received(:track_event).
+ with(Analytics::PHONE_DELETION_REQUESTED, extra)
+ expect(response).to redirect_to(account_url)
+ end
+
+ it 'removes the phone' do
+ stub_sign_in(user)
+
+ delete :delete
+
+ user.phone_configurations.reload
+ expect(user.phone_configurations).to be_empty
+ end
+ end
+ end
end
diff --git a/spec/features/users/user_edit_spec.rb b/spec/features/users/user_edit_spec.rb
index f2a783447d1..c9f9de2d51c 100644
--- a/spec/features/users/user_edit_spec.rb
+++ b/spec/features/users/user_edit_spec.rb
@@ -67,6 +67,32 @@
end
end
+ context 'deleting 2FA phone number' do
+ before do
+ sign_in_and_2fa_user(user)
+ visit manage_phone_path
+ end
+
+ scenario 'delete not an option if no other mfa configured' do
+ expect(MfaPolicy.new(user).multiple_factors_enabled?).to eq false
+
+ expect(page).to_not have_button(t('forms.buttons.delete'))
+ end
+
+ context 'with multiple mfa configured' do
+ let(:user) { create(:user, :signed_up, :with_piv_or_cac) }
+
+ scenario 'delete is an option that works' do
+ expect(MfaPolicy.new(user).multiple_factors_enabled?).to eq true
+
+ expect(page).to have_button(t('forms.phone.buttons.delete'))
+ click_button t('forms.phone.buttons.delete')
+ expect(page).to have_current_path(account_path)
+ expect(MfaPolicy.new(user.reload).multiple_factors_enabled?).to eq false
+ end
+ end
+ end
+
context "user A accesses create password page with user B's email change token" do
it "redirects to user A's account page", email: true do
sign_in_and_2fa_user(user)
diff --git a/spec/forms/two_factor_authentication/phone_deletion_form_spec.rb b/spec/forms/two_factor_authentication/phone_deletion_form_spec.rb
new file mode 100644
index 00000000000..98a37cc1ac5
--- /dev/null
+++ b/spec/forms/two_factor_authentication/phone_deletion_form_spec.rb
@@ -0,0 +1,101 @@
+require 'rails_helper'
+
+describe TwoFactorAuthentication::PhoneDeletionForm do
+ let(:form) { described_class.new(user, configuration) }
+
+ describe '#submit' do
+ let!(:result) { form.submit }
+ let(:configuration) { user.phone_configurations.first }
+
+ context 'with only a single mfa method' do
+ let(:user) { create(:user, :signed_up) }
+
+ it 'returns failure' do
+ expect(result.success?).to eq false
+ end
+
+ it 'returns analytics' do
+ expect(result.extra).to eq(
+ configuration_present: true,
+ configuration_id: configuration.id,
+ configuration_owner: user.uuid,
+ mfa_method_counts: { phone: 1 }
+ )
+ end
+
+ it 'leaves the phone alone' do
+ expect(user.phone_configurations.reload).to_not be_empty
+ end
+ end
+
+ context 'with multiple mfa methods available' do
+ let(:user) { create(:user, :signed_up, :with_piv_or_cac) }
+
+ it 'returns success' do
+ expect(result.success?).to eq true
+ end
+
+ it 'updates the user remember device revokation timestamp' do
+ expect(user.reload.remember_device_revoked_at.to_i).to be_within(1).of(Time.zone.now.to_i)
+ end
+
+ it 'returns analytics' do
+ expect(result.extra).to eq(
+ configuration_present: true,
+ configuration_id: configuration.id,
+ configuration_owner: user.uuid,
+ mfa_method_counts: { piv_cac: 1 }
+ )
+ end
+
+ it 'removes the phone' do
+ expect(user.phone_configurations.reload).to be_empty
+ end
+ end
+
+ context 'with no phone configuration' do
+ let(:user) { create(:user, :signed_up, :with_piv_or_cac) }
+ let(:configuration) { nil }
+
+ it 'returns success' do
+ expect(result.success?).to eq true
+ end
+
+ it 'returns analytics' do
+ expect(result.extra).to eq(
+ configuration_present: false,
+ configuration_id: nil,
+ configuration_owner: nil,
+ mfa_method_counts: { phone: 1, piv_cac: 1 }
+ )
+ end
+
+ it 'leaves the phone alone' do
+ expect(user.phone_configurations.reload).to_not be_empty
+ end
+ end
+
+ context 'with a phone of a different user' do
+ let(:user) { create(:user, :signed_up, :with_piv_or_cac) }
+ let(:configuration) { other_user.phone_configurations.first }
+ let(:other_user) { create(:user, :signed_up) }
+
+ it 'returns failure' do
+ expect(result.success?).to eq false
+ end
+
+ it 'returns analytics' do
+ expect(result.extra).to eq(
+ configuration_present: true,
+ configuration_id: configuration.id,
+ configuration_owner: other_user.uuid,
+ mfa_method_counts: { phone: 1, piv_cac: 1 }
+ )
+ end
+
+ it 'leaves the phone alone' do
+ expect(other_user.phone_configurations.reload).to_not be_empty
+ end
+ end
+ end
+end
diff --git a/spec/models/webauthn_configuration_spec.rb b/spec/models/webauthn_configuration_spec.rb
index 76f3f39411b..e00b34a1331 100644
--- a/spec/models/webauthn_configuration_spec.rb
+++ b/spec/models/webauthn_configuration_spec.rb
@@ -21,6 +21,12 @@
end
end
+ describe 'class#selection_presenters' do
+ it 'returns an empty array for an empty set' do
+ expect(described_class.selection_presenters([])).to eq []
+ end
+ end
+
describe '#mfa_enabled?' do
let(:mfa_enabled) { subject.mfa_enabled? }
context 'when webauthn enabled' do