From 6a21915ceee653b6bb81515b238c86207ef28d1f Mon Sep 17 00:00:00 2001 From: Leo Hemsted Date: Tue, 25 May 2021 18:02:38 +0100 Subject: [PATCH] add webauthn authentication js tests notably i had to change `window.location = foo` to `window.location.assign` so that i could have something to spy on with jest. mocking sucks. Otherwise this is pretty similar to the registerSecurityKey.test.js file. --- .../javascripts/authenticateSecurityKey.js | 2 +- .../authenticateSecurityKey.test.js | 232 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 tests/javascripts/authenticateSecurityKey.test.js diff --git a/app/assets/javascripts/authenticateSecurityKey.js b/app/assets/javascripts/authenticateSecurityKey.js index d9f396580..1326c923b 100644 --- a/app/assets/javascripts/authenticateSecurityKey.js +++ b/app/assets/javascripts/authenticateSecurityKey.js @@ -47,7 +47,7 @@ throw Error(response.statusText); }) .then(data => { - window.location = data.redirect_url; + window.location.assign(data.redirect_url); }); }) .catch(error => { diff --git a/tests/javascripts/authenticateSecurityKey.test.js b/tests/javascripts/authenticateSecurityKey.test.js new file mode 100644 index 000000000..90bbf89f3 --- /dev/null +++ b/tests/javascripts/authenticateSecurityKey.test.js @@ -0,0 +1,232 @@ +beforeAll(() => { + window.CBOR = require('../../node_modules/cbor-js/cbor.js') + require('../../app/assets/javascripts/authenticateSecurityKey.js') + + // disable console.error() so we don't see it in test output + // you might need to comment this out to debug some failures + jest.spyOn(console, 'error').mockImplementation(() => { }) + + // ensure window.alert() is implemented to simplify errors + jest.spyOn(window, 'alert').mockImplementation(() => { }) + + // populate missing values to allow consistent jest.spyOn() + window.fetch = () => { } + window.navigator.credentials = { get: () => { } } +}) + +afterAll(() => { + require('./support/teardown.js') + + // restore window attributes to their original undefined state + delete window.fetch + delete window.navigator.credentials +}) + +describe('Authenticate with security key', () => { + let button + + beforeEach(() => { + document.body.innerHTML = ` + + ` + button = document.querySelector('[data-module="authenticate-security-key"]') + window.GOVUK.modules.start() + }) + + test('authenticates a credential and redirects', (done) => { + + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url) => { + // initial fetch of options from the server + // fetch defaults to GET + // options from the server are CBOR-encoded + const webauthnOptions = window.CBOR.encode('someArbitraryOptions') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }) + + jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { + expect(options).toEqual('someArbitraryOptions') + + // fake PublicKeyCredential response from WebAuthn API + // all of the array properties represent Array(Buffer) objects + const credentialsGetResponse = { + response: { + authenticatorData: [2, 2, 2], + signature: [3, 3, 3], + clientDataJSON: [4, 4, 4] + }, + rawId: [1, 1, 1], + type: "public-key", + } + return Promise.resolve(credentialsGetResponse) + }) + + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url, options = {}) => { + // subsequent POST of credential data to server + const decodedData = window.CBOR.decode(options.body) + expect(decodedData.credentialId).toEqual(new Uint8Array([1, 1, 1])) + expect(decodedData.authenticatorData).toEqual(new Uint8Array([2, 2, 2])) + expect(decodedData.signature).toEqual(new Uint8Array([3, 3, 3])) + expect(decodedData.clientDataJSON).toEqual(new Uint8Array([4, 4, 4])) + expect(options.headers['X-CSRFToken']).toBe('abc123') + const loginResponse = window.CBOR.encode({ redirect_url: '/foo' }) + + return Promise.resolve({ + ok: true, arrayBuffer: () => Promise.resolve(loginResponse) + }) + }) + + jest.spyOn(window.location, 'assign').mockImplementation((href) => { + expect(href).toEqual("/foo") + done(); + }) + + // this will make the test fail if the alert is called + jest.spyOn(window, 'alert').mockImplementation((msg) => { + done(msg) + }) + + button.click() + }); + + test.each([ + ['network'], + ['server'], + ])('alerts if fetching WebAuthn fails (%s error)', (errorType, done) => { + jest.spyOn(window, 'fetch').mockImplementation((_url) => { + if (errorType == 'network') { + return Promise.reject('error') + } else { + return Promise.resolve({ ok: false, statusText: 'error' }) + } + }) + + jest.spyOn(window, 'alert').mockImplementation((msg) => { + expect(msg).toEqual('Error during authentication.\n\nerror') + done() + }) + + button.click() + }) + + test('alerts if comms with the authenticator fails', (done) => { + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url) => { + const webauthnOptions = window.CBOR.encode('someArbitraryOptions') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }) + + jest.spyOn(window.navigator.credentials, 'get').mockImplementation(() => { + return Promise.reject(new DOMException('error')) + }) + + jest.spyOn(window, 'alert').mockImplementation((msg) => { + expect(msg).toEqual('Error during authentication.\n\nerror') + done() + }) + + button.click() + }); + + test.each([ + ['network error'], + ['internal server error'], + ])('alerts if POSTing WebAuthn credentials fails (%s)', (errorType, done) => { + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url) => { + const webauthnOptions = window.CBOR.encode('someArbitraryOptions') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }) + + jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { + expect(options).toEqual('someArbitraryOptions') + const credentialsGetResponse = { + response: { + authenticatorData: [2, 2, 2], + signature: [3, 3, 3], + clientDataJSON: [4, 4, 4] + }, + rawId: [1, 1, 1], + type: "public-key", + } + return Promise.resolve(credentialsGetResponse) + }) + + jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { + // subsequent POST of credential data to server + switch (errorType) { + case 'network error': + return Promise.reject('error') + case 'internal server error': + // dont need this becuase we dont cbor return errors + const message = Promise.reject('encoding error') + return Promise.resolve({ ok: false, arrayBuffer: () => message, statusText: 'error' }) + } + }) + + jest.spyOn(window, 'alert').mockImplementation((msg) => { + expect(msg).toEqual('Error during authentication.\n\nerror') + done() + }) + + button.click() + }); + + + test('reloads page if POSTing WebAuthn credentials returns 403', (done) => { + jest.spyOn(window, 'fetch') + .mockImplementationOnce((_url) => { + const webauthnOptions = window.CBOR.encode('someArbitraryOptions') + + return Promise.resolve({ + ok: true, arrayBuffer: () => webauthnOptions + }) + }) + + jest.spyOn(window.navigator.credentials, 'get').mockImplementation((options) => { + expect(options).toEqual('someArbitraryOptions') + const credentialsGetResponse = { + response: { + authenticatorData: [2, 2, 2], + signature: [3, 3, 3], + clientDataJSON: [4, 4, 4] + }, + rawId: [1, 1, 1], + type: "public-key", + } + return Promise.resolve(credentialsGetResponse) + }) + + jest.spyOn(window, 'fetch').mockImplementationOnce((_url) => { + return Promise.resolve( + { + ok: false, + status: 403, + }) + }) + + // assert that reload is called and the page is refreshed + jest.spyOn(window.location, 'reload').mockImplementation(() => { + done(); + }) + + // this will make the test fail if the alert is called + jest.spyOn(window, 'alert').mockImplementation((msg) => { + done(msg) + }) + + button.click() + }); + + +});