diff --git a/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb b/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb new file mode 100644 index 00000000000..f108c15b62a --- /dev/null +++ b/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb @@ -0,0 +1,56 @@ +module Api + module Internal + module TwoFactorAuthentication + class AuthAppController < ApplicationController + include CsrfTokenConcern + include ReauthenticationRequiredConcern + + before_action :render_unauthorized, unless: :recently_authenticated_2fa? + + after_action :add_csrf_token_header_to_response + + respond_to :json + + def update + result = ::TwoFactorAuthentication::AuthAppUpdateForm.new( + user: current_user, + configuration_id: params[:id], + ).submit(name: params[:name]) + + analytics.auth_app_update_name_submitted(**result.to_h) + + if result.success? + render json: { success: true } + else + render json: { success: false, error: result.first_error_message }, status: :bad_request + end + end + + def destroy + result = ::TwoFactorAuthentication::AuthAppDeleteForm.new( + user: current_user, + configuration_id: params[:id], + ).submit + + analytics.auth_app_delete_submitted(**result.to_h) + + if result.success? + create_user_event(:authenticator_disabled) + revoke_remember_device(current_user) + event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) + PushNotification::HttpPush.deliver(event) + render json: { success: true } + else + render json: { success: false, error: result.first_error_message }, status: :bad_request + end + end + + private + + def render_unauthorized + render json: { error: 'Unauthorized' }, status: :unauthorized + end + end + end + end +end diff --git a/app/controllers/users/auth_app_controller.rb b/app/controllers/users/auth_app_controller.rb new file mode 100644 index 00000000000..2082362dc04 --- /dev/null +++ b/app/controllers/users/auth_app_controller.rb @@ -0,0 +1,65 @@ +module Users + class AuthAppController < ApplicationController + include ReauthenticationRequiredConcern + + before_action :confirm_two_factor_authenticated + before_action :confirm_recently_authenticated_2fa + before_action :set_form + before_action :validate_configuration_exists + + def edit; end + + def update + result = form.submit(name: params.dig(:form, :name)) + + analytics.auth_app_update_name_submitted(**result.to_h) + + if result.success? + flash[:success] = t('two_factor_authentication.auth_app.renamed') + redirect_to account_path + else + flash.now[:error] = result.first_error_message + render :edit + end + end + + def destroy + result = form.submit + + analytics.auth_app_delete_submitted(**result.to_h) + + if result.success? + flash[:success] = t('two_factor_authentication.auth_app.deleted') + create_user_event(:authenticator_disabled) + revoke_remember_device(current_user) + event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) + PushNotification::HttpPush.deliver(event) + redirect_to account_path + else + flash[:error] = result.first_error_message + redirect_to edit_auth_app_path(id: params[:id]) + end + end + + private + + def form + @form ||= form_class.new(user: current_user, configuration_id: params[:id]) + end + + alias_method :set_form, :form + + def form_class + case action_name + when 'edit', 'update' + TwoFactorAuthentication::AuthAppUpdateForm + when 'destroy' + TwoFactorAuthentication::AuthAppDeleteForm + end + end + + def validate_configuration_exists + render_not_found if form.configuration.blank? + end + end +end diff --git a/app/forms/two_factor_authentication/auth_app_delete_form.rb b/app/forms/two_factor_authentication/auth_app_delete_form.rb new file mode 100644 index 00000000000..6a49f4513e4 --- /dev/null +++ b/app/forms/two_factor_authentication/auth_app_delete_form.rb @@ -0,0 +1,57 @@ +module TwoFactorAuthentication + class AuthAppDeleteForm + include ActiveModel::Model + include ActionView::Helpers::TranslationHelper + + attr_reader :user, :configuration_id + + validate :validate_configuration_exists + validate :validate_has_multiple_mfa + + def initialize(user:, configuration_id:) + @user = user + @configuration_id = configuration_id + end + + def submit + success = valid? + + configuration.destroy if success + + FormResponse.new( + success:, + errors:, + extra: extra_analytics_attributes, + serialize_error_details_only: true, + ) + end + + def configuration + @configuration ||= user.auth_app_configurations.find_by(id: configuration_id) + end + + private + + def validate_configuration_exists + return if configuration.present? + errors.add( + :configuration_id, + :configuration_not_found, + message: t('errors.manage_authenticator.internal_error'), + ) + end + + def validate_has_multiple_mfa + return if !configuration || MfaPolicy.new(user).multiple_factors_enabled? + errors.add( + :configuration_id, + :only_method, + message: t('errors.manage_authenticator.remove_only_method_error'), + ) + end + + def extra_analytics_attributes + { configuration_id: } + end + end +end diff --git a/app/forms/two_factor_authentication/auth_app_update_form.rb b/app/forms/two_factor_authentication/auth_app_update_form.rb new file mode 100644 index 00000000000..cd6bb2dbcbb --- /dev/null +++ b/app/forms/two_factor_authentication/auth_app_update_form.rb @@ -0,0 +1,68 @@ +module TwoFactorAuthentication + class AuthAppUpdateForm + include ActiveModel::Model + include ActionView::Helpers::TranslationHelper + + attr_reader :user, :configuration_id + + validate :validate_configuration_exists + validate :validate_unique_name + + def initialize(user:, configuration_id:) + @user = user + @configuration_id = configuration_id + end + + def submit(name:) + @name = name + + success = valid? + if valid? + configuration.name = name + success = configuration.valid? + errors.merge!(configuration.errors) + configuration.save if success + end + + FormResponse.new( + success:, + errors:, + extra: extra_analytics_attributes, + serialize_error_details_only: true, + ) + end + + def name + return @name if defined?(@name) + @name = configuration&.name + end + + def configuration + @configuration ||= user.auth_app_configurations.find_by(id: configuration_id) + end + + private + + def validate_configuration_exists + return if configuration.present? + errors.add( + :configuration_id, + :configuration_not_found, + message: t('errors.manage_authenticator.internal_error'), + ) + end + + def validate_unique_name + return unless user.auth_app_configurations.where.not(id: configuration_id).find_by(name:) + errors.add( + :name, + :duplicate, + message: t('errors.manage_authenticator.unique_name_error'), + ) + end + + def extra_analytics_attributes + { configuration_id: } + end + end +end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index f1956723085..0fbe31c784b 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -188,6 +188,45 @@ def add_phone_setup_visit ) end + # Tracks when a user deletes their auth app from account + # @param [Boolean] success + # @param [Hash] error_details + # @param [Integer] configuration_id + def auth_app_delete_submitted( + success:, + configuration_id:, + error_details: nil, + **extra + ) + track_event( + :auth_app_delete_submitted, + success:, + error_details:, + configuration_id:, + **extra, + ) + end + + # When a user updates name for auth app + # @param [Boolean] success + # @param [Hash] error_details + # @param [Integer] configuration_id + # Tracks when user submits a name change for an Auth App configuration + def auth_app_update_name_submitted( + success:, + configuration_id:, + error_details: nil, + **extra + ) + track_event( + :auth_app_update_name_submitted, + success:, + error_details:, + configuration_id:, + **extra, + ) + end + # When a user views the "you are already signed in with the following email" screen def authentication_confirmation track_event('Authentication Confirmation') diff --git a/app/views/accounts/_auth_apps.html.erb b/app/views/accounts/_auth_apps.html.erb index 64a39eaf59f..e13de333a53 100644 --- a/app/views/accounts/_auth_apps.html.erb +++ b/app/views/accounts/_auth_apps.html.erb @@ -1,22 +1,24 @@

<%= t('headings.account.authentication_apps') %>

-
- <% MfaContext.new(current_user).auth_app_configurations.each do |auth_app_configuration| %> -
-
-
- <%= auth_app_configuration.name %> -
-
- <% if MfaPolicy.new(current_user).multiple_factors_enabled? %> -
- <%= render 'accounts/actions/disable_totp', id: auth_app_configuration.id %> -
- <% end %> -
+ +
+ <% MfaContext.new(current_user).auth_app_configurations.each do |configuration| %> + <%= render ManageableAuthenticatorComponent.new( + configuration:, + user_session:, + manage_url: edit_auth_app_path(id: configuration.id), + manage_api_url: api_internal_two_factor_authentication_auth_app_path(id: configuration.id), + custom_strings: { + deleted: t('two_factor_authentication.auth_app.deleted'), + renamed: t('two_factor_authentication.auth_app.renamed'), + manage_accessible_label: t('two_factor_authentication.auth_app.manage_accessible_label'), + }, + role: 'list-item', + ) %> <% end %>
+ <% if current_user.auth_app_configurations.count < IdentityConfig.store.max_auth_apps_per_account %> <%= render ButtonComponent.new( action: ->(**tag_options, &block) do diff --git a/app/views/users/auth_app/edit.html.erb b/app/views/users/auth_app/edit.html.erb new file mode 100644 index 00000000000..55bbe2409a5 --- /dev/null +++ b/app/views/users/auth_app/edit.html.erb @@ -0,0 +1,40 @@ +<% self.title = t('two_factor_authentication.auth_app.edit_heading') %> + +<%= render PageHeadingComponent.new.with_content(t('two_factor_authentication.auth_app.edit_heading')) %> + +<%= simple_form_for( + @form, + as: :form, + method: :put, + html: { autocomplete: 'off' }, + url: auth_app_path(id: @form.configuration.id), + ) do |f| %> + <%= render ValidatedFieldComponent.new( + form: f, + name: :name, + label: t('two_factor_authentication.auth_app.nickname'), + ) %> + + <%= f.submit( + t('two_factor_authentication.auth_app.change_nickname'), + class: 'display-block margin-top-5', + ) %> +<% end %> + +<%= render ButtonComponent.new( + action: ->(**tag_options, &block) do + button_to( + auth_app_path(id: @form.configuration.id), + form: { aria: { label: t('two_factor_authentication.auth_app.delete') } }, + **tag_options, + &block + ) + end, + method: :delete, + big: true, + wide: true, + danger: true, + class: 'display-block margin-top-2', + ).with_content(t('two_factor_authentication.auth_app.delete')) %> + +<%= render 'shared/cancel', link: account_path %> diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index 0dd6bd01859..e55ae0021a5 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -20,6 +20,14 @@ en: attempt_remaining_warning_html: one: You have %{count} attempt remaining. other: You have %{count} attempts remaining. + auth_app: + change_nickname: Change nickname + delete: Delete this device + deleted: Successfully deleted an authentication app method + edit_heading: Manage your authentication app settings + manage_accessible_label: Manage authentication app + nickname: Nickname + renamed: Successfully renamed your authentication app method backup_code_header_text: Enter your backup code backup_code_prompt: You can use this backup code once. After you submit it, you’ll need to use a new backup code next time. diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index 01ef2bc2814..5f094a6efae 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -20,6 +20,15 @@ es: attempt_remaining_warning_html: one: Le quedan %{count} intento. other: Le quedan %{count} intentos. + auth_app: + change_nickname: Cambiar apodo + delete: Eliminar este dispositivo + deleted: Se ha eliminado correctamente un método de aplicación de autenticación. + edit_heading: Gestionar la configuración de su aplicación de autenticación + manage_accessible_label: Gestionar la aplicación de autenticación + nickname: Apodo + renamed: Se ha cambiado correctamente el nombre de su método de aplicación de + autenticación. backup_code_header_text: Ingrese su código de respaldo backup_code_prompt: Puede utilizar este código de respaldo una vez. Tendrá que usar un nuevo código de respaldo la próxima vez después de que lo envíe. diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index bca8d4bb545..01465df3f19 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -22,6 +22,14 @@ fr: attempt_remaining_warning_html: one: Il vous reste %{count} tentative. other: Il vous reste %{count} tentatives. + auth_app: + change_nickname: Changer de pseudo + delete: Supprimer cet appareil + deleted: Suppression réussie d’une méthode d’application d’authentification + edit_heading: Gérer les paramètres de votre application d’authentification + manage_accessible_label: Gérer l’application d’authentification + nickname: Pseudo + renamed: Votre méthode d’application d’authentification a été renommée avec succès backup_code_header_text: Entrez votre code de sauvegarde backup_code_prompt: Vous pouvez utiliser ce code de sauvegarde une seule fois. Après l’avoir envoyé, vous devrez utiliser un nouveau code de sauvegarde diff --git a/config/routes.rb b/config/routes.rb index 78ab0a170d6..1968d04dc18 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,6 +23,8 @@ namespace :two_factor_authentication do put '/webauthn/:id' => 'webauthn#update', as: :webauthn delete '/webauthn/:id' => 'webauthn#destroy', as: nil + put '/auth_app/:id' => 'auth_app#update', as: :auth_app + delete '/auth_app/:id' => 'auth_app#destroy', as: nil end end end @@ -255,7 +257,9 @@ get '/manage/webauthn/:id' => 'users/webauthn#edit', as: :edit_webauthn put '/manage/webauthn/:id' => 'users/webauthn#update', as: :webauthn delete '/manage/webauthn/:id' => 'users/webauthn#destroy', as: nil - + get '/manage/auth_app/:id' => 'users/auth_app#edit', as: :edit_auth_app + put '/manage/auth_app/:id' => 'users/auth_app#update', as: :auth_app + delete '/manage/auth_app/:id' => 'users/auth_app#destroy', as: nil get '/account/personal_key' => 'accounts/personal_keys#new', as: :create_new_personal_key post '/account/personal_key' => 'accounts/personal_keys#create' diff --git a/spec/controllers/api/internal/two_factor_authentication/auth_app_controller_spec.rb b/spec/controllers/api/internal/two_factor_authentication/auth_app_controller_spec.rb new file mode 100644 index 00000000000..199a0e82a53 --- /dev/null +++ b/spec/controllers/api/internal/two_factor_authentication/auth_app_controller_spec.rb @@ -0,0 +1,217 @@ +require 'rails_helper' + +RSpec.describe Api::Internal::TwoFactorAuthentication::AuthAppController do + let(:user) { create(:user, :with_phone) } + let(:configuration) { create(:auth_app_configuration, user:) } + + before do + stub_analytics + stub_sign_in(user) if user + end + + describe '#update' do + let(:name) { 'example' } + let(:params) { { id: configuration.id, name: } } + let(:response) { put :update, params: params } + subject(:response_body) { JSON.parse(response.body, symbolize_names: true) } + + it 'responds with successful result' do + expect(response_body).to eq(success: true) + expect(response.status).to eq(200) + end + + it 'logs the submission attempt' do + response + + expect(@analytics).to have_logged_event( + :auth_app_update_name_submitted, + success: true, + error_details: nil, + configuration_id: configuration.id.to_s, + ) + end + + it 'includes csrf token in the response headers' do + expect(response.headers['X-CSRF-Token']).to be_kind_of(String) + end + + context 'signed out' do + let(:user) { nil } + let(:configuration) { create(:auth_app_configuration) } + + it 'responds with unauthorized response' do + expect(response_body).to eq(error: 'Unauthorized') + expect(response.status).to eq(401) + end + end + + context 'with invalid submission' do + let(:name) { '' } + + it 'responds with unsuccessful result' do + expect(response_body).to eq(success: false, error: t('errors.messages.blank')) + expect(response.status).to eq(400) + end + + it 'logs the submission attempt' do + response + + expect(@analytics).to have_logged_event( + :auth_app_update_name_submitted, + success: false, + configuration_id: configuration.id.to_s, + error_details: { name: { blank: true } }, + ) + end + end + + context 'not recently authenticated' do + before do + allow(controller).to receive(:recently_authenticated_2fa?).and_return(false) + end + + it 'responds with unauthorized response' do + expect(response_body).to eq(error: 'Unauthorized') + expect(response.status).to eq(401) + end + end + + context 'with a configuration that does not exist' do + let(:params) { { id: 0 } } + + it 'responds with unsuccessful result' do + expect(response_body).to eq( + success: false, + error: t('errors.manage_authenticator.internal_error'), + ) + expect(response.status).to eq(400) + end + end + + context 'with a configuration that does not belong to the user' do + let(:configuration) { create(:auth_app_configuration) } + + it 'responds with unsuccessful result' do + expect(response_body).to eq( + success: false, + error: t('errors.manage_authenticator.internal_error'), + ) + expect(response.status).to eq(400) + end + end + end + + describe '#destroy' do + let(:params) { { id: configuration.id } } + let(:response) { delete :destroy, params: params } + subject(:response_body) { JSON.parse(response.body, symbolize_names: true) } + + it 'responds with successful result' do + expect(response_body).to eq(success: true) + expect(response.status).to eq(200) + end + + it 'logs the submission attempt' do + response + + expect(@analytics).to have_logged_event( + :auth_app_delete_submitted, + success: true, + configuration_id: configuration.id.to_s, + error_details: nil, + ) + end + + it 'includes csrf token in the response headers' do + expect(response.headers['X-CSRF-Token']).to be_kind_of(String) + end + + it 'sends a recovery information changed event' do + expect(PushNotification::HttpPush).to receive(:deliver). + with(PushNotification::RecoveryInformationChangedEvent.new(user: user)) + + response + end + + it 'revokes remembered device' do + expect(user.remember_device_revoked_at).to eq nil + + freeze_time do + response + expect(user.reload.remember_device_revoked_at).to eq Time.zone.now + end + end + + it 'logs a user event for the removed credential' do + expect { response }.to change { user.events.authenticator_disabled.size }.by 1 + end + + context 'signed out' do + let(:user) { nil } + let(:configuration) { create(:auth_app_configuration) } + + it 'responds with unauthorized response' do + expect(response_body).to eq(error: 'Unauthorized') + expect(response.status).to eq(401) + end + end + + context 'with invalid submission' do + let(:user) { create(:user) } + + it 'responds with unsuccessful result' do + expect(response_body).to eq( + success: false, + error: t('errors.manage_authenticator.remove_only_method_error'), + ) + expect(response.status).to eq(400) + end + + it 'logs the submission attempt' do + response + + expect(@analytics).to have_logged_event( + :auth_app_delete_submitted, + success: false, + configuration_id: configuration.id.to_s, + error_details: { configuration_id: { only_method: true } }, + ) + end + end + + context 'not recently authenticated' do + before do + allow(controller).to receive(:recently_authenticated_2fa?).and_return(false) + end + + it 'responds with unauthorized response' do + expect(response_body).to eq(error: 'Unauthorized') + expect(response.status).to eq(401) + end + end + + context 'with a configuration that does not exist' do + let(:params) { { id: 0 } } + + it 'responds with unsuccessful result' do + expect(response_body).to eq( + success: false, + error: t('errors.manage_authenticator.internal_error'), + ) + expect(response.status).to eq(400) + end + end + + context 'with a configuration that does not belong to the user' do + let(:configuration) { create(:auth_app_configuration) } + + it 'responds with unsuccessful result' do + expect(response_body).to eq( + success: false, + error: t('errors.manage_authenticator.internal_error'), + ) + expect(response.status).to eq(400) + end + end + end +end diff --git a/spec/features/remember_device/totp_spec.rb b/spec/features/remember_device/totp_spec.rb index 207b6fd6d62..f3de6d554bc 100644 --- a/spec/features/remember_device/totp_spec.rb +++ b/spec/features/remember_device/totp_spec.rb @@ -41,17 +41,29 @@ def remember_device_and_sign_out_user context 'update totp' do def remember_device_and_sign_out_user + auth_app_config = create(:auth_app_configuration, user:) + name = auth_app_config.name + sign_in_and_2fa_user(user) visit account_two_factor_authentication_path - page.find('.remove-auth-app').click # Delete - click_on t('account.index.totp_confirm_delete') + + click_link( + format( + '%s: %s', + t('two_factor_authentication.auth_app.manage_accessible_label'), + name, + ), + ) + + click_button t('two_factor_authentication.auth_app.delete') + travel_to(10.seconds.from_now) # Travel past the revoked at date from disabling the device click_link t('account.index.auth_app_add'), href: authenticator_setup_url fill_in_totp_name fill_in :code, with: totp_secret_from_page check t('forms.messages.remember_device') click_submit_default - expect(page).to have_current_path(account_two_factor_authentication_path) + expect(page).to have_current_path(account_path) first(:button, t('links.sign_out')).click user end diff --git a/spec/features/users/totp_management_spec.rb b/spec/features/users/totp_management_spec.rb index de496c4e0c5..49b43395fb2 100644 --- a/spec/features/users/totp_management_spec.rb +++ b/spec/features/users/totp_management_spec.rb @@ -4,18 +4,95 @@ context 'when the user has totp enabled' do let(:user) { create(:user, :fully_registered, :with_authentication_app) } - it 'allows the user to disable their totp app' do + it 'allows user to delete a platform authenticator when another 2FA option is set up' do + auth_app_config = create(:auth_app_configuration, user:) + name = auth_app_config.name + + sign_in_and_2fa_user(user) + visit account_two_factor_authentication_path + + expect(user.reload.auth_app_configurations.count).to eq(2) + expect(page).to have_content(name) + + click_link( + format( + '%s: %s', + t('two_factor_authentication.auth_app.manage_accessible_label'), + name, + ), + ) + + expect(current_path).to eq(edit_auth_app_path(id: auth_app_config.id)) + + click_button t('two_factor_authentication.auth_app.delete') + + expect(page).to have_content(t('two_factor_authentication.auth_app.deleted')) + expect(user.reload.auth_app_configurations.count).to eq(1) + end + + it 'allows user to rename an authentication app app' do + auth_app_configuration = create(:auth_app_configuration, user:) + name = auth_app_configuration.name + sign_in_and_2fa_user(user) visit account_two_factor_authentication_path - expect(page).to have_content(t('two_factor_authentication.login_options.auth_app')) - expect(page.find('.remove-auth-app')).to_not be_nil - page.find('.remove-auth-app').click + expect(page).to have_content(name) + + click_link( + format( + '%s: %s', + t('two_factor_authentication.auth_app.manage_accessible_label'), + name, + ), + ) + + expect(current_path).to eq(edit_auth_app_path(id: auth_app_configuration.id)) + expect(page).to have_field( + t('two_factor_authentication.auth_app.nickname'), + with: name, + ) + + fill_in t('two_factor_authentication.auth_app.nickname'), with: 'new name' + + click_button t('two_factor_authentication.auth_app.change_nickname') + + expect(page).to have_content('new name') + expect(page).to have_content(t('two_factor_authentication.auth_app.renamed')) + end + + it 'requires a user to use a unique name when renaming' do + existing_auth_app_configuration = create(:auth_app_configuration, user:, name: 'existing') + new_app_auth_configuration = create(:auth_app_configuration, user:, name: 'new existing') + name = existing_auth_app_configuration.name + + sign_in_and_2fa_user(user) + visit account_two_factor_authentication_path + + expect(page).to have_content(name) + + click_link( + format( + '%s: %s', + t('two_factor_authentication.auth_app.manage_accessible_label'), + name, + ), + ) + + expect(current_path).to eq(edit_auth_app_path(id: existing_auth_app_configuration.id)) + expect(page).to have_field( + t('two_factor_authentication.auth_app.nickname'), + with: name, + ) + + fill_in t('two_factor_authentication.auth_app.nickname'), + with: new_app_auth_configuration.name + + click_button t('two_factor_authentication.auth_app.change_nickname') - expect(current_path).to eq auth_app_delete_path - click_on t('account.index.totp_confirm_delete') + expect(current_path).to eq(edit_auth_app_path(id: existing_auth_app_configuration.id)) - expect(current_path).to eq account_two_factor_authentication_path + expect(page).to have_content(t('errors.manage_authenticator.unique_name_error')) end end diff --git a/spec/forms/two_factor_authentication/auth_app_delete_form_spec.rb b/spec/forms/two_factor_authentication/auth_app_delete_form_spec.rb new file mode 100644 index 00000000000..3e8c3f15dcb --- /dev/null +++ b/spec/forms/two_factor_authentication/auth_app_delete_form_spec.rb @@ -0,0 +1,89 @@ +require 'rails_helper' + +RSpec.describe TwoFactorAuthentication::AuthAppDeleteForm do + let(:user) { create(:user) } + let(:configuration) { create(:auth_app_configuration, user:) } + let(:configuration_id) { configuration&.id } + let(:form) { described_class.new(user:, configuration_id:) } + + describe '#submit' do + let(:result) { form.submit } + + context 'with having another mfa enabled' do + let(:user) { create(:user, :with_phone) } + + it 'returns a successful result' do + expect(result.success?).to eq(true) + expect(result.to_h).to eq(success: true, configuration_id:) + end + + context 'with blank configuration' do + let(:configuration) { nil } + + it 'returns an unsuccessful result' do + expect(result.success?).to eq(false) + expect(result.to_h).to eq( + success: false, + error_details: { + configuration_id: { configuration_not_found: true }, + }, + configuration_id:, + ) + end + end + + context 'with configuration that does not exist' do + let(:configuration_id) { 'does-not-exist' } + + it 'returns an unsuccessful result' do + expect(result.success?).to eq(false) + expect(result.to_h).to eq( + success: false, + error_details: { + configuration_id: { configuration_not_found: true }, + }, + configuration_id:, + ) + end + end + + context 'with configuration not belonging to the user' do + let(:configuration) { create(:auth_app_configuration) } + + it 'returns an unsuccessful result' do + expect(result.success?).to eq(false) + expect(result.to_h).to eq( + success: false, + error_details: { + configuration_id: { configuration_not_found: true }, + }, + configuration_id:, + ) + end + end + end + + context 'with user not having another mfa enabled' do + let(:user) { create(:user) } + + it 'returns an unsuccessful result' do + expect(result.success?).to eq(false) + expect(result.to_h).to eq( + success: false, + error_details: { + configuration_id: { only_method: true }, + }, + configuration_id:, + ) + end + end + end + + describe '#configuration' do + subject(:form_configuration) { form.configuration } + + it 'returns configuration' do + expect(form_configuration).to eq(configuration) + end + end +end diff --git a/spec/forms/two_factor_authentication/auth_app_update_form_spec.rb b/spec/forms/two_factor_authentication/auth_app_update_form_spec.rb new file mode 100644 index 00000000000..87d1dd46948 --- /dev/null +++ b/spec/forms/two_factor_authentication/auth_app_update_form_spec.rb @@ -0,0 +1,142 @@ +require 'rails_helper' + +RSpec.describe TwoFactorAuthentication::AuthAppUpdateForm do + let(:user) { create(:user) } + let(:original_name) { 'original-name' } + let(:configuration) { create(:auth_app_configuration, user:, name: original_name) } + let(:configuration_id) { configuration&.id } + let(:form) { described_class.new(user:, configuration_id:) } + + describe '#submit' do + let(:name) { 'new-namae' } + let(:result) { form.submit(name:) } + + it 'returns a successful result' do + expect(result.success?).to eq(true) + expect(result.to_h).to eq(success: true, configuration_id:) + end + + it 'saves the new name' do + result + + expect(configuration.reload.name).to eq(name) + end + + context 'with blank configuration' do + let(:configuration) { nil } + + it 'returns an unsuccessful result' do + expect(result.success?).to eq(false) + expect(result.to_h).to eq( + success: false, + error_details: { + configuration_id: { configuration_not_found: true }, + }, + configuration_id:, + ) + end + end + + context 'with configuration that does not exist' do + let(:configuration_id) { 'does-not-exist' } + + it 'returns an unsuccessful result' do + expect(result.success?).to eq(false) + expect(result.to_h).to eq( + success: false, + error_details: { + configuration_id: { configuration_not_found: true }, + }, + configuration_id:, + ) + end + end + + context 'with configuration not belonging to the user' do + let(:configuration) { create(:auth_app_configuration, name: original_name) } + + it 'returns an unsuccessful result' do + expect(result.success?).to eq(false) + expect(result.to_h).to eq( + success: false, + error_details: { + configuration_id: { configuration_not_found: true }, + }, + configuration_id:, + ) + end + + it 'does not save the new name' do + expect(configuration).not_to receive(:save) + + result + + expect(configuration.reload.name).to eq(original_name) + end + end + + context 'with blank name' do + let(:name) { '' } + + it 'returns an unsuccessful result' do + expect(result.success?).to eq(false) + expect(result.to_h).to eq( + success: false, + error_details: { + name: { blank: true }, + }, + configuration_id:, + ) + end + + it 'does not save the new name' do + expect(configuration).not_to receive(:save) + + result + + expect(configuration.reload.name).to eq(original_name) + end + end + + context 'with duplicate name' do + before do + create(:auth_app_configuration, user:, name:) + end + + it 'returns an unsuccessful result' do + expect(result.success?).to eq(false) + expect(result.to_h).to eq( + success: false, + error_details: { + name: { duplicate: true }, + }, + configuration_id:, + ) + end + + it 'does not save the new name' do + expect(configuration).not_to receive(:save) + + result + + expect(configuration.reload.name).to eq(original_name) + end + end + end + + describe '#name' do + subject(:name) { form.name } + + it 'returns configuration name' do + expect(name).to eq(configuration.name) + end + end + + describe '#configuration' do + subject(:form_configuration) { form.configuration } + + it 'returns configuration' do + expect(form_configuration).to eq(configuration) + end + end +end diff --git a/spec/views/accounts/_auth_apps.html.erb_spec.rb b/spec/views/accounts/_auth_apps.html.erb_spec.rb new file mode 100644 index 00000000000..3160151842f --- /dev/null +++ b/spec/views/accounts/_auth_apps.html.erb_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +RSpec.describe 'accounts/_auth_apps.html.erb' do + let(:user) do + create( + :user, + auth_app_configurations: create_list(:auth_app_configuration, 2), + ) + end + let(:user_session) { { auth_events: [] } } + + subject(:rendered) { render partial: 'accounts/auth_apps' } + + before do + allow(view).to receive(:current_user).and_return(user) + allow(view).to receive(:user_session).and_return(user_session) + end + + it 'renders a list of auth apps' do + expect(rendered).to have_selector('[role="list"] [role="list-item"]', count: 2) + end +end diff --git a/spec/views/accounts/show.html.erb_spec.rb b/spec/views/accounts/show.html.erb_spec.rb index 23cf81fb0fb..22e8364a511 100644 --- a/spec/views/accounts/show.html.erb_spec.rb +++ b/spec/views/accounts/show.html.erb_spec.rb @@ -109,25 +109,6 @@ end end - context 'auth app listing and adding' do - context 'user has no auth app' do - let(:user) { create(:user, :fully_registered, :with_piv_or_cac) } - - it 'does not render auth app' do - expect(view).to_not render_template(partial: '_auth_apps') - end - end - - context 'user has an auth app' do - let(:user) { create(:user, :fully_registered, :with_authentication_app) } - it 'renders the auth app section' do - render - - expect(view).to render_template(partial: '_auth_apps') - end - end - end - context 'PIV/CAC listing and adding' do context 'user has no piv/cac' do let(:user) { create(:user, :fully_registered, :with_authentication_app) } diff --git a/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb b/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb index 66441dfbd63..66baa50c0e0 100644 --- a/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb +++ b/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb @@ -37,15 +37,6 @@ ), ) end - - it 'contains link to disable TOTP' do - render - - expect(rendered).to have_link( - t('forms.buttons.disable'), - href: auth_app_delete_path(id: user.auth_app_configurations.first.id), - ) - end end context 'when the user does not have password_reset_profile' do diff --git a/spec/views/users/auth_app/edit.html.erb_spec.rb b/spec/views/users/auth_app/edit.html.erb_spec.rb new file mode 100644 index 00000000000..55e90c0393f --- /dev/null +++ b/spec/views/users/auth_app/edit.html.erb_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe 'users/auth_app/edit.html.erb' do + include Devise::Test::ControllerHelpers + + let(:nickname) { 'Example' } + let(:configuration) { create(:auth_app_configuration, name: nickname) } + let(:user) { create(:user, auth_app_configurations: [configuration]) } + let(:form) do + TwoFactorAuthentication::AuthAppUpdateForm.new( + user:, + configuration_id: configuration.id, + ) + end + + subject(:rendered) { render } + + before do + @form = form + end + + it 'renders form to update configuration' do + expect(rendered).to have_selector( + "form[action='#{auth_app_path(id: configuration.id)}'] input[name='_method'][value='put']", + visible: false, + ) + end + + it 'initializes form with configuration values' do + expect(rendered).to have_field( + t('two_factor_authentication.auth_app.nickname'), + with: nickname, + ) + end + + it 'has labelled form with button to delete configuration' do + expect(rendered).to have_button_to_with_accessibility( + t('two_factor_authentication.auth_app.delete'), + auth_app_path(id: configuration.id), + ) + end +end