Commit 3da3dab0 authored by Peter Hegman's avatar Peter Hegman Committed by Mark Florian

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

In an effort to encourage users to save recovery codes. Prevent users
from proceeding until they have copied, downloaded, or printed their
2FA recovery codes
parent 8662326a
......@@ -13,11 +13,17 @@ export const mount2faAuthentication = () => {
};
export const mount2faRegistration = () => {
const el = $('#js-register-token-2fa');
if (!el.length) {
return;
}
if (gon.webauthn) {
const webauthnRegister = new WebAuthnRegister($('#js-register-token-2fa'), gon.webauthn);
const webauthnRegister = new WebAuthnRegister(el, gon.webauthn);
webauthnRegister.start();
} else {
const u2fRegister = new U2FRegister($('#js-register-token-2fa'), gon.u2f);
const u2fRegister = new U2FRegister(el, gon.u2f);
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 { initClose2faSuccessMessage } from '~/authentication/two_factor_auth';
document.addEventListener('DOMContentLoaded', initProfileAccount);
initClose2faSuccessMessage();
import { parseBoolean } from '~/lib/utils/common_utils';
import { mount2faRegistration } from '~/authentication/mount_2fa';
import { initRecoveryCodes } from '~/authentication/two_factor_auth';
document.addEventListener('DOMContentLoaded', () => {
const twoFactorNode = document.querySelector('.js-two-factor-auth');
const skippable = parseBoolean(twoFactorNode.dataset.twoFactorSkippable);
const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSkippable) : false;
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>`;
......@@ -13,3 +14,5 @@ document.addEventListener('DOMContentLoaded', () => {
mount2faRegistration();
});
initRecoveryCodes();
......@@ -84,5 +84,8 @@ export default {
:size="size"
icon="copy-to-clipboard"
:aria-label="__('Copy this value')"
/>
v-on="$listeners"
>
<slot></slot>
</gl-button>
</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 @@
.gl-alert-body
= 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
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
......
%p.slead
- 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
- show_success_alert = local_assigns.fetch(:show_success_alert, nil)
.codes.card{ data: { qa_selector: 'codes_content' } }
%ul
- @codes.each do |code|
%li
%span.monospace{ data: { qa_selector: 'code_content' } }= code
- if Feature.enabled?(:vue_2fa_recovery_codes, current_user)
.js-2fa-recovery-codes{ data: { codes: @codes.to_json, profile_account_path: profile_account_path(two_factor_auth_enabled_successfully: show_success_alert) } }
- else
%p.slead
- 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
= 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'
.codes.card{ data: { qa_selector: 'codes_content' } }
%ul
- @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')
- add_page_specific_style 'page_bundles/profile_two_factor_auth'
%h3.page-title
= _('Two-factor Authentication Recovery codes')
%hr
= render 'codes'
- page_title _('Two-factor Authentication'), _('Account')
- add_page_specific_style 'page_bundles/profile_two_factor_auth'
.gl-alert.gl-alert-success.gl-mb-5
= _('Congratulations! You have enabled Two-factor Authentication!')
- unless Feature.enabled?(:vue_2fa_recovery_codes, current_user)
.gl-alert.gl-alert-success.gl-mb-5
= _('Congratulations! You have enabled Two-factor Authentication!')
= render 'codes'
= render 'codes', show_success_alert: true
......@@ -196,6 +196,7 @@ module Gitlab
config.assets.precompile << "page_bundles/pipelines.css"
config.assets.precompile << "page_bundles/pipeline_schedules.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/todos.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
......@@ -7725,6 +7725,9 @@ msgstr ""
msgid "Copy branch name"
msgstr ""
msgid "Copy codes"
msgstr ""
msgid "Copy command"
msgstr ""
......@@ -20470,6 +20473,9 @@ msgstr ""
msgid "Please convert %{linkStart}them to Git%{linkEnd}, and go through the %{linkToImportFlow} again."
msgstr ""
msgid "Please copy, download, or print your recovery codes before proceeding."
msgstr ""
msgid "Please create a password for your new account."
msgstr ""
......@@ -20755,6 +20761,9 @@ msgstr ""
msgid "Primary"
msgstr ""
msgid "Print codes"
msgstr ""
msgid "Prioritize"
msgstr ""
......@@ -25183,6 +25192,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."
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"
msgstr ""
......
......@@ -14,8 +14,9 @@ module QA
element :register_2fa_app_button
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 :copy_button
element :codes_content
element :code_content
end
......@@ -43,7 +44,8 @@ module QA
code_elements.map { |code_content| code_content.text }
end
def click_proceed_button
def click_copy_and_proceed
click_element :copy_button
click_element :proceed_button
end
end
......
......@@ -29,6 +29,7 @@ module QA
end
before do
Runtime::Feature.enable('vue_2fa_recovery_codes', user: developer_user)
group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER)
end
......@@ -57,6 +58,7 @@ module QA
end
after do
Runtime::Feature.disable('vue_2fa_recovery_codes', user: developer_user)
group.set_require_two_factor_authentication(value: 'false')
group.remove_via_api!
sandbox_group.remove_via_api!
......@@ -81,7 +83,7 @@ module QA
recovery_code = two_fa_auth.recovery_codes.sample
two_fa_auth.click_proceed_button
two_fa_auth.click_copy_and_proceed
recovery_code
end
......
......@@ -16,6 +16,7 @@ module QA
end
before do
Runtime::Feature.enable('vue_2fa_recovery_codes', user: user)
enable_2fa_for_user(user)
end
......@@ -46,6 +47,10 @@ module QA
expect(page).to have_text('Invalid two-factor code')
end
after do
Runtime::Feature.disable('vue_2fa_recovery_codes', user: user)
end
def enable_2fa_for_user(user)
Flow::Login.while_signed_in(as: user) do
Page::Main::Menu.perform(&:click_settings_link)
......@@ -56,7 +61,7 @@ module QA
otp = QA::Support::OTP.new(two_fa_auth.otp_secret_content)
two_fa_auth.set_pin_code(otp.fresh_otp)
two_fa_auth.click_register_2fa_app_button
two_fa_auth.click_proceed_button
two_fa_auth.click_copy_and_proceed
end
end
end
......
......@@ -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/ }
before do
Runtime::Feature.enable('vue_2fa_recovery_codes', user: developer_user)
group.add_member(developer_user, Resource::Members::AccessLevel::DEVELOPER)
end
......@@ -57,6 +58,7 @@ module QA
end
after do
Runtime::Feature.disable('vue_2fa_recovery_codes', user: developer_user)
group.set_require_two_factor_authentication(value: 'false')
group.remove_via_api! do |resource|
resource.api_client = admin_api_client
......@@ -99,9 +101,9 @@ module QA
two_fa_auth.set_pin_code(@otp.fresh_otp)
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
......
......@@ -742,28 +742,65 @@ RSpec.describe 'Login' do
end
context 'when the user did not enable 2FA' do
it 'asks to set 2FA before asking to accept the terms' do
expect(authentication_metrics)
.to increment(:user_authenticated_counter)
context 'when `vue_2fa_recovery_codes` feature flag is disabled' do
before do
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
fill_in 'user_password', with: '12345678'
visit new_user_session_path
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 'Accept terms'
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
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
......
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 ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
describe('clipboard button', () => {
let wrapper;
const createWrapper = propsData => {
wrapper = shallowMount(ClipboardButton, {
const createWrapper = (propsData, options = {}) => {
wrapper = mount(ClipboardButton, {
propsData,
...options,
});
};
const findButton = () => wrapper.find(GlButton);
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -26,7 +29,7 @@ describe('clipboard button', () => {
});
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');
});
......@@ -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