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