Commit 27b71f08 authored by Mark Florian's avatar Mark Florian

Merge branch '267730-2fa-ux-save-recovery-codes' into 'master'

Require users to copy, download, or print 2FA recovery codes

See merge request gitlab-org/gitlab!49078
parents 25f534f1 3da3dab0
...@@ -13,11 +13,17 @@ export const mount2faAuthentication = () => { ...@@ -13,11 +13,17 @@ export const mount2faAuthentication = () => {
}; };
export const mount2faRegistration = () => { export const mount2faRegistration = () => {
const el = $('#js-register-token-2fa');
if (!el.length) {
return;
}
if (gon.webauthn) { if (gon.webauthn) {
const webauthnRegister = new WebAuthnRegister($('#js-register-token-2fa'), gon.webauthn); const webauthnRegister = new WebAuthnRegister(el, gon.webauthn);
webauthnRegister.start(); webauthnRegister.start();
} else { } else {
const u2fRegister = new U2FRegister($('#js-register-token-2fa'), gon.u2f); const u2fRegister = new U2FRegister(el, gon.u2f);
u2fRegister.start(); u2fRegister.start();
} }
}; };
<script>
import Mousetrap from 'mousetrap';
import { GlSprintf, GlButton, GlAlert } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { __ } from '~/locale';
import {
COPY_BUTTON_ACTION,
DOWNLOAD_BUTTON_ACTION,
PRINT_BUTTON_ACTION,
RECOVERY_CODE_DOWNLOAD_FILENAME,
COPY_KEYBOARD_SHORTCUT,
} from '../constants';
export const i18n = {
pageTitle: __('Two-factor Authentication Recovery codes'),
alertTitle: __('Please copy, download, or print your recovery codes before proceeding.'),
pageDescription: __(
'Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{boldStart}will%{boldEnd} lose access to your account.',
),
copyButton: __('Copy codes'),
downloadButton: __('Download codes'),
printButton: __('Print codes'),
proceedButton: __('Proceed'),
};
export default {
name: 'RecoveryCodes',
copyButtonAction: COPY_BUTTON_ACTION,
downloadButtonAction: DOWNLOAD_BUTTON_ACTION,
printButtonAction: PRINT_BUTTON_ACTION,
recoveryCodeDownloadFilename: RECOVERY_CODE_DOWNLOAD_FILENAME,
i18n,
mousetrap: null,
components: { GlSprintf, GlButton, GlAlert, ClipboardButton },
props: {
codes: {
type: Array,
required: true,
},
profileAccountPath: {
type: String,
required: true,
},
},
data() {
return {
proceedButtonDisabled: true,
};
},
computed: {
codesAsString() {
return this.codes.join('\n');
},
codeDownloadUrl() {
return `data:text/plain;charset=utf-8,${encodeURIComponent(this.codesAsString)}`;
},
},
created() {
this.$options.mousetrap = new Mousetrap();
this.$options.mousetrap.bind(COPY_KEYBOARD_SHORTCUT, this.handleKeyboardCopy);
},
beforeDestroy() {
if (!this.$options.mousetrap) {
return;
}
this.$options.mousetrap.unbind(COPY_KEYBOARD_SHORTCUT);
},
methods: {
handleButtonClick(action) {
this.proceedButtonDisabled = false;
if (action === this.$options.printButtonAction) {
window.print();
}
},
handleKeyboardCopy() {
if (!window.getSelection) {
return;
}
const copiedText = window.getSelection().toString();
if (copiedText.includes(this.codesAsString)) {
this.proceedButtonDisabled = false;
}
},
},
};
</script>
<template>
<div>
<h3 class="page-title">
{{ $options.i18n.pageTitle }}
</h3>
<hr />
<gl-alert variant="info" :dismissible="false">
{{ $options.i18n.alertTitle }}
</gl-alert>
<p class="gl-mt-5">
<gl-sprintf :message="$options.i18n.pageDescription">
<template #bold="{ content }"
><strong>{{ content }}</strong></template
>
</gl-sprintf>
</p>
<div
class="codes-to-print gl-my-5 gl-p-5 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base"
data-testid="recovery-codes"
data-qa-selector="codes_content"
>
<ul class="gl-m-0 gl-pl-5">
<li v-for="(code, index) in codes" :key="index">
<span class="gl-font-monospace" data-qa-selector="code_content">{{ code }}</span>
</li>
</ul>
</div>
<div class="gl-my-n2 gl-mx-n2 gl-display-flex gl-flex-wrap">
<div class="gl-p-2">
<clipboard-button
:title="$options.i18n.copyButton"
:text="codesAsString"
data-qa-selector="copy_button"
@click="handleButtonClick($options.copyButtonAction)"
>
{{ $options.i18n.copyButton }}
</clipboard-button>
</div>
<div class="gl-p-2">
<gl-button
:href="codeDownloadUrl"
:title="$options.i18n.downloadButton"
icon="download"
:download="$options.recoveryCodeDownloadFilename"
@click="handleButtonClick($options.downloadButtonAction)"
>
{{ $options.i18n.downloadButton }}
</gl-button>
</div>
<div class="gl-p-2">
<gl-button
:title="$options.i18n.printButton"
@click="handleButtonClick($options.printButtonAction)"
>
{{ $options.i18n.printButton }}
</gl-button>
</div>
<div class="gl-p-2">
<gl-button
:href="profileAccountPath"
:disabled="proceedButtonDisabled"
:title="$options.i18n.proceedButton"
variant="success"
data-qa-selector="proceed_button"
>{{ $options.i18n.proceedButton }}</gl-button
>
</div>
</div>
</div>
</template>
export const COPY_BUTTON_ACTION = 'copy';
export const DOWNLOAD_BUTTON_ACTION = 'download';
export const PRINT_BUTTON_ACTION = 'print';
export const RECOVERY_CODE_DOWNLOAD_FILENAME = 'gitlab-recovery-codes.txt';
export const SUCCESS_QUERY_PARAM = 'two_factor_auth_enabled_successfully';
export const COPY_KEYBOARD_SHORTCUT = 'mod+c';
import Vue from 'vue';
import { updateHistory, removeParams } from '~/lib/utils/url_utility';
import RecoveryCodes from './components/recovery_codes.vue';
import { SUCCESS_QUERY_PARAM } from './constants';
export const initRecoveryCodes = () => {
const el = document.querySelector('.js-2fa-recovery-codes');
if (!el) {
return false;
}
const { codes = '[]', profileAccountPath = '' } = el.dataset;
return new Vue({
el,
render(createElement) {
return createElement(RecoveryCodes, {
props: {
codes: JSON.parse(codes),
profileAccountPath,
},
});
},
});
};
export const initClose2faSuccessMessage = () => {
const closeButton = document.querySelector('.js-close-2fa-enabled-success-alert');
if (!closeButton) {
return;
}
closeButton.addEventListener(
'click',
() => {
updateHistory({
url: removeParams([SUCCESS_QUERY_PARAM]),
title: document.title,
replace: true,
});
},
{ once: true },
);
};
import initProfileAccount from '~/profile/account'; import initProfileAccount from '~/profile/account';
import { initClose2faSuccessMessage } from '~/authentication/two_factor_auth';
document.addEventListener('DOMContentLoaded', initProfileAccount); document.addEventListener('DOMContentLoaded', initProfileAccount);
initClose2faSuccessMessage();
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import { mount2faRegistration } from '~/authentication/mount_2fa'; import { mount2faRegistration } from '~/authentication/mount_2fa';
import { initRecoveryCodes } from '~/authentication/two_factor_auth';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const twoFactorNode = document.querySelector('.js-two-factor-auth'); const twoFactorNode = document.querySelector('.js-two-factor-auth');
const skippable = parseBoolean(twoFactorNode.dataset.twoFactorSkippable); const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSkippable) : false;
if (skippable) { if (skippable) {
const button = `<a class="btn btn-sm btn-warning float-right" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`; const button = `<a class="btn btn-sm btn-warning float-right" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`;
...@@ -13,3 +14,5 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -13,3 +14,5 @@ document.addEventListener('DOMContentLoaded', () => {
mount2faRegistration(); mount2faRegistration();
}); });
initRecoveryCodes();
...@@ -84,5 +84,8 @@ export default { ...@@ -84,5 +84,8 @@ export default {
:size="size" :size="size"
icon="copy-to-clipboard" icon="copy-to-clipboard"
:aria-label="__('Copy this value')" :aria-label="__('Copy this value')"
/> v-on="$listeners"
>
<slot></slot>
</gl-button>
</template> </template>
@media print {
.codes-to-print {
background-color: var(--white);
height: 100%;
width: 100%;
position: fixed;
top: 0;
left: 0;
margin: 0;
}
}
...@@ -7,6 +7,14 @@ ...@@ -7,6 +7,14 @@
.gl-alert-body .gl-alert-body
= s_('Profiles|Some options are unavailable for LDAP accounts') = s_('Profiles|Some options are unavailable for LDAP accounts')
- if params[:two_factor_auth_enabled_successfully]
.gl-alert.gl-alert-success.gl-my-5{ role: 'alert' }
= sprite_icon('check-circle', size: 16, css_class: 'gl-alert-icon gl-alert-icon-no-title')
%button.gl-alert-dismiss.js-close-2fa-enabled-success-alert{ type: 'button', aria: { label: _('Close') } }
= sprite_icon('close', size: 16)
.gl-alert-body
= _('Congratulations! You have enabled Two-factor Authentication!')
.row.gl-mt-3 .row.gl-mt-3
.col-lg-4.profile-settings-sidebar .col-lg-4.profile-settings-sidebar
%h4.gl-mt-0 %h4.gl-mt-0
......
%p.slead - show_success_alert = local_assigns.fetch(:show_success_alert, nil)
- lose_2fa_message = _('Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' }
= lose_2fa_message.html_safe
.codes.card{ data: { qa_selector: 'codes_content' } } - if Feature.enabled?(:vue_2fa_recovery_codes, current_user)
%ul .js-2fa-recovery-codes{ data: { codes: @codes.to_json, profile_account_path: profile_account_path(two_factor_auth_enabled_successfully: show_success_alert) } }
- @codes.each do |code| - else
%li %p.slead
%span.monospace{ data: { qa_selector: 'code_content' } }= code - lose_2fa_message = _('Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' }
= lose_2fa_message.html_safe
.d-flex .codes.card{ data: { qa_selector: 'codes_content' } }
= link_to _('Proceed'), profile_account_path, class: 'gl-button btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' } %ul
= link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'gl-button btn btn-default' - @codes.each do |code|
%li
%span.monospace{ data: { qa_selector: 'code_content' } }= code
.d-flex
= link_to _('Proceed'), profile_account_path, class: 'gl-button btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' }
= link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'gl-button btn btn-default'
- page_title _('Recovery Codes'), _('Two-factor Authentication') - page_title _('Recovery Codes'), _('Two-factor Authentication')
- add_page_specific_style 'page_bundles/profile_two_factor_auth'
%h3.page-title
= _('Two-factor Authentication Recovery codes')
%hr
= render 'codes' = render 'codes'
- page_title _('Two-factor Authentication'), _('Account') - page_title _('Two-factor Authentication'), _('Account')
- add_page_specific_style 'page_bundles/profile_two_factor_auth'
.gl-alert.gl-alert-success.gl-mb-5 - unless Feature.enabled?(:vue_2fa_recovery_codes, current_user)
= _('Congratulations! You have enabled Two-factor Authentication!') .gl-alert.gl-alert-success.gl-mb-5
= _('Congratulations! You have enabled Two-factor Authentication!')
= render 'codes' = render 'codes', show_success_alert: true
...@@ -197,6 +197,7 @@ module Gitlab ...@@ -197,6 +197,7 @@ module Gitlab
config.assets.precompile << "page_bundles/pipelines.css" config.assets.precompile << "page_bundles/pipelines.css"
config.assets.precompile << "page_bundles/pipeline_schedules.css" config.assets.precompile << "page_bundles/pipeline_schedules.css"
config.assets.precompile << "page_bundles/productivity_analytics.css" config.assets.precompile << "page_bundles/productivity_analytics.css"
config.assets.precompile << "page_bundles/profile_two_factor_auth.css"
config.assets.precompile << "page_bundles/terminal.css" config.assets.precompile << "page_bundles/terminal.css"
config.assets.precompile << "page_bundles/todos.css" config.assets.precompile << "page_bundles/todos.css"
config.assets.precompile << "page_bundles/reports.css" config.assets.precompile << "page_bundles/reports.css"
......
---
name: vue_2fa_recovery_codes
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49078
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/290113
milestone: '13.7'
type: development
group: group::access
default_enabled: false
...@@ -7698,6 +7698,9 @@ msgstr "" ...@@ -7698,6 +7698,9 @@ msgstr ""
msgid "Copy branch name" msgid "Copy branch name"
msgstr "" msgstr ""
msgid "Copy codes"
msgstr ""
msgid "Copy command" msgid "Copy command"
msgstr "" msgstr ""
...@@ -20470,6 +20473,9 @@ msgstr "" ...@@ -20470,6 +20473,9 @@ msgstr ""
msgid "Please convert %{linkStart}them to Git%{linkEnd}, and go through the %{linkToImportFlow} again." msgid "Please convert %{linkStart}them to Git%{linkEnd}, and go through the %{linkToImportFlow} again."
msgstr "" msgstr ""
msgid "Please copy, download, or print your recovery codes before proceeding."
msgstr ""
msgid "Please create a password for your new account." msgid "Please create a password for your new account."
msgstr "" msgstr ""
...@@ -20755,6 +20761,9 @@ msgstr "" ...@@ -20755,6 +20761,9 @@ msgstr ""
msgid "Primary" msgid "Primary"
msgstr "" msgstr ""
msgid "Print codes"
msgstr ""
msgid "Prioritize" msgid "Prioritize"
msgstr "" msgstr ""
...@@ -25180,6 +25189,9 @@ msgstr "" ...@@ -25180,6 +25189,9 @@ msgstr ""
msgid "Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account." msgid "Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account."
msgstr "" msgstr ""
msgid "Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{boldStart}will%{boldEnd} lose access to your account."
msgstr ""
msgid "Show all activity" msgid "Show all activity"
msgstr "" msgstr ""
......
...@@ -14,8 +14,9 @@ module QA ...@@ -14,8 +14,9 @@ module QA
element :register_2fa_app_button element :register_2fa_app_button
end end
view 'app/views/profiles/two_factor_auths/_codes.html.haml' do view 'app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue' do
element :proceed_button element :proceed_button
element :copy_button
element :codes_content element :codes_content
element :code_content element :code_content
end end
...@@ -43,7 +44,8 @@ module QA ...@@ -43,7 +44,8 @@ module QA
code_elements.map { |code_content| code_content.text } code_elements.map { |code_content| code_content.text }
end end
def click_proceed_button def click_copy_and_proceed
click_element :copy_button
click_element :proceed_button click_element :proceed_button
end end
end end
......
...@@ -29,6 +29,7 @@ module QA ...@@ -29,6 +29,7 @@ module QA
end end
before do before do
Runtime::Feature.enable('vue_2fa_recovery_codes', user: developer_user)
group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER) group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER)
end end
...@@ -57,6 +58,7 @@ module QA ...@@ -57,6 +58,7 @@ module QA
end end
after do after do
Runtime::Feature.disable('vue_2fa_recovery_codes', user: developer_user)
group.set_require_two_factor_authentication(value: 'false') group.set_require_two_factor_authentication(value: 'false')
group.remove_via_api! group.remove_via_api!
sandbox_group.remove_via_api! sandbox_group.remove_via_api!
...@@ -81,7 +83,7 @@ module QA ...@@ -81,7 +83,7 @@ module QA
recovery_code = two_fa_auth.recovery_codes.sample recovery_code = two_fa_auth.recovery_codes.sample
two_fa_auth.click_proceed_button two_fa_auth.click_copy_and_proceed
recovery_code recovery_code
end end
......
...@@ -16,6 +16,7 @@ module QA ...@@ -16,6 +16,7 @@ module QA
end end
before do before do
Runtime::Feature.enable('vue_2fa_recovery_codes', user: user)
enable_2fa_for_user(user) enable_2fa_for_user(user)
end end
...@@ -46,6 +47,10 @@ module QA ...@@ -46,6 +47,10 @@ module QA
expect(page).to have_text('Invalid two-factor code') expect(page).to have_text('Invalid two-factor code')
end end
after do
Runtime::Feature.disable('vue_2fa_recovery_codes', user: user)
end
def enable_2fa_for_user(user) def enable_2fa_for_user(user)
Flow::Login.while_signed_in(as: user) do Flow::Login.while_signed_in(as: user) do
Page::Main::Menu.perform(&:click_settings_link) Page::Main::Menu.perform(&:click_settings_link)
...@@ -56,7 +61,7 @@ module QA ...@@ -56,7 +61,7 @@ module QA
otp = QA::Support::OTP.new(two_fa_auth.otp_secret_content) otp = QA::Support::OTP.new(two_fa_auth.otp_secret_content)
two_fa_auth.set_pin_code(otp.fresh_otp) two_fa_auth.set_pin_code(otp.fresh_otp)
two_fa_auth.click_register_2fa_app_button two_fa_auth.click_register_2fa_app_button
two_fa_auth.click_proceed_button two_fa_auth.click_copy_and_proceed
end end
end end
end end
......
...@@ -31,6 +31,7 @@ module QA ...@@ -31,6 +31,7 @@ module QA
let(:two_fa_expected_text) { /The group settings for.*require you to enable Two-Factor Authentication for your account.*You need to do this before/ } let(:two_fa_expected_text) { /The group settings for.*require you to enable Two-Factor Authentication for your account.*You need to do this before/ }
before do before do
Runtime::Feature.enable('vue_2fa_recovery_codes', user: developer_user)
group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER) group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER)
end end
...@@ -57,6 +58,7 @@ module QA ...@@ -57,6 +58,7 @@ module QA
end end
after do after do
Runtime::Feature.disable('vue_2fa_recovery_codes', user: developer_user)
group.set_require_two_factor_authentication(value: 'false') group.set_require_two_factor_authentication(value: 'false')
group.remove_via_api! do |resource| group.remove_via_api! do |resource|
resource.api_client = admin_api_client resource.api_client = admin_api_client
...@@ -99,9 +101,9 @@ module QA ...@@ -99,9 +101,9 @@ module QA
two_fa_auth.set_pin_code(@otp.fresh_otp) two_fa_auth.set_pin_code(@otp.fresh_otp)
two_fa_auth.click_register_2fa_app_button two_fa_auth.click_register_2fa_app_button
expect(two_fa_auth).to have_text('Congratulations! You have enabled Two-factor Authentication!') two_fa_auth.click_copy_and_proceed
two_fa_auth.click_proceed_button expect(two_fa_auth).to have_text('Congratulations! You have enabled Two-factor Authentication!')
end end
end end
end end
......
...@@ -742,28 +742,65 @@ RSpec.describe 'Login' do ...@@ -742,28 +742,65 @@ RSpec.describe 'Login' do
end end
context 'when the user did not enable 2FA' do context 'when the user did not enable 2FA' do
it 'asks to set 2FA before asking to accept the terms' do context 'when `vue_2fa_recovery_codes` feature flag is disabled' do
expect(authentication_metrics) before do
.to increment(:user_authenticated_counter) stub_feature_flags(vue_2fa_recovery_codes: false)
end
visit new_user_session_path it 'asks to set 2FA before asking to accept the terms' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
fill_in 'user_login', with: user.email visit new_user_session_path
fill_in 'user_password', with: '12345678'
click_button 'Sign in' fill_in 'user_login', with: user.email
fill_in 'user_password', with: '12345678'
expect_to_be_on_terms_page click_button 'Sign in'
click_button 'Accept terms'
expect_to_be_on_terms_page
click_button 'Accept terms'
expect(current_path).to eq(profile_two_factor_auth_path)
fill_in 'pin_code', with: user.reload.current_otp
expect(current_path).to eq(profile_two_factor_auth_path) click_button 'Register with two-factor app'
fill_in 'pin_code', with: user.reload.current_otp expect(page).to have_content('Congratulations! You have enabled Two-factor Authentication!')
click_button 'Register with two-factor app' click_link 'Proceed'
click_link 'Proceed'
expect(current_path).to eq(profile_account_path) expect(current_path).to eq(profile_account_path)
end
end
context 'when `vue_2fa_recovery_codes` feature flag is enabled' do
it 'asks to set 2FA before asking to accept the terms', :js do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
visit new_user_session_path
fill_in 'user_login', with: user.email
fill_in 'user_password', with: '12345678'
click_button 'Sign in'
expect_to_be_on_terms_page
click_button 'Accept terms'
expect(current_path).to eq(profile_two_factor_auth_path)
fill_in 'pin_code', with: user.reload.current_otp
click_button 'Register with two-factor app'
click_button 'Copy codes'
click_link 'Proceed'
expect(current_path).to eq(profile_account_path)
expect(page).to have_content('Congratulations! You have enabled Two-factor Authentication!')
end
end end
end end
......
import { mount } from '@vue/test-utils';
import { GlAlert, GlButton } from '@gitlab/ui';
import { nextTick } from 'vue';
import { within } from '@testing-library/dom';
import { extendedWrapper } from 'jest/helpers/vue_test_utils_helper';
import RecoveryCodes, {
i18n,
} from '~/authentication/two_factor_auth/components/recovery_codes.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import {
RECOVERY_CODE_DOWNLOAD_FILENAME,
COPY_KEYBOARD_SHORTCUT,
} from '~/authentication/two_factor_auth/constants';
import { codes, codesFormattedString, codesDownloadHref, profileAccountPath } from '../mock_data';
describe('RecoveryCodes', () => {
let wrapper;
const createComponent = (options = {}) => {
wrapper = extendedWrapper(
mount(RecoveryCodes, {
propsData: {
codes,
profileAccountPath,
...(options?.propsData || {}),
},
...options,
}),
);
};
const queryByText = (text, options) => within(wrapper.element).queryByText(text, options);
const findAlert = () => wrapper.find(GlAlert);
const findRecoveryCodes = () => wrapper.findByTestId('recovery-codes');
const findCopyButton = () => wrapper.find(ClipboardButton);
const findButtonByText = text =>
wrapper.findAll(GlButton).wrappers.find(buttonWrapper => buttonWrapper.text() === text);
const findDownloadButton = () => findButtonByText('Download codes');
const findPrintButton = () => findButtonByText('Print codes');
const findProceedButton = () => findButtonByText('Proceed');
const manuallyCopyRecoveryCodes = () =>
wrapper.vm.$options.mousetrap.trigger(COPY_KEYBOARD_SHORTCUT);
beforeEach(() => {
createComponent();
});
it('renders title', () => {
expect(queryByText(i18n.pageTitle)).toEqual(expect.any(HTMLElement));
});
it('renders alert', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(i18n.alertTitle);
});
it('renders codes', () => {
const recoveryCodes = findRecoveryCodes().text();
codes.forEach(code => {
expect(recoveryCodes).toContain(code);
});
});
describe('"Proceed" button', () => {
it('renders button as disabled', () => {
const proceedButton = findProceedButton();
expect(proceedButton.exists()).toBe(true);
expect(proceedButton.props('disabled')).toBe(true);
expect(proceedButton.attributes()).toMatchObject({
title: i18n.proceedButton,
href: profileAccountPath,
});
});
});
describe('"Copy codes" button', () => {
it('renders button', () => {
const copyButton = findCopyButton();
expect(copyButton.exists()).toBe(true);
expect(copyButton.text()).toBe(i18n.copyButton);
expect(copyButton.props()).toMatchObject({
title: i18n.copyButton,
text: codesFormattedString,
});
});
describe('when button is clicked', () => {
it('enables "Proceed" button', async () => {
findCopyButton().trigger('click');
await nextTick();
expect(findProceedButton().props('disabled')).toBe(false);
});
});
});
describe('"Download codes" button', () => {
it('renders button', () => {
const downloadButton = findDownloadButton();
expect(downloadButton.exists()).toBe(true);
expect(downloadButton.attributes()).toMatchObject({
title: i18n.downloadButton,
download: RECOVERY_CODE_DOWNLOAD_FILENAME,
href: codesDownloadHref,
});
});
describe('when button is clicked', () => {
it('enables "Proceed" button', async () => {
const downloadButton = findDownloadButton();
// jsdom does not support navigating.
// Since we are clicking an anchor tag there is no way to mock this
// and we are forced to instead remove the `href` attribute.
// More info: https://github.com/jsdom/jsdom/issues/2112#issuecomment-663672587
downloadButton.element.removeAttribute('href');
downloadButton.trigger('click');
await nextTick();
expect(findProceedButton().props('disabled')).toBe(false);
});
});
});
describe('"Print codes" button', () => {
it('renders button', () => {
const printButton = findPrintButton();
expect(printButton.exists()).toBe(true);
expect(printButton.attributes()).toMatchObject({
title: i18n.printButton,
});
});
describe('when button is clicked', () => {
it('enables "Proceed" button and opens print dialog', async () => {
window.print = jest.fn();
findPrintButton().trigger('click');
await nextTick();
expect(findProceedButton().props('disabled')).toBe(false);
expect(window.print).toHaveBeenCalledWith();
});
});
});
describe('when codes are manually copied', () => {
describe('when selected text is the recovery codes', () => {
beforeEach(() => {
jest.spyOn(window, 'getSelection').mockImplementation(() => ({
toString: jest.fn(() => codesFormattedString),
}));
});
it('enables "Proceed" button', async () => {
manuallyCopyRecoveryCodes();
await nextTick();
expect(findProceedButton().props('disabled')).toBe(false);
});
});
describe('when selected text includes the recovery codes', () => {
beforeEach(() => {
jest.spyOn(window, 'getSelection').mockImplementation(() => ({
toString: jest.fn(() => `foo bar ${codesFormattedString}`),
}));
});
it('enables "Proceed" button', async () => {
manuallyCopyRecoveryCodes();
await nextTick();
expect(findProceedButton().props('disabled')).toBe(false);
});
});
describe('when selected text does not include the recovery codes', () => {
beforeEach(() => {
jest.spyOn(window, 'getSelection').mockImplementation(() => ({
toString: jest.fn(() => 'foo bar'),
}));
});
it('keeps "Proceed" button disabled', async () => {
manuallyCopyRecoveryCodes();
await nextTick();
expect(findProceedButton().props('disabled')).toBe(true);
});
});
});
});
import { createWrapper } from '@vue/test-utils';
import { getByTestId, fireEvent } from '@testing-library/dom';
import * as urlUtils from '~/lib/utils/url_utility';
import { initRecoveryCodes, initClose2faSuccessMessage } from '~/authentication/two_factor_auth';
import RecoveryCodes from '~/authentication/two_factor_auth/components/recovery_codes.vue';
import { codesJsonString, codes, profileAccountPath } from './mock_data';
describe('initRecoveryCodes', () => {
let el;
let wrapper;
const findRecoveryCodesComponent = () => wrapper.find(RecoveryCodes);
beforeEach(() => {
el = document.createElement('div');
el.setAttribute('class', 'js-2fa-recovery-codes');
el.setAttribute('data-codes', codesJsonString);
el.setAttribute('data-profile-account-path', profileAccountPath);
document.body.appendChild(el);
wrapper = createWrapper(initRecoveryCodes());
});
afterEach(() => {
document.body.innerHTML = '';
});
it('parses `data-codes` and passes to `RecoveryCodes` as `codes` prop', () => {
expect(findRecoveryCodesComponent().props('codes')).toEqual(codes);
});
it('parses `data-profile-account-path` and passes to `RecoveryCodes` as `profileAccountPath` prop', () => {
expect(findRecoveryCodesComponent().props('profileAccountPath')).toEqual(profileAccountPath);
});
});
describe('initClose2faSuccessMessage', () => {
beforeEach(() => {
document.body.innerHTML = `
<button
data-testid="close-2fa-enabled-success-alert"
class="js-close-2fa-enabled-success-alert"
>
</button>
`;
initClose2faSuccessMessage();
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('when alert is closed', () => {
beforeEach(() => {
delete window.location;
window.location = new URL(
'https://localhost/-/profile/account?two_factor_auth_enabled_successfully=true',
);
document.title = 'foo bar';
urlUtils.updateHistory = jest.fn();
});
afterEach(() => {
document.title = '';
});
it('removes `two_factor_auth_enabled_successfully` query param', () => {
fireEvent.click(getByTestId(document.body, 'close-2fa-enabled-success-alert'));
expect(urlUtils.updateHistory).toHaveBeenCalledWith({
url: 'https://localhost/-/profile/account',
title: 'foo bar',
replace: true,
});
});
});
});
export const codes = [
'e8471c403a6a84c0',
'b1b92de21c68f08e',
'd7689f332cd8cd73',
'05b706accfa95cfa',
'b0a2b45ea956c1d2',
'599dc672d18d5161',
'e14e9f4adf4b8bf2',
'1013007a75efeeec',
'26bd057c4c696a4f',
'1c46fba5a4275ef4',
];
export const codesJsonString =
'["e8471c403a6a84c0","b1b92de21c68f08e","d7689f332cd8cd73","05b706accfa95cfa","b0a2b45ea956c1d2","599dc672d18d5161","e14e9f4adf4b8bf2","1013007a75efeeec","26bd057c4c696a4f","1c46fba5a4275ef4"]';
export const codesFormattedString = `e8471c403a6a84c0
b1b92de21c68f08e
d7689f332cd8cd73
05b706accfa95cfa
b0a2b45ea956c1d2
599dc672d18d5161
e14e9f4adf4b8bf2
1013007a75efeeec
26bd057c4c696a4f
1c46fba5a4275ef4`;
export const codesDownloadHref =
'data:text/plain;charset=utf-8,e8471c403a6a84c0%0Ab1b92de21c68f08e%0Ad7689f332cd8cd73%0A05b706accfa95cfa%0Ab0a2b45ea956c1d2%0A599dc672d18d5161%0Ae14e9f4adf4b8bf2%0A1013007a75efeeec%0A26bd057c4c696a4f%0A1c46fba5a4275ef4';
export const profileAccountPath = '/-/profile/account';
import { shallowMount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
describe('clipboard button', () => { describe('clipboard button', () => {
let wrapper; let wrapper;
const createWrapper = propsData => { const createWrapper = (propsData, options = {}) => {
wrapper = shallowMount(ClipboardButton, { wrapper = mount(ClipboardButton, {
propsData, propsData,
...options,
}); });
}; };
const findButton = () => wrapper.find(GlButton);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
...@@ -26,7 +29,7 @@ describe('clipboard button', () => { ...@@ -26,7 +29,7 @@ describe('clipboard button', () => {
}); });
it('renders a button for clipboard', () => { it('renders a button for clipboard', () => {
expect(wrapper.find(GlButton).exists()).toBe(true); expect(findButton().exists()).toBe(true);
expect(wrapper.attributes('data-clipboard-text')).toBe('copy me'); expect(wrapper.attributes('data-clipboard-text')).toBe('copy me');
}); });
...@@ -53,4 +56,35 @@ describe('clipboard button', () => { ...@@ -53,4 +56,35 @@ describe('clipboard button', () => {
); );
}); });
}); });
it('renders default slot as button text', () => {
createWrapper(
{
text: 'copy me',
title: 'Copy this value',
},
{
slots: {
default: 'Foo bar',
},
},
);
expect(findButton().text()).toBe('Foo bar');
});
it('re-emits button events', () => {
const onClick = jest.fn();
createWrapper(
{
text: 'copy me',
title: 'Copy this value',
},
{ listeners: { click: onClick } },
);
findButton().trigger('click');
expect(onClick).toHaveBeenCalled();
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment