diff options
Diffstat (limited to 'spec/frontend/authentication/webauthn')
5 files changed, 298 insertions, 11 deletions
diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js index b1f4e43e56d..b3a634fb072 100644 --- a/spec/frontend/authentication/webauthn/authenticate_spec.js +++ b/spec/frontend/authentication/webauthn/authenticate_spec.js @@ -1,5 +1,6 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlWebauthnAuthenticate from 'test_fixtures/webauthn/authenticate.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import waitForPromises from 'helpers/wait_for_promises'; import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate'; import MockWebAuthnDevice from './mock_webauthn_device'; @@ -35,7 +36,7 @@ describe('WebAuthnAuthenticate', () => { }; beforeEach(() => { - loadHTMLFixture('webauthn/authenticate.html'); + setHTMLFixture(htmlWebauthnAuthenticate); fallbackElement = document.createElement('div'); fallbackElement.classList.add('js-2fa-form'); webAuthnDevice = new MockWebAuthnDevice(); diff --git a/spec/frontend/authentication/webauthn/components/registration_spec.js b/spec/frontend/authentication/webauthn/components/registration_spec.js new file mode 100644 index 00000000000..e4ca1ac8c38 --- /dev/null +++ b/spec/frontend/authentication/webauthn/components/registration_spec.js @@ -0,0 +1,255 @@ +import { nextTick } from 'vue'; +import { GlAlert, GlButton, GlForm, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import Registration from '~/authentication/webauthn/components/registration.vue'; +import { + I18N_BUTTON_REGISTER, + I18N_BUTTON_SETUP, + I18N_BUTTON_TRY_AGAIN, + I18N_ERROR_HTTP, + I18N_ERROR_UNSUPPORTED_BROWSER, + I18N_INFO_TEXT, + I18N_STATUS_SUCCESS, + I18N_STATUS_WAITING, + STATE_ERROR, + STATE_READY, + STATE_SUCCESS, + STATE_UNSUPPORTED, + STATE_WAITING, + WEBAUTHN_REGISTER, +} from '~/authentication/webauthn/constants'; +import * as WebAuthnUtils from '~/authentication/webauthn/util'; +import WebAuthnError from '~/authentication/webauthn/error'; + +const csrfToken = 'mock-csrf-token'; +jest.mock('~/lib/utils/csrf', () => ({ token: csrfToken })); +jest.mock('~/authentication/webauthn/util'); +jest.mock('~/authentication/webauthn/error'); + +describe('Registration', () => { + const initialError = null; + const passwordRequired = true; + const targetPath = '/-/profile/two_factor_auth/create_webauthn'; + let wrapper; + + const createComponent = (provide = {}) => { + wrapper = shallowMountExtended(Registration, { + provide: { initialError, passwordRequired, targetPath, ...provide }, + }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + describe(`when ${STATE_UNSUPPORTED} state`, () => { + it('shows an error if using unsecure scheme (HTTP)', () => { + // `supported` function returns false for HTTP because `navigator.credentials` is undefined. + WebAuthnUtils.supported.mockReturnValue(false); + WebAuthnUtils.isHTTPS.mockReturnValue(false); + createComponent(); + + const alert = wrapper.findComponent(GlAlert); + expect(alert.props('variant')).toBe('danger'); + expect(alert.text()).toBe(I18N_ERROR_HTTP); + }); + + it('shows an error if using unsupported browser', () => { + WebAuthnUtils.supported.mockReturnValue(false); + WebAuthnUtils.isHTTPS.mockReturnValue(true); + createComponent(); + + const alert = wrapper.findComponent(GlAlert); + expect(alert.props('variant')).toBe('danger'); + expect(alert.text()).toBe(I18N_ERROR_UNSUPPORTED_BROWSER); + }); + }); + + describe('when scheme or browser are supported', () => { + const mockCreate = jest.fn(); + + const clickSetupDeviceButton = () => { + findButton().vm.$emit('click'); + return nextTick(); + }; + + const setupDevice = () => { + clickSetupDeviceButton(); + return waitForPromises(); + }; + + beforeEach(() => { + WebAuthnUtils.isHTTPS.mockReturnValue(true); + WebAuthnUtils.supported.mockReturnValue(true); + global.navigator.credentials = { create: mockCreate }; + gon.webauthn = { options: {} }; + }); + + afterEach(() => { + global.navigator.credentials = undefined; + }); + + describe(`when ${STATE_READY} state`, () => { + it('shows button and explanation text', () => { + createComponent(); + + expect(findButton().text()).toBe(I18N_BUTTON_SETUP); + expect(wrapper.text()).toContain(I18N_INFO_TEXT); + }); + }); + + describe(`when ${STATE_WAITING} state`, () => { + it('shows loading icon and message after pressing the button', async () => { + createComponent(); + + await clickSetupDeviceButton(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.text()).toContain(I18N_STATUS_WAITING); + }); + }); + + describe(`when ${STATE_SUCCESS} state`, () => { + const credentials = 1; + + const findCurrentPasswordInput = () => wrapper.findByTestId('current-password-input'); + const findDeviceNameInput = () => wrapper.findByTestId('device-name-input'); + + beforeEach(() => { + mockCreate.mockResolvedValueOnce(true); + WebAuthnUtils.convertCreateResponse.mockReturnValue(credentials); + }); + + describe('registration form', () => { + it('has correct action', async () => { + createComponent(); + + await setupDevice(); + + expect(wrapper.findComponent(GlForm).attributes('action')).toBe(targetPath); + }); + + describe('when password is required', () => { + it('shows device name and password fields', async () => { + createComponent(); + + await setupDevice(); + + expect(wrapper.text()).toContain(I18N_STATUS_SUCCESS); + + // Visible inputs + expect(findCurrentPasswordInput().attributes('name')).toBe('current_password'); + expect(findDeviceNameInput().attributes('name')).toBe('device_registration[name]'); + + // Hidden inputs + expect( + wrapper + .find('input[name="device_registration[device_response]"]') + .attributes('value'), + ).toBe(`${credentials}`); + expect(wrapper.find('input[name=authenticity_token]').attributes('value')).toBe( + csrfToken, + ); + + expect(findButton().text()).toBe(I18N_BUTTON_REGISTER); + }); + + it('enables the register device button when device name and password are filled', async () => { + createComponent(); + + await setupDevice(); + + expect(findButton().props('disabled')).toBe(true); + + // Visible inputs + findCurrentPasswordInput().vm.$emit('input', 'my current password'); + findDeviceNameInput().vm.$emit('input', 'my device name'); + await nextTick(); + + expect(findButton().props('disabled')).toBe(false); + }); + }); + + describe('when password is not required', () => { + it('shows a device name field', async () => { + createComponent({ passwordRequired: false }); + + await setupDevice(); + + expect(wrapper.text()).toContain(I18N_STATUS_SUCCESS); + + // Visible inputs + expect(findCurrentPasswordInput().exists()).toBe(false); + expect(findDeviceNameInput().attributes('name')).toBe('device_registration[name]'); + + // Hidden inputs + expect( + wrapper + .find('input[name="device_registration[device_response]"]') + .attributes('value'), + ).toBe(`${credentials}`); + expect(wrapper.find('input[name=authenticity_token]').attributes('value')).toBe( + csrfToken, + ); + + expect(findButton().text()).toBe(I18N_BUTTON_REGISTER); + }); + + it('enables the register device button when device name is filled', async () => { + createComponent({ passwordRequired: false }); + + await setupDevice(); + + expect(findButton().props('disabled')).toBe(true); + + findDeviceNameInput().vm.$emit('input', 'my device name'); + await nextTick(); + + expect(findButton().props('disabled')).toBe(false); + }); + }); + }); + }); + + describe(`when ${STATE_ERROR} state`, () => { + it('shows an initial error message and a retry button', () => { + const myError = 'my error'; + createComponent({ initialError: myError }); + + const alert = wrapper.findComponent(GlAlert); + expect(alert.props()).toMatchObject({ + variant: 'danger', + secondaryButtonText: I18N_BUTTON_TRY_AGAIN, + }); + expect(alert.text()).toContain(myError); + }); + + it('shows an error message and a retry button', async () => { + createComponent(); + const error = new Error(); + mockCreate.mockRejectedValueOnce(error); + + await setupDevice(); + + expect(WebAuthnError).toHaveBeenCalledWith(error, WEBAUTHN_REGISTER); + expect(wrapper.findComponent(GlAlert).props()).toMatchObject({ + variant: 'danger', + secondaryButtonText: I18N_BUTTON_TRY_AGAIN, + }); + }); + + it('recovers after an error (error to success state)', async () => { + createComponent(); + mockCreate.mockRejectedValueOnce(new Error()).mockResolvedValueOnce(true); + + await setupDevice(); + + expect(wrapper.findComponent(GlAlert).props('variant')).toBe('danger'); + + wrapper.findComponent(GlAlert).vm.$emit('secondaryAction'); + await waitForPromises(); + + expect(wrapper.findComponent(GlAlert).props('variant')).toBe('info'); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/webauthn/error_spec.js b/spec/frontend/authentication/webauthn/error_spec.js index 9b71f77dde2..b979173edc6 100644 --- a/spec/frontend/authentication/webauthn/error_spec.js +++ b/spec/frontend/authentication/webauthn/error_spec.js @@ -1,16 +1,17 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import WebAuthnError from '~/authentication/webauthn/error'; +import { WEBAUTHN_AUTHENTICATE, WEBAUTHN_REGISTER } from '~/authentication/webauthn/constants'; describe('WebAuthnError', () => { it.each([ [ 'NotSupportedError', 'Your device is not compatible with GitLab. Please try another device', - 'authenticate', + WEBAUTHN_AUTHENTICATE, ], - ['InvalidStateError', 'This device has not been registered with us.', 'authenticate'], - ['InvalidStateError', 'This device has already been registered with us.', 'register'], - ['UnknownError', 'There was a problem communicating with your device.', 'register'], + ['InvalidStateError', 'This device has not been registered with us.', WEBAUTHN_AUTHENTICATE], + ['InvalidStateError', 'This device has already been registered with us.', WEBAUTHN_REGISTER], + ['UnknownError', 'There was a problem communicating with your device.', WEBAUTHN_REGISTER], ])('exception %s will have message %s, flow type: %s', (exception, expectedMessage, flowType) => { expect(new WebAuthnError(new DOMException('', exception), flowType).message()).toEqual( expectedMessage, @@ -24,7 +25,7 @@ describe('WebAuthnError', () => { const expectedMessage = 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.'; expect( - new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(), + new WebAuthnError(new DOMException('', 'SecurityError'), WEBAUTHN_AUTHENTICATE).message(), ).toEqual(expectedMessage); }); @@ -33,7 +34,7 @@ describe('WebAuthnError', () => { const expectedMessage = 'There was a problem communicating with your device.'; expect( - new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(), + new WebAuthnError(new DOMException('', 'SecurityError'), WEBAUTHN_AUTHENTICATE).message(), ).toEqual(expectedMessage); }); }); diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js index 773481346fc..5f0691782a7 100644 --- a/spec/frontend/authentication/webauthn/register_spec.js +++ b/spec/frontend/authentication/webauthn/register_spec.js @@ -1,5 +1,6 @@ import $ from 'jquery'; -import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import htmlWebauthnRegister from 'test_fixtures/webauthn/register.html'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { trimText } from 'helpers/text_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -25,7 +26,7 @@ describe('WebAuthnRegister', () => { let component; beforeEach(() => { - loadHTMLFixture('webauthn/register.html'); + setHTMLFixture(htmlWebauthnRegister); webAuthnDevice = new MockWebAuthnDevice(); container = $('#js-register-token-2fa'); component = new WebAuthnRegister(container, { diff --git a/spec/frontend/authentication/webauthn/util_spec.js b/spec/frontend/authentication/webauthn/util_spec.js index bc44b47d0ba..831d1636b8c 100644 --- a/spec/frontend/authentication/webauthn/util_spec.js +++ b/spec/frontend/authentication/webauthn/util_spec.js @@ -1,4 +1,9 @@ -import { base64ToBuffer, bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util'; +import { + base64ToBuffer, + bufferToBase64, + base64ToBase64Url, + supported, +} from '~/authentication/webauthn/util'; const encodedString = 'SGVsbG8gd29ybGQh'; const stringBytes = [72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33]; @@ -31,4 +36,28 @@ describe('Webauthn utils', () => { expect(base64ToBase64Url(argument)).toBe(expectedResult); }); }); + + describe('supported', () => { + afterEach(() => { + global.navigator.credentials = undefined; + window.PublicKeyCredential = undefined; + }); + + it.each` + credentials | PublicKeyCredential | expected + ${undefined} | ${undefined} | ${false} + ${{}} | ${undefined} | ${false} + ${{ create: true }} | ${undefined} | ${false} + ${{ create: true, get: true }} | ${undefined} | ${false} + ${{ create: true, get: true }} | ${true} | ${true} + `( + 'returns $expected when credentials is $credentials and PublicKeyCredential is $PublicKeyCredential', + ({ credentials, PublicKeyCredential, expected }) => { + global.navigator.credentials = credentials; + window.PublicKeyCredential = PublicKeyCredential; + + expect(supported()).toBe(expected); + }, + ); + }); }); |