diff --git a/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb b/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb new file mode 100644 index 00000000000..4a08b0d74a5 --- /dev/null +++ b/app/controllers/api/internal/two_factor_authentication/piv_cac_controller.rb @@ -0,0 +1,60 @@ +module Api + module Internal + module TwoFactorAuthentication + class PivCacController < 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::PivCacUpdateForm.new( + user: current_user, + configuration_id: params[:id], + ).submit(name: params[:name]) + + analytics.piv_cac_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::PivCacDeleteForm.new( + user: current_user, + configuration_id: params[:id], + ).submit + + analytics.piv_cac_delete_submitted(**result.to_h) + + if result.success? + create_user_event(:piv_cac_disabled) + revoke_remember_device(current_user) + deliver_push_notification + render json: { success: true } + else + render json: { success: false, error: result.first_error_message }, status: :bad_request + end + end + + private + + def deliver_push_notification + event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) + PushNotification::HttpPush.deliver(event) + end + + def render_unauthorized + render json: { error: 'Unauthorized' }, status: :unauthorized + end + end + end + end +end diff --git a/app/controllers/users/piv_cac_controller.rb b/app/controllers/users/piv_cac_controller.rb new file mode 100644 index 00000000000..a675022ee94 --- /dev/null +++ b/app/controllers/users/piv_cac_controller.rb @@ -0,0 +1,76 @@ +module Users + class PivCacController < ApplicationController + include ReauthenticationRequiredConcern + + before_action :confirm_two_factor_authenticated + before_action :confirm_recently_authenticated_2fa + before_action :set_form + before_action :validate_configuration_exists + before_action :set_presenter + + def edit; end + + def update + result = form.submit(name: params.dig(:form, :name)) + + analytics.piv_cac_update_name_submitted(**result.to_h) + + if result.success? + flash[:success] = presenter.rename_success_alert_text + redirect_to account_path + else + flash.now[:error] = result.first_error_message + render :edit + end + end + + def destroy + result = form.submit + + analytics.piv_cac_delete_submitted(**result.to_h) + + if result.success? + create_user_event(:piv_cac_disabled) + revoke_remember_device(current_user) + deliver_push_notification + + flash[:success] = presenter.delete_success_alert_text + redirect_to account_path + else + flash[:error] = result.first_error_message + redirect_to edit_piv_cac_path(id: params[:id]) + end + end + + private + + def deliver_push_notification + event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user) + PushNotification::HttpPush.deliver(event) + end + + def form + @form ||= form_class.new(user: current_user, configuration_id: params[:id]) + end + + def presenter + @presenter ||= TwoFactorAuthentication::PivCacEditPresenter.new + end + + alias_method :set_form, :form + alias_method :set_presenter, :presenter + + def form_class + case action_name + when 'edit', 'update' + TwoFactorAuthentication::PivCacUpdateForm + when 'destroy' + TwoFactorAuthentication::PivCacDeleteForm + end + end + + def validate_configuration_exists + render_not_found if form.configuration.blank? + end + end +end diff --git a/app/forms/two_factor_authentication/piv_cac_delete_form.rb b/app/forms/two_factor_authentication/piv_cac_delete_form.rb new file mode 100644 index 00000000000..5b4a9da898f --- /dev/null +++ b/app/forms/two_factor_authentication/piv_cac_delete_form.rb @@ -0,0 +1,57 @@ +module TwoFactorAuthentication + class PivCacDeleteForm + 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.piv_cac_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: configuration_id } + end + end +end diff --git a/app/forms/two_factor_authentication/piv_cac_update_form.rb b/app/forms/two_factor_authentication/piv_cac_update_form.rb new file mode 100644 index 00000000000..2c8144f6072 --- /dev/null +++ b/app/forms/two_factor_authentication/piv_cac_update_form.rb @@ -0,0 +1,70 @@ +module TwoFactorAuthentication + class PivCacUpdateForm + 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.piv_cac_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.piv_cac_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: configuration_id } + end + end +end diff --git a/app/presenters/two_factor_authentication/piv_cac_edit_presenter.rb b/app/presenters/two_factor_authentication/piv_cac_edit_presenter.rb new file mode 100644 index 00000000000..d643ea4ab48 --- /dev/null +++ b/app/presenters/two_factor_authentication/piv_cac_edit_presenter.rb @@ -0,0 +1,31 @@ +module TwoFactorAuthentication + class PivCacEditPresenter + include ActionView::Helpers::TranslationHelper + + def initialize; end + + def heading + t('two_factor_authentication.piv_cac.edit_heading') + end + + def nickname_field_label + t('two_factor_authentication.piv_cac.nickname') + end + + def rename_button_label + t('two_factor_authentication.piv_cac.change_nickname') + end + + def delete_button_label + t('two_factor_authentication.piv_cac.delete') + end + + def rename_success_alert_text + t('two_factor_authentication.piv_cac.renamed') + end + + def delete_success_alert_text + t('two_factor_authentication.piv_cac.deleted') + end + end +end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index 0fbe31c784b..cd08bdb0123 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -3830,6 +3830,25 @@ def phone_input_country_changed(country_code:, **extra) track_event(:phone_input_country_changed, country_code:, **extra) end + # @param [Boolean] success + # @param [Hash] error_details + # @param [Integer] configuration_id + # Tracks when user attempts to delete a PIV/CAC configuraton + def piv_cac_delete_submitted( + success:, + configuration_id:, + error_details: nil, + **extra + ) + track_event( + :piv_cac_delete_submitted, + success:, + error_details:, + configuration_id:, + **extra, + ) + end + # @identity.idp.previous_event_name User Registration: piv cac disabled # @identity.idp.previous_event_name PIV CAC disabled # Tracks when user's piv cac is disabled @@ -3866,6 +3885,25 @@ def piv_cac_setup_visited(in_account_creation_flow:, **extra) ) end + # @param [Boolean] success + # @param [Hash] error_details + # @param [Integer] configuration_id + # Tracks when user submits a name change for a PIV/CAC configuraton + def piv_cac_update_name_submitted( + success:, + configuration_id:, + error_details: nil, + **extra + ) + track_event( + :piv_cac_update_name_submitted, + success:, + error_details:, + configuration_id:, + **extra, + ) + end + # @param [String] redirect_url URL user was directed to # @param [String, nil] step which step # @param [String, nil] location which part of a step, if applicable diff --git a/app/views/accounts/_piv_cac.html.erb b/app/views/accounts/_piv_cac.html.erb index 0008e84835d..820ab08457e 100644 --- a/app/views/accounts/_piv_cac.html.erb +++ b/app/views/accounts/_piv_cac.html.erb @@ -2,20 +2,20 @@ <%= t('headings.account.federal_employee_id') %> -
- <% MfaContext.new(current_user).piv_cac_configurations.each do |piv_cac_configuration| %> -
-
-
- <%= piv_cac_configuration.name %> -
-
- <% if MfaPolicy.new(current_user).multiple_factors_enabled? %> -
- <%= render 'accounts/actions/disable_piv_cac', id: piv_cac_configuration.id %> -
- <% end %> -
+
+ <% MfaContext.new(current_user).piv_cac_configurations.each do |configuration| %> + <%= render ManageableAuthenticatorComponent.new( + configuration:, + user_session:, + manage_url: edit_piv_cac_path(id: configuration.id), + manage_api_url: api_internal_two_factor_authentication_piv_cac_path(id: configuration.id), + custom_strings: { + deleted: t('two_factor_authentication.piv_cac.deleted'), + renamed: t('two_factor_authentication.piv_cac.renamed'), + manage_accessible_label: t('two_factor_authentication.piv_cac.manage_accessible_label'), + }, + role: 'list-item', + ) %> <% end %>
@@ -25,6 +25,7 @@ link_to(setup_piv_cac_url, **tag_options, &block) end, icon: :add, - class: 'usa-button usa-button--outline margin-top-2', + outline: true, + class: 'margin-top-2', ).with_content(t('account.index.piv_cac_add')) %> <% end %> diff --git a/app/views/users/piv_cac/edit.html.erb b/app/views/users/piv_cac/edit.html.erb new file mode 100644 index 00000000000..7995216d56c --- /dev/null +++ b/app/views/users/piv_cac/edit.html.erb @@ -0,0 +1,39 @@ +<% self.title = @presenter.heading %> + +<%= render PageHeadingComponent.new.with_content(@presenter.heading) %> + +<%= simple_form_for( + @form, + as: :form, + method: :put, + html: { autocomplete: 'off' }, + url: piv_cac_path(id: @form.configuration.id), + ) do |f| %> + <%= render ValidatedFieldComponent.new( + form: f, + name: :name, + label: @presenter.nickname_field_label, + ) %> + <%= f.submit( + @presenter.rename_button_label, + class: 'display-block margin-top-5', + ) %> +<% end %> + +<%= render ButtonComponent.new( + action: ->(**tag_options, &block) do + button_to( + piv_cac_path(id: @form.configuration.id), + form: { aria: { label: @presenter.delete_button_label } }, + **tag_options, + &block + ) + end, + method: :delete, + big: true, + wide: true, + danger: true, + class: 'display-block margin-top-2', + ).with_content(@presenter.delete_button_label) %> + +<%= render 'shared/cancel', link: account_path %> \ No newline at end of file diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml index e55ae0021a5..ab5e7db2a51 100644 --- a/config/locales/two_factor_authentication/en.yml +++ b/config/locales/two_factor_authentication/en.yml @@ -140,6 +140,14 @@ en: troubleshooting: change_number: Use another phone number code_not_received: I didn’t receive my one-time code + piv_cac: + change_nickname: Change nickname + delete: Delete this method + deleted: Successfully deleted a PIV/CAC method + edit_heading: Manage your PIV/CAC settings + manage_accessible_label: Manage PIV/CAC + nickname: Nickname + renamed: Successfully renamed your PIV/CAC method piv_cac_header_text: Present your PIV/CAC please_try_again_html: Please try again in %{countdown}. read_about_two_factor_authentication: Read about two-factor authentication diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml index 5f094a6efae..9ef52718a16 100644 --- a/config/locales/two_factor_authentication/es.yml +++ b/config/locales/two_factor_authentication/es.yml @@ -147,6 +147,14 @@ es: troubleshooting: change_number: Utilice otro número de teléfono. code_not_received: No recibí mi código de un solo uso. + piv_cac: + change_nickname: Cambiar apodo + delete: Eliminar este método + deleted: Se ha eliminado correctamente un método PIV/CAC + edit_heading: Gestionar la configuración de PIV/CAC + manage_accessible_label: Gestionar PIV/CAC + nickname: Apodo + renamed: Se ha cambiado correctamente el nombre de su método PIV/CAC piv_cac_header_text: Presenta tu PIV/CAC please_try_again_html: Inténtelo de nuevo en %{countdown}. read_about_two_factor_authentication: Conozca la autenticación de dos factores diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml index 01465df3f19..6d7a86738f7 100644 --- a/config/locales/two_factor_authentication/fr.yml +++ b/config/locales/two_factor_authentication/fr.yml @@ -155,6 +155,14 @@ fr: troubleshooting: change_number: Utilisez un autre numéro de téléphone code_not_received: Je n’ai pas reçu mon code à usage unique + piv_cac: + change_nickname: Changer de pseudo + delete: Supprimer cette méthode + deleted: Suppression réussie d’une méthode PIV/CAC + edit_heading: Gérer les paramètres de PIV/CAC + manage_accessible_label: Gérer la carte PIV/CAC + nickname: Pseudo + renamed: Votre méthode PIV/CAC a été renommée avec succès piv_cac_header_text: Veuillez présenter votre carte PIV/CAC please_try_again_html: Veuillez essayer de nouveau dans %{countdown}. read_about_two_factor_authentication: En savoir plus sur l’authentification à deux facteurs diff --git a/config/routes.rb b/config/routes.rb index e806fcbeaa0..ed8509775e4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -21,6 +21,8 @@ put '/sessions' => 'sessions#update' namespace :two_factor_authentication do + put '/piv_cac/:id' => 'piv_cac#update', as: :piv_cac + delete '/piv_cac/:id' => 'piv_cac#destroy', as: nil put '/webauthn/:id' => 'webauthn#update', as: :webauthn delete '/webauthn/:id' => 'webauthn#destroy', as: nil put '/auth_app/:id' => 'auth_app#update', as: :auth_app @@ -261,6 +263,9 @@ delete '/manage/phone/:id' => 'users/edit_phone#destroy' get '/manage/personal_key' => 'users/personal_keys#show', as: :manage_personal_key post '/manage/personal_key' => 'users/personal_keys#update' + get '/manage/piv_cac/:id' => 'users/piv_cac#edit', as: :edit_piv_cac + put '/manage/piv_cac/:id' => 'users/piv_cac#update', as: :piv_cac + delete '/manage/piv_cac/:id' => 'users/piv_cac#destroy', as: nil 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 diff --git a/spec/controllers/api/internal/two_factor_authentication/piv_cac_controller_spec.rb b/spec/controllers/api/internal/two_factor_authentication/piv_cac_controller_spec.rb new file mode 100644 index 00000000000..91c238740e4 --- /dev/null +++ b/spec/controllers/api/internal/two_factor_authentication/piv_cac_controller_spec.rb @@ -0,0 +1,217 @@ +require 'rails_helper' + +RSpec.describe Api::Internal::TwoFactorAuthentication::PivCacController do + let(:user) { create(:user, :with_phone) } + let(:configuration) { create(:piv_cac_configuration, user: 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( + :piv_cac_update_name_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 + + context 'signed out' do + let(:user) { nil } + let(:configuration) { create(:webauthn_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( + :piv_cac_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(:webauthn_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( + :piv_cac_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.piv_cac_disabled.size }.by 1 + end + + context 'signed out' do + let(:user) { nil } + let(:configuration) { create(:webauthn_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( + :piv_cac_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(:webauthn_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/controllers/users/piv_cac_controller_spec.rb b/spec/controllers/users/piv_cac_controller_spec.rb new file mode 100644 index 00000000000..f407d211e1f --- /dev/null +++ b/spec/controllers/users/piv_cac_controller_spec.rb @@ -0,0 +1,204 @@ +require 'rails_helper' + +RSpec.describe Users::PivCacController do + let(:user) { create(:user, :with_phone) } + let(:configuration) { create(:piv_cac_configuration, user: user) } + let(:presenter) { TwoFactorAuthentication::PivCacEditPresenter.new } + + before do + stub_analytics + stub_sign_in(user) if user + end + + describe '#edit' do + let(:params) { { id: configuration.id } } + let(:response) { get :edit, params: params } + + it 'assigns the form instance' do + response + + expect(assigns(:form)).to be_kind_of(TwoFactorAuthentication::PivCacUpdateForm) + expect(assigns(:form).configuration).to eq(configuration) + end + + context 'signed out' do + let(:user) { nil } + let(:configuration) { create(:webauthn_configuration) } + + it 'redirects to sign-in page' do + expect(response).to redirect_to(new_user_session_url) + end + end + + context 'not recently authenticated' do + before do + allow(controller).to receive(:recently_authenticated_2fa?).and_return(false) + end + + it 'redirects to reauthenticate' do + expect(response).to redirect_to(login_two_factor_options_path) + end + end + + context 'editing a configuration that does not exist' do + let(:params) { { id: 0 } } + + it 'renders not found' do + expect(response).to be_not_found + end + end + + context 'editing a configuration that does not belong to the user' do + let(:configuration) { create(:piv_cac_configuration) } + + it 'renders not found' do + expect(response).to be_not_found + end + end + end + + describe '#update' do + let(:name) { 'example' } + let(:params) { { id: configuration.id, form: { name: name } } } + let(:response) { put :update, params: params } + + it 'redirects to account page with success message' do + expect(response).to redirect_to(account_path) + expect(flash[:success]).to eq(presenter.rename_success_alert_text) + end + + it 'logs the submission attempt' do + response + + expect(@analytics).to have_logged_event( + :piv_cac_update_name_submitted, + success: true, + error_details: nil, + configuration_id: configuration.id.to_s, + ) + end + + it 'assigns the form instance' do + response + + expect(assigns(:form)).to be_kind_of(TwoFactorAuthentication::PivCacUpdateForm) + expect(assigns(:form).configuration).to eq(configuration) + end + + context 'signed out' do + let(:user) { nil } + let(:configuration) { create(:piv_cac_configuration) } + + it 'redirects to sign-in page' do + expect(response).to redirect_to(new_user_session_url) + end + end + + context 'with invalid submission' do + let(:name) { '' } + + it 'renders edit template with error' do + expect(response).to render_template(:edit) + expect(flash.now[:error]).to eq(t('errors.messages.blank')) + end + end + + context 'not recently authenticated' do + before do + allow(controller).to receive(:recently_authenticated_2fa?).and_return(false) + end + + it 'redirects to reauthenticate' do + expect(response).to redirect_to(login_two_factor_options_path) + end + end + + context 'with a configuration that does not exist' do + let(:params) { { id: 0 } } + + it 'renders not found' do + expect(response).to be_not_found + end + end + + context 'with a configuration that does not belong to the user' do + let(:configuration) { create(:piv_cac_configuration) } + + it 'renders not found' do + expect(response).to be_not_found + end + end + end + + describe '#destroy' do + let(:params) { { id: configuration.id } } + let(:response) { delete :destroy, params: params } + + it 'responds with successful result' do + expect(response).to redirect_to(account_path) + expect(flash[:success]).to eq(presenter.delete_success_alert_text) + end + + it 'logs the submission attempt' do + response + + expect(@analytics).to have_logged_event( + :piv_cac_delete_submitted, + success: true, + configuration_id: configuration.id.to_s, + error_details: nil, + ) + end + + it 'assigns the form instance' do + response + + expect(assigns(:form)).to be_kind_of(TwoFactorAuthentication::PivCacDeleteForm) + expect(assigns(:form).configuration).to eq(configuration) + end + + context 'signed out' do + let(:user) { nil } + let(:configuration) { create(:piv_cac_configuration) } + + it 'redirects to sign-in page' do + expect(response).to redirect_to(new_user_session_url) + end + end + + context 'with invalid submission' do + let(:user) { create(:user) } + + it 'redirects to edit with unsuccessful result' do + expect(response).to redirect_to(edit_piv_cac_path(id: configuration.id)) + expect(flash[:error]).to eq(t('errors.manage_authenticator.remove_only_method_error')) + end + end + + context 'not recently authenticated' do + before do + allow(controller).to receive(:recently_authenticated_2fa?).and_return(false) + end + + it 'redirects to reauthenticate' do + expect(response).to redirect_to(login_two_factor_options_path) + end + end + + context 'with a configuration that does not exist' do + let(:params) { { id: 0 } } + + it 'renders not found' do + expect(response).to be_not_found + end + end + + context 'with a configuration that does not belong to the user' do + let(:configuration) { create(:piv_cac_configuration) } + + it 'renders not found' do + expect(response).to be_not_found + end + end + end +end diff --git a/spec/features/users/piv_cac_management_spec.rb b/spec/features/users/piv_cac_management_spec.rb index 5b6f67e76f3..26ea4315e19 100644 --- a/spec/features/users/piv_cac_management_spec.rb +++ b/spec/features/users/piv_cac_management_spec.rb @@ -35,9 +35,9 @@ def find_form(page, attributes) expect(current_path).to eq account_path visit account_two_factor_authentication_path - expect(page.find('.remove-piv')).to_not be_nil - user.reload + expect(page).to have_link(href: edit_piv_cac_path(id: user.piv_cac_configurations.first.id)) + expect(user.piv_cac_configurations.first.x509_dn_uuid).to eq uuid expect(user.events.order(created_at: :desc).last.event_type).to eq('piv_cac_enabled') end @@ -160,6 +160,7 @@ def find_form(page, attributes) with: { phone: '+1 202-555-1212' }, ) end + let(:piv_name) { 'My PIV Card' } scenario 'does allow association of another piv/cac with the account' do stub_piv_cac_service @@ -175,15 +176,20 @@ def find_form(page, attributes) sign_in_and_2fa_user(user) visit account_two_factor_authentication_path - expect(page.find('.remove-piv')).to_not be_nil - page.find('.remove-piv').click + expect(page).to have_content(piv_name) - expect(current_path).to eq piv_cac_delete_path - click_on t('account.index.piv_cac_confirm_delete') + click_link( + format( + '%s: %s', + t('two_factor_authentication.piv_cac.manage_accessible_label'), + piv_name, + ), + ) - expect(current_path).to eq account_two_factor_authentication_path + expect(current_path).to eq(edit_piv_cac_path(id: user.piv_cac_configurations.first.id)) + click_button t('two_factor_authentication.piv_cac.delete') - expect(page).to have_link(t('account.index.piv_cac_add'), href: setup_piv_cac_url) + expect(page).to have_content(t('two_factor_authentication.piv_cac.deleted')) user.reload expect(user.piv_cac_configurations.first&.x509_dn_uuid).to be_nil diff --git a/spec/forms/two_factor_authentication/piv_cac_delete_form_spec.rb b/spec/forms/two_factor_authentication/piv_cac_delete_form_spec.rb new file mode 100644 index 00000000000..ef6e43b4fd4 --- /dev/null +++ b/spec/forms/two_factor_authentication/piv_cac_delete_form_spec.rb @@ -0,0 +1,91 @@ +require 'rails_helper' + +RSpec.describe TwoFactorAuthentication::PivCacDeleteForm do + let(:user) { create(:user) } + let(:configuration) { create(:piv_cac_configuration, user: user) } + let(:configuration_id) { configuration&.id } + let(:form) { described_class.new(user: user, configuration_id: configuration_id) } + + describe '#submit' do + let(:result) { form.submit } + + context 'when the user has another mfa enabled' do + before do + create(:phone_configuration, user: user) + end + + it 'returns a successful result' do + expect(result.success?).to eq(true) + expect(result.to_h).to eq(success: true, configuration_id: 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: configuration_id, + ) + end + end + + context 'with a non-existent configuration_id' 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: configuration_id, + ) + end + end + + context 'with configuration not belonging to the user' do + let(:configuration) { create(:piv_cac_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: configuration_id, + ) + end + end + end + + context 'when the user does not have 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: 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/piv_cac_update_form_spec.rb b/spec/forms/two_factor_authentication/piv_cac_update_form_spec.rb new file mode 100644 index 00000000000..2d6eb087185 --- /dev/null +++ b/spec/forms/two_factor_authentication/piv_cac_update_form_spec.rb @@ -0,0 +1,142 @@ +require 'rails_helper' + +RSpec.describe TwoFactorAuthentication::PivCacUpdateForm do + let(:user) { create(:user) } + let(:original_name) { 'original-name' } + let(:configuration) { create(:piv_cac_configuration, user: user, name: original_name) } + let(:configuration_id) { configuration&.id } + let(:form) { described_class.new(user:, configuration_id:) } + + describe '#submit' do + let(:name) { 'new-name' } + let(:result) { form.submit(name: name) } + + it 'returns a successful result' do + expect(result.success?).to eq(true) + expect(result.to_h).to eq(success: true, configuration_id: 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: 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: configuration_id, + ) + end + end + + context 'with configuration not belonging to the user' do + let(:configuration) { create(:piv_cac_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: 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(:piv_cac_configuration, user:, name:, x509_dn_uuid: 'unique-1') + 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/presenters/two_factor_authentication/piv_cac_edit_presenter_spec.rb b/spec/presenters/two_factor_authentication/piv_cac_edit_presenter_spec.rb new file mode 100644 index 00000000000..23aa801617c --- /dev/null +++ b/spec/presenters/two_factor_authentication/piv_cac_edit_presenter_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +RSpec.describe TwoFactorAuthentication::PivCacEditPresenter do + subject(:presenter) { described_class.new } + + describe '#heading' do + it 'returns heading text' do + expect(presenter.heading).to eq(t('two_factor_authentication.piv_cac.edit_heading')) + end + end + + describe '#nickname_field_label' do + it 'returns nickname field label' do + expect(presenter.nickname_field_label).to eq(t('two_factor_authentication.piv_cac.nickname')) + end + end + + describe '#rename_button_label' do + it 'returns rename button label' do + expect(presenter.rename_button_label). + to eq(t('two_factor_authentication.piv_cac.change_nickname')) + end + end + + describe '#delete_button_label' do + it 'returns delete button label' do + expect(presenter.delete_button_label).to eq(t('two_factor_authentication.piv_cac.delete')) + end + end + + describe '#rename_success_alert_text' do + it 'returns rename success alert text' do + expect(presenter.rename_success_alert_text). + to eq(t('two_factor_authentication.piv_cac.renamed')) + end + end + + describe '#delete_success_alert_text' do + it 'returns delete success alert text' do + expect(presenter.delete_success_alert_text). + to eq(t('two_factor_authentication.piv_cac.deleted')) + end + end +end diff --git a/spec/views/accounts/_piv_cac.html.erb_spec.rb b/spec/views/accounts/_piv_cac.html.erb_spec.rb new file mode 100644 index 00000000000..3efaa9961aa --- /dev/null +++ b/spec/views/accounts/_piv_cac.html.erb_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe 'accounts/_piv_cac.html.erb' do + let(:user) do + user = create(:user) + 2.times do |n| + create( + :piv_cac_configuration, + user: user, + name: "Configuration #{n}", + x509_dn_uuid: "unique-uuid-#{n}", + ) + end + user + end + + let(:user_session) { { auth_events: [] } } + + subject(:rendered) { render partial: 'accounts/piv_cac' } + + 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 piv cac configurations' 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 22e8364a511..58ddbac7c7b 100644 --- a/spec/views/accounts/show.html.erb_spec.rb +++ b/spec/views/accounts/show.html.erb_spec.rb @@ -120,6 +120,11 @@ context 'user has a piv/cac' do let(:user) { create(:user, :fully_registered, :with_piv_or_cac) } + + before do + allow(view).to receive(:user_session).and_return({}) + end + it 'renders the piv/cac section' do render diff --git a/spec/views/users/piv_cac/edit.html.erb_spec.rb b/spec/views/users/piv_cac/edit.html.erb_spec.rb new file mode 100644 index 00000000000..75e97e6e3dd --- /dev/null +++ b/spec/views/users/piv_cac/edit.html.erb_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +RSpec.describe 'users/piv_cac/edit.html.erb' do + include Devise::Test::ControllerHelpers + + let(:nickname) { 'Example' } + let(:configuration) { create(:piv_cac_configuration, name: nickname) } + let(:user) { create(:user, piv_cac_configurations: [configuration]) } + let(:form) do + TwoFactorAuthentication::PivCacUpdateForm.new( + user:, + configuration_id: configuration.id, + ) + end + let(:presenter) do + TwoFactorAuthentication::PivCacEditPresenter.new + end + + subject(:rendered) { render } + + before do + @form = form + @presenter = presenter + end + + it 'renders form to update configuration' do + expect(rendered).to have_selector( + "form[action='#{piv_cac_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.piv_cac.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.piv_cac.delete'), + piv_cac_path(id: configuration.id), + ) + end +end