summaryrefslogtreecommitdiff
path: root/spec/frontend/authentication/webauthn
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/authentication/webauthn')
-rw-r--r--spec/frontend/authentication/webauthn/authenticate_spec.js5
-rw-r--r--spec/frontend/authentication/webauthn/components/registration_spec.js255
-rw-r--r--spec/frontend/authentication/webauthn/error_spec.js13
-rw-r--r--spec/frontend/authentication/webauthn/register_spec.js5
-rw-r--r--spec/frontend/authentication/webauthn/util_spec.js31
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);
+ },
+ );
+ });
});