diff options
Diffstat (limited to 'spec/frontend/authentication')
3 files changed, 353 insertions, 0 deletions
diff --git a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js new file mode 100644 index 00000000000..025e605b920 --- /dev/null +++ b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js @@ -0,0 +1,242 @@ +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 'helpers/vue_test_utils_helper'; +import Tracking from '~/tracking'; +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(() => { + jest.spyOn(Tracking, 'event'); + 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, + }); + }); + + it('fires Snowplow event', () => { + expect(findProceedButton().attributes()).toMatchObject({ + 'data-track-event': 'click_button', + 'data-track-label': '2fa_recovery_codes_proceed_button', + }); + }); + }); + + 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', () => { + beforeEach(async () => { + findCopyButton().trigger('click'); + + await nextTick(); + }); + + it('enables "Proceed" button', () => { + expect(findProceedButton().props('disabled')).toBe(false); + }); + + it('fires Snowplow event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: '2fa_recovery_codes_copy_button', + }); + }); + }); + }); + + 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', () => { + beforeEach(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(); + }); + + it('enables "Proceed" button', () => { + expect(findProceedButton().props('disabled')).toBe(false); + }); + + it('fires Snowplow event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: '2fa_recovery_codes_download_button', + }); + }); + }); + }); + + 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', () => { + beforeEach(async () => { + window.print = jest.fn(); + + findPrintButton().trigger('click'); + + await nextTick(); + }); + + it('enables "Proceed" button and opens print dialog', () => { + expect(findProceedButton().props('disabled')).toBe(false); + expect(window.print).toHaveBeenCalled(); + }); + + it('fires Snowplow event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', { + label: '2fa_recovery_codes_print_button', + }); + }); + }); + }); + + describe('when codes are manually copied', () => { + describe('when selected text is the recovery codes', () => { + beforeEach(async () => { + jest.spyOn(window, 'getSelection').mockImplementation(() => ({ + toString: jest.fn(() => codesFormattedString), + })); + + manuallyCopyRecoveryCodes(); + + await nextTick(); + }); + + it('enables "Proceed" button', () => { + expect(findProceedButton().props('disabled')).toBe(false); + }); + + it('fires Snowplow event', () => { + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'copy_keyboard_shortcut', { + label: '2fa_recovery_codes_manual_copy', + }); + }); + }); + + 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); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/index_spec.js b/spec/frontend/authentication/two_factor_auth/index_spec.js new file mode 100644 index 00000000000..b181170b0a1 --- /dev/null +++ b/spec/frontend/authentication/two_factor_auth/index_spec.js @@ -0,0 +1,80 @@ +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, + }); + }); + }); +}); diff --git a/spec/frontend/authentication/two_factor_auth/mock_data.js b/spec/frontend/authentication/two_factor_auth/mock_data.js new file mode 100644 index 00000000000..7b2a1764abd --- /dev/null +++ b/spec/frontend/authentication/two_factor_auth/mock_data.js @@ -0,0 +1,31 @@ +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'; |