Browse Source

Maintenance: Add test for 2FA user profile setting.

Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Martin Gruner 9 months ago
parent
commit
a6db16221b

+ 0 - 1
app/frontend/apps/desktop/components/CommonFlyout/CommonFlyoutActionFooter.vue

@@ -19,7 +19,6 @@ const emit = defineEmits<{
   action: []
 }>()
 
-// ** Form // **
 const { isDisabled, formNodeId } = useForm(toRef(props, 'form'))
 
 const cancel = () => {

+ 7 - 32
app/frontend/apps/desktop/components/TwoFactor/TwoFactorConfigurationWizard.vue

@@ -1,16 +1,14 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { ref, computed, toRef } from 'vue'
+import { ref, computed } from 'vue'
 
-import { useForm } from '#shared/components/Form/useForm.ts'
 import { useTwoFactorPlugins } from '#shared/entities/two-factor/composables/useTwoFactorPlugins.ts'
 import type { ObjectLike } from '#shared/types/utils.ts'
 
-import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
-
 import TwoFactorConfigurationMethodList from './TwoFactorConfiguration/TwoFactorConfigurationMethodList.vue'
 import TwoFactorConfigurationRecoveryCodes from './TwoFactorConfiguration/TwoFactorConfigurationRecoveryCodes.vue'
+import TwoFactorConfigurationWizardFooterActions from './TwoFactorConfigurationWizard/TwoFactorConfigurationWizardFooterActions.vue'
 
 import type {
   TwoFactorConfigurationActionPayload,
@@ -40,10 +38,6 @@ const activeComponent = computed(() => {
   }
 })
 
-const { isDisabled, formNodeId } = useForm(
-  toRef(activeComponentInstance.value?.footerActionOptions || {}, 'form'),
-)
-
 const footerActionOptions = computed(() => ({
   hideActionButton:
     activeComponentInstance.value?.footerActionOptions?.hideActionButton,
@@ -104,29 +98,10 @@ const cancel = () => {
     />
   </div>
   <div class="flex flex-col gap-3">
-    <CommonButton
-      v-if="!footerActionOptions.hideActionButton"
-      size="large"
-      block
-      :disabled="isDisabled || footerActionOptions.actionButton?.disabled"
-      :form="formNodeId"
-      :type="footerActionOptions.actionButton?.type"
-      :prefix-icon="footerActionOptions.actionButton?.prefixIcon"
-      :variant="footerActionOptions.actionButton?.variant || 'submit'"
-      @click="onFooterButtonAction()"
-    >
-      {{ $t(footerActionOptions.actionLabel) || 'Submit' }}
-    </CommonButton>
-    <CommonButton
-      v-if="!footerActionOptions.hideCancelButton"
-      size="large"
-      block
-      :disabled="isDisabled || footerActionOptions.cancelButton?.disabled"
-      :prefix-icon="footerActionOptions.cancelButton?.prefixIcon"
-      :variant="footerActionOptions.cancelButton?.variant || 'secondary'"
-      @click="cancel()"
-    >
-      {{ $t(footerActionOptions.cancelLabel) }}
-    </CommonButton>
+    <TwoFactorConfigurationWizardFooterActions
+      v-bind="footerActionOptions"
+      @action="onFooterButtonAction()"
+      @cancel="cancel()"
+    />
   </div>
 </template>

+ 56 - 0
app/frontend/apps/desktop/components/TwoFactor/TwoFactorConfigurationWizard/TwoFactorConfigurationWizardFooterActions.vue

@@ -0,0 +1,56 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { toRef } from 'vue'
+
+import { useForm } from '#shared/components/Form/useForm.ts'
+
+import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
+import type { ActionFooterOptions as Props } from '#desktop/components/CommonFlyout/types.ts'
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  cancel: []
+  action: []
+}>()
+
+const { isDisabled, formNodeId } = useForm(toRef(props, 'form'))
+
+const cancel = () => {
+  emit('cancel')
+}
+
+const execute = () => {
+  emit('action')
+}
+</script>
+
+<template>
+  <div class="flex flex-col gap-3">
+    <CommonButton
+      v-if="!hideActionButton"
+      size="large"
+      block
+      :disabled="isDisabled || actionButton?.disabled"
+      :form="formNodeId"
+      :type="actionButton?.type"
+      :prefix-icon="actionButton?.prefixIcon"
+      :variant="actionButton?.variant || 'submit'"
+      @click="execute()"
+    >
+      {{ $t(actionLabel) || 'Submit' }}
+    </CommonButton>
+    <CommonButton
+      v-if="!hideCancelButton"
+      size="large"
+      block
+      :disabled="isDisabled || cancelButton?.disabled"
+      :prefix-icon="cancelButton?.prefixIcon"
+      :variant="cancelButton?.variant || 'secondary'"
+      @click="cancel()"
+    >
+      {{ $t(cancelLabel) }}
+    </CommonButton>
+  </div>
+</template>

+ 1 - 1
i18n/zammad.pot

@@ -6301,7 +6301,7 @@ msgstr ""
 #: app/assets/javascripts/app/views/integration/exchange_wizard.jst.eco:98
 #: app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco:105
 #: app/assets/javascripts/app/views/signup/verify.jst.eco:10
-#: app/frontend/apps/desktop/components/TwoFactor/TwoFactorConfigurationWizard.vue:57
+#: app/frontend/apps/desktop/components/TwoFactor/TwoFactorConfigurationWizard.vue:51
 #: app/frontend/apps/desktop/pages/guided-setup/components/GuidedSetupActionFooter.vue:88
 msgid "Go Back"
 msgstr ""

+ 1 - 42
spec/system/apps/desktop/account_spec.rb → spec/system/apps/desktop/personal_setting/profile_spec.rb

@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe 'Desktop > Account', app: :desktop_view, authenticated_as: :agent, type: :system do
+RSpec.describe 'Desktop > Personal Setting > Profile', app: :desktop_view, authenticated_as: :agent, type: :system do
   let(:agent) { create(:agent) }
 
   before do
@@ -40,47 +40,6 @@ RSpec.describe 'Desktop > Account', app: :desktop_view, authenticated_as: :agent
     end
   end
 
-  describe 'password change' do
-    let(:agent) { create(:agent, password: 'test') }
-
-    it 'user can change password' do
-      click_on 'Profile settings'
-      click_on 'Password'
-
-      fill_in 'Current password', with: 'test'
-      fill_in 'New password', with: 'testTEST1234'
-      fill_in 'Confirm new password', with: 'testTEST1234'
-
-      click_on 'Change Password'
-
-      expect(page).to have_text('Password changed successfully')
-    end
-  end
-
-  describe 'token handling' do
-    let(:agent) { create(:admin) }
-
-    it 'user can create and use a token' do
-      click_on 'Profile settings'
-      click_on 'Token Access'
-
-      click_on 'New Personal Access Token'
-
-      fill_in 'Name', with: 'Test Token'
-
-      # Activate some permissions for the token
-      find('span', text: 'Configure your system.').click
-      find('span', text: 'Manage personal settings.').click
-
-      click_on 'Create'
-      wait_for_mutation('userCurrentAccessTokenAdd')
-
-      expect(Token.last.name).to eq('Test Token')
-      expect(Token.last.permissions.map(&:name)).to eq(%w[admin user_preferences])
-      expect(Token.last.check?).to be(true)
-    end
-  end
-
   describe 'avatar handling', authenticated_as: :agent do
     let(:agent) { create(:agent, firstname: 'Jane', lastname: 'Doe') }
 

+ 245 - 0
spec/system/apps/desktop/personal_setting/security_spec.rb

@@ -0,0 +1,245 @@
+# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+require 'rails_helper'
+
+RSpec.describe 'Desktop > Personal Setting > Security', app: :desktop_view, authenticated_as: :agent, type: :system do
+  let(:agent) { create(:agent) }
+
+  def go_to_personal_setting
+    visit '/'
+    find("[aria-label=\"Avatar (#{agent.fullname})\"]").click
+    click_on 'Profile settings'
+  end
+
+  describe 'password change' do
+    let(:agent) { create(:agent, password: 'test') }
+
+    it 'user can change password' do
+      go_to_personal_setting
+      click_on 'Password'
+
+      fill_in 'Current password', with: 'test'
+      fill_in 'New password', with: 'testTEST1234'
+      fill_in 'Confirm new password', with: 'testTEST1234'
+
+      click_on 'Change Password'
+
+      expect(page).to have_text('Password changed successfully')
+    end
+  end
+
+  describe 'two-factor authentication handling' do
+    let(:agent) { create(:agent, password: 'test') }
+
+    context 'with security keys method' do
+      before do
+        skip('Mocking of Web Authentication API is currently supported only in Chrome.') if Capybara.current_driver != :zammad_chrome
+        Setting.set('two_factor_authentication_method_security_keys', true)
+        Setting.set('two_factor_authentication_recovery_codes', false)
+        Setting.set('two_factor_authentication_enforce_role_ids', [])
+      end
+
+      it 'can set up and use the method' do
+        go_to_personal_setting
+        click_on 'Two-factor Authentication'
+
+        click_on 'Set up security keys'
+
+        fill_in 'Current password', with: 'test'
+        click_on 'Next'
+
+        click_on 'Set Up'
+
+        fill_in 'Name for this security key', with: Faker::Lorem.unique.word
+
+        # Mock a U2F key via the Selenium virtual authenticator feature (supported only by Chrome ATM).
+        #   A virtual authenticator instance will be set up for the remainder of the browser session.
+        #   We will reuse it later during the login phase.
+        options = Selenium::WebDriver::VirtualAuthenticatorOptions.new(protocol: :u2f, transport: :usb, resident_key: false,
+                                                                       user_consenting: true, user_verification: true,
+                                                                       user_verified: true)
+        page.driver.browser.add_virtual_authenticator(options)
+
+        click_on 'Next'
+
+        expect(page).to have_text('Two-factor authentication method was set up successfully.')
+
+        # Logout
+        find("[aria-label=\"Avatar (#{agent.fullname})\"]").click
+        click_on 'Sign out'
+
+        # Login
+        fill_in 'Username / Email', with: agent.login
+        fill_in 'Password', with: 'test'
+        click_on 'Sign in'
+
+        # Mocked key via the virtual authenticator will be accessed again right about here.
+
+        expect(page).to have_current_route('/')
+      end
+    end
+
+    context 'with authenticator app method' do
+      before do
+        Setting.set('two_factor_authentication_method_authenticator_app', true)
+        Setting.set('two_factor_authentication_recovery_codes', false)
+      end
+
+      describe 'when using optional setup in personal settings' do
+        before do
+          Setting.set('two_factor_authentication_enforce_role_ids', [])
+        end
+
+        it 'can set up and use the method' do
+          go_to_personal_setting
+          click_on 'Two-factor Authentication'
+
+          click_on 'Set up authenticator app'
+
+          fill_in 'Current password', with: 'test'
+          click_on 'Next'
+
+          find('canvas').click
+          secret = find('#qr-code-secret-overlay > span').text
+          fill_in('securityCode', with: ROTP::TOTP.new(secret).now)
+          click_on 'Set Up'
+          expect(page).to have_text('Two-factor method has been configured successfully.')
+
+          # Logout
+          find("[aria-label=\"Avatar (#{agent.fullname})\"]").click
+          click_on 'Sign out'
+
+          # Workaround for a non-reusable OTP inside the 30s window.
+          #   Set the last OTP timestamp in the 2FA configuration to a time window in the past.
+          two_factor_pref = agent.two_factor_preferences.last
+          two_factor_pref.configuration[:last_otp_at] = 30.seconds.ago.to_i
+          two_factor_pref.save!
+
+          # Login
+          fill_in 'Username / Email', with: agent.login
+          fill_in 'Password', with: 'test'
+          click_on 'Sign in'
+
+          # 2FA Code
+          fill_in('Security Code', with: ROTP::TOTP.new(secret).now)
+          click_on('Sign in')
+          expect(page).to have_current_route('/')
+        end
+      end
+
+      describe 'when using mandatory setup after authentication' do
+        before do
+          Setting.set('two_factor_authentication_enforce_role_ids', [Role.lookup(name: 'Agent').id])
+        end
+
+        it 'can set up and use the method' do
+          visit '/'
+
+          expect(page).to have_text('Set Up Two-factor Authentication')
+          expect(page).to have_text('You must protect your account with two-factor authentication.')
+          expect(page).to have_text('Choose your preferred two-factor authentication method to set it up.')
+
+          click_on 'Authenticator App'
+
+          find('canvas').click
+          secret = find('#qr-code-secret-overlay > span').text
+          fill_in('securityCode', with: ROTP::TOTP.new(secret).now)
+          click_on 'Set Up'
+          expect(page).to have_text('Two-factor method has been configured successfully.')
+
+          # Logout
+          find("[aria-label=\"Avatar (#{agent.fullname})\"]").click
+          click_on 'Sign out'
+
+          # Workaround for a non-reusable OTP inside the 30s window.
+          #   Set the last OTP timestamp in the 2FA configuration to a time window in the past.
+          two_factor_pref = agent.two_factor_preferences.last
+          two_factor_pref.configuration[:last_otp_at] = 30.seconds.ago.to_i
+          two_factor_pref.save!
+
+          # Login
+          fill_in 'Username / Email', with: agent.login
+          fill_in 'Password', with: 'test'
+          click_on 'Sign in'
+
+          # 2FA Code
+          fill_in('Security Code', with: ROTP::TOTP.new(secret).now)
+          click_on('Sign in')
+          expect(page).to have_current_route('/')
+        end
+      end
+    end
+
+    context 'with recovery codes enabled' do
+      before do
+        Setting.set('two_factor_authentication_method_authenticator_app', true)
+        Setting.set('two_factor_authentication_recovery_codes', true)
+        Setting.set('two_factor_authentication_enforce_role_ids', [])
+      end
+
+      it 'can use a pre-generated code to login' do
+        go_to_personal_setting
+        click_on 'Two-factor Authentication'
+
+        click_on 'Set up authenticator app'
+
+        fill_in 'Current password', with: 'test'
+        click_on 'Next'
+
+        find('canvas').click
+        secret = find('#qr-code-secret-overlay > span').text
+        fill_in('securityCode', with: ROTP::TOTP.new(secret).now)
+        click_on 'Set Up'
+        expect(page).to have_text('Two-factor method has been configured successfully.')
+
+        recovery_codes = find('div[data-test-id="recovery-codes"]').text.split("\n")
+
+        expect(recovery_codes.length).to be(10)
+
+        click_on "OK, I've saved my recovery codes"
+
+        expect(page).to have_button('Regenerate Recovery Codes')
+
+        # Logout
+        find("[aria-label=\"Avatar (#{agent.fullname})\"]").click
+        click_on 'Sign out'
+
+        # Login
+        fill_in 'Username / Email', with: agent.login
+        fill_in 'Password', with: 'test'
+        click_on 'Sign in'
+
+        # Switch to recovery codes.
+        click_on 'Try another method'
+        click_on 'Or use one of your recovery codes.'
+        fill_in('Recovery Code', with: recovery_codes[0])
+        click_on('Sign in')
+        expect(page).to have_current_route('/')
+      end
+    end
+  end
+
+  describe 'token handling' do
+    let(:agent) { create(:admin) }
+
+    it 'user can create and use a token' do
+      go_to_personal_setting
+      click_on 'Token Access'
+
+      click_on 'New Personal Access Token'
+
+      fill_in 'Name', with: 'Test Token'
+
+      # Activate some permissions for the token
+      find('span', text: 'Configure your system.').click
+      find('span', text: 'Manage personal settings.').click
+
+      click_on 'Create'
+      wait_for_mutation('userCurrentAccessTokenAdd')
+
+      expect(Token.last.name).to eq('Test Token')
+      expect(Token.last.permissions.map(&:name)).to eq(%w[admin user_preferences])
+      expect(Token.last.check?).to be(true)
+    end
+  end
+end