From 0933cf8b02ffa3974f425a58765e700f0acef929 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 16 Jun 2020 14:07:59 +1000 Subject: [PATCH] Added multi device protocol tests --- js/modules/data.js | 2 +- ts/receiver/receiver.ts | 8 +- ts/session/protocols/MultiDeviceProtocol.ts | 13 +- ts/session/utils/Buffer.ts | 18 ++ ts/session/utils/index.ts | 3 +- .../protocols/MultiDeviceProtocol_test.ts | 290 ++++++++++++++++++ ts/test/test-utils/testUtils.ts | 7 +- 7 files changed, 330 insertions(+), 11 deletions(-) create mode 100644 ts/session/utils/Buffer.ts create mode 100644 ts/test/session/protocols/MultiDeviceProtocol_test.ts diff --git a/js/modules/data.js b/js/modules/data.js index a71e0a566..84bb00e53 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -627,7 +627,7 @@ async function createOrUpdatePairingAuthorisation(data) { } async function getPairingAuthorisationsFor(pubKey) { - const authorisations = channels.getPairingAuthorisationsFor(pubKey); + const authorisations = await channels.getPairingAuthorisationsFor(pubKey); return authorisations.map(authorisation => ({ ...authorisation, diff --git a/ts/receiver/receiver.ts b/ts/receiver/receiver.ts index ef85abaf3..3006d9057 100644 --- a/ts/receiver/receiver.ts +++ b/ts/receiver/receiver.ts @@ -17,6 +17,7 @@ import { toNumber } from 'lodash'; import { DataMessage } from '../session/messages/outgoing'; import { MultiDeviceProtocol } from '../session/protocols'; import { PubKey } from '../session/types'; +import { BufferUtils } from '../session/utils'; export { handleEndSession, handleMediumGroupUpdate }; @@ -270,8 +271,11 @@ async function handleSecondaryDeviceFriendRequest(pubKey: string) { await MultiDeviceProtocol.savePairingAuthorisation({ primaryDevicePubKey: authorisation.primaryDevicePubKey, secondaryDevicePubKey: authorisation.secondaryDevicePubKey, - requestSignature: Buffer.from(authorisation.requestSignature).buffer, - grantSignature: Buffer.from(authorisation.grantSignature).buffer, + requestSignature: BufferUtils.base64toUint8Array( + authorisation.requestSignature + ).buffer, + grantSignature: BufferUtils.base64toUint8Array(authorisation.grantSignature) + .buffer, }); return true; diff --git a/ts/session/protocols/MultiDeviceProtocol.ts b/ts/session/protocols/MultiDeviceProtocol.ts index 602da3a1c..ab28ad5b2 100644 --- a/ts/session/protocols/MultiDeviceProtocol.ts +++ b/ts/session/protocols/MultiDeviceProtocol.ts @@ -7,6 +7,7 @@ import { } from '../../../js/modules/data'; import { PrimaryPubKey, PubKey, SecondaryPubKey } from '../types'; import { UserUtil } from '../../util'; +import { BufferUtils } from '../utils'; /* The reason we're exporing a class here instead of just exporting the functions directly is for the sake of testing. @@ -34,14 +35,14 @@ export class MultiDeviceProtocol { } // We always prefer our local pairing over the one on the server - const ourDevices = await this.getAllDevices(ourKey); - if (ourDevices.some(d => d.key === device.key)) { + const isOurDevice = await this.isOurDevice(device); + if (isOurDevice) { return; } // Only fetch if we hit the refresh delay const lastFetchTime = this.lastFetch[device.key]; - if (lastFetchTime && lastFetchTime + this.refreshDelay < Date.now()) { + if (lastFetchTime && lastFetchTime + this.refreshDelay > Date.now()) { return; } @@ -49,7 +50,6 @@ export class MultiDeviceProtocol { try { const authorisations = await this.fetchPairingAuthorisations(device); - // TODO: validate? await Promise.all(authorisations.map(this.savePairingAuthorisation)); } catch (e) { // Something went wrong, let it re-try another time @@ -93,8 +93,9 @@ export class MultiDeviceProtocol { }) => ({ primaryDevicePubKey, secondaryDevicePubKey, - requestSignature: Buffer.from(requestSignature, 'base64').buffer, - grantSignature: Buffer.from(grantSignature, 'base64').buffer, + requestSignature: BufferUtils.base64toUint8Array(requestSignature) + .buffer, + grantSignature: BufferUtils.base64toUint8Array(grantSignature).buffer, }) ); } diff --git a/ts/session/utils/Buffer.ts b/ts/session/utils/Buffer.ts new file mode 100644 index 000000000..d470c613b --- /dev/null +++ b/ts/session/utils/Buffer.ts @@ -0,0 +1,18 @@ +/** + * Convert base64 string into a Uint8Array. + * + * The reason for this function is to avoid a very weird issue when converting to and from base64. + * ``` + * const base64 = ; + * const arrayBuffer = Buffer.from(base64, 'base64').buffer; + * const reconstructedBase64 = Buffer.from(arrayBuffer).toString('base64'); + * expect(base64 === reconstructedBase64) // This returns false!! + * ``` + * + * I have no idea why that doesn't work but a work around is to wrap the original base64 buffer in a Uin8Array before calling `buffer` on it. + * + * @param base64 The base 64 string. + */ +export function base64toUint8Array(base64: string): Uint8Array { + return new Uint8Array(Buffer.from(base64, 'base64')); +} diff --git a/ts/session/utils/index.ts b/ts/session/utils/index.ts index aa99cb92f..653819523 100644 --- a/ts/session/utils/index.ts +++ b/ts/session/utils/index.ts @@ -1,8 +1,9 @@ import * as MessageUtils from './Messages'; import * as GroupUtils from './Groups'; import * as SyncMessageUtils from './SyncMessageUtils'; +import * as BufferUtils from './Buffer'; export * from './TypedEmitter'; export * from './JobQueue'; -export { MessageUtils, SyncMessageUtils, GroupUtils }; +export { MessageUtils, SyncMessageUtils, GroupUtils, BufferUtils }; diff --git a/ts/test/session/protocols/MultiDeviceProtocol_test.ts b/ts/test/session/protocols/MultiDeviceProtocol_test.ts new file mode 100644 index 000000000..b92d0305a --- /dev/null +++ b/ts/test/session/protocols/MultiDeviceProtocol_test.ts @@ -0,0 +1,290 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { TestUtils, timeout } from '../../test-utils'; +import { PairingAuthorisation } from '../../../../js/modules/data'; +import { MultiDeviceProtocol } from '../../../session/protocols'; +import { PubKey } from '../../../session/types'; +import { UserUtil } from '../../../util'; + +function generateFakeAuthorisations( + primary: PubKey, + otherDevices: Array +): Array { + return otherDevices.map( + device => + ({ + primaryDevicePubKey: primary.key, + secondaryDevicePubKey: device.key, + requestSignature: new Uint8Array(0), + grantSignature: new Uint8Array(1), + } as PairingAuthorisation) + ); +} + +describe('MultiDeviceProtocol', () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => { + TestUtils.restoreStubs(); + sandbox.restore(); + }); + + describe('getPairingAuthorisations', () => { + let fetchPairingStub: sinon.SinonStub<[PubKey], Promise>; + beforeEach(() => { + fetchPairingStub = sandbox + .stub(MultiDeviceProtocol, 'fetchPairingAuthorisationsIfNeeded') + .resolves(); + }); + + it('should fetch pairing authorisations before getting authorisations from the database', async () => { + const dataStub = TestUtils.stubData( + 'getPairingAuthorisationsFor' + ).resolves([]); + await MultiDeviceProtocol.getPairingAuthorisations( + TestUtils.generateFakePubkey() + ); + expect(fetchPairingStub.called).to.equal(true, 'Pairing is not fetched.'); + expect(fetchPairingStub.calledBefore(dataStub)).to.equal( + true, + 'Database result was fetched before network result' + ); + }); + + it('should return the authorisations from the database', async () => { + const device1 = TestUtils.generateFakePubkey(); + const device2 = TestUtils.generateFakePubkey(); + const pairing: PairingAuthorisation = { + primaryDevicePubKey: device1.key, + secondaryDevicePubKey: device2.key, + requestSignature: new Uint8Array(1), + grantSignature: new Uint8Array(2), + }; + TestUtils.stubData('getPairingAuthorisationsFor').resolves([pairing]); + const a1 = await MultiDeviceProtocol.getPairingAuthorisations(device1); + expect(a1).to.deep.equal([pairing]); + + const a2 = await MultiDeviceProtocol.getPairingAuthorisations(device2); + expect(a2).to.deep.equal([pairing]); + }); + }); + + describe('fetchPairingAuthorisations', () => { + it('should throw if lokiFileServerAPI does not exist', async () => { + TestUtils.stubWindow('lokiFileServerAPI', undefined); + expect( + MultiDeviceProtocol.fetchPairingAuthorisations( + TestUtils.generateFakePubkey() + ) + ).to.be.rejectedWith('lokiFileServerAPI is not initialised.'); + }); + + it('should return the authorisations', async () => { + const networkAuth = { + primaryDevicePubKey: + '05caa6310a490415df45f8f4ad1b3655ad7a11e722257887a30cf71601d679720b', + secondaryDevicePubKey: + '051296b9588641eea268d60ad6636eecb53a95150e91c0531a00203e01a2c16a39', + requestSignature: + '+knEdlenTV+MooRqlFsZRPWW8s9pcjKwB40fY5o0GJmAi2RPZtaVGRTqgApTIn2zPBTE4GQlmPD7uxcczHDjAg==', + grantSignature: + 'eKzcOWMEVetybkuiVK2u18B9en5pywohn2Hn25/VOVTMrIsKSCW4xXpqwipfqvgvi62WtUt6SA9bCEB5Ngcyiw==', + }; + + const stub = sinon.stub().resolves({ + isPrimary: false, + authorisations: [networkAuth], + }); + TestUtils.stubWindow('lokiFileServerAPI', { + getUserDeviceMapping: stub, + }); + + const authorisations = await MultiDeviceProtocol.fetchPairingAuthorisations( + TestUtils.generateFakePubkey() + ); + expect(authorisations.length).to.equal(1); + + const { + primaryDevicePubKey, + secondaryDevicePubKey, + requestSignature, + grantSignature, + } = authorisations[0]; + expect(primaryDevicePubKey).to.equal(networkAuth.primaryDevicePubKey); + expect(secondaryDevicePubKey).to.equal(networkAuth.secondaryDevicePubKey); + expect(Buffer.from(requestSignature).toString('base64')).to.equal( + networkAuth.requestSignature + ); + expect(grantSignature).to.not.equal( + undefined, + 'Grant signature should not be undefined.' + ); + // tslint:disable-next-line: no-non-null-assertion + expect(Buffer.from(grantSignature!).toString('base64')).to.equal( + networkAuth.grantSignature + ); + }); + }); + + describe('fetchPairingAuthorisationIfNeeded', () => { + let fetchPairingAuthorisationStub: sinon.SinonStub< + [PubKey], + Promise> + >; + let currentDevice: PubKey; + let device: PubKey; + beforeEach(() => { + MultiDeviceProtocol.resetFetchCache(); + + fetchPairingAuthorisationStub = sandbox + .stub(MultiDeviceProtocol, 'fetchPairingAuthorisations') + .resolves([]); + currentDevice = TestUtils.generateFakePubkey(); + device = TestUtils.generateFakePubkey(); + sandbox + .stub(UserUtil, 'getCurrentDevicePubKey') + .resolves(currentDevice.key); + }); + + it('should not fetch authorisations for our devices', async () => { + const otherDevices = TestUtils.generateFakePubKeys(2); + const authorisations = generateFakeAuthorisations( + currentDevice, + otherDevices + ); + sandbox + .stub(MultiDeviceProtocol, 'getPairingAuthorisations') + .resolves(authorisations); + + for (const ourDevice of [currentDevice, ...otherDevices]) { + // Ensure cache is not getting in our way + MultiDeviceProtocol.resetFetchCache(); + + await MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(ourDevice); + expect(fetchPairingAuthorisationStub.called).to.equal( + false, + 'Pairing should not be fetched from the server' + ); + } + }); + + it('should fetch if it has not fetched before', async () => { + await MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + expect(fetchPairingAuthorisationStub.calledWith(device)).to.equal( + true, + 'Device does not match' + ); + expect(fetchPairingAuthorisationStub.called).to.equal( + true, + 'Pairing should be fetched from the server' + ); + }); + + it('should not fetch if the refresh delay has not been met', async () => { + await MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + await timeout(100); + await MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + expect(fetchPairingAuthorisationStub.callCount).to.equal( + 1, + 'Pairing should only be fetched once every refresh delay' + ); + }); + + it('should fetch again if time since last fetch is more than refresh delay', async () => { + const clock = sandbox.useFakeTimers(); + await MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + clock.tick(MultiDeviceProtocol.refreshDelay + 10); + await MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + expect(fetchPairingAuthorisationStub.callCount).to.equal(2); + }); + + it('should fetch again if something went wrong while fetching', async () => { + fetchPairingAuthorisationStub.throws(new Error('42')); + await MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + await timeout(100); + await MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + expect(fetchPairingAuthorisationStub.callCount).to.equal(2); + }); + + it('should fetch only once if called rapidly', async () => { + fetchPairingAuthorisationStub.callsFake(async () => { + await timeout(200); + return []; + }); + + void MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + await timeout(10); + void MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + await timeout(200); + expect(fetchPairingAuthorisationStub.callCount).to.equal(1); + }); + + it('should save the fetched authorisations', async () => { + const saveStub = sandbox + .stub(MultiDeviceProtocol, 'savePairingAuthorisation') + .resolves(); + const authorisations = generateFakeAuthorisations( + device, + TestUtils.generateFakePubKeys(3) + ); + fetchPairingAuthorisationStub.resolves(authorisations); + await MultiDeviceProtocol.fetchPairingAuthorisationsIfNeeded(device); + expect(saveStub.callCount).to.equal(authorisations.length); + }); + }); + + describe('getAllDevices', () => { + it('should return all devices', async () => { + const primary = TestUtils.generateFakePubkey(); + const otherDevices = TestUtils.generateFakePubKeys(2); + const authorisations = generateFakeAuthorisations(primary, otherDevices); + sandbox + .stub(MultiDeviceProtocol, 'getPairingAuthorisations') + .resolves(authorisations); + + const devices = [primary, ...otherDevices]; + for (const device of devices) { + const allDevices = await MultiDeviceProtocol.getAllDevices(device); + const allDevicePubKeys = allDevices.map(p => p.key); + expect(allDevicePubKeys).to.have.same.members(devices.map(d => d.key)); + } + }); + }); + + describe('getPrimaryDevice', () => { + it('should return the primary device', async () => { + const primary = TestUtils.generateFakePubkey(); + const otherDevices = TestUtils.generateFakePubKeys(2); + const authorisations = generateFakeAuthorisations(primary, otherDevices); + sandbox + .stub(MultiDeviceProtocol, 'getPairingAuthorisations') + .resolves(authorisations); + + const devices = [primary, ...otherDevices]; + for (const device of devices) { + const actual = await MultiDeviceProtocol.getPrimaryDevice(device); + expect(actual.key).to.equal(primary.key); + } + }); + }); + + describe('getSecondaryDevices', () => { + it('should return the secondary devices', async () => { + const primary = TestUtils.generateFakePubkey(); + const otherDevices = TestUtils.generateFakePubKeys(2); + const authorisations = generateFakeAuthorisations(primary, otherDevices); + sandbox + .stub(MultiDeviceProtocol, 'getPairingAuthorisations') + .resolves(authorisations); + + const devices = [primary, ...otherDevices]; + for (const device of devices) { + const secondaryDevices = await MultiDeviceProtocol.getSecondaryDevices( + device + ); + const pubKeys = secondaryDevices.map(p => p.key); + expect(pubKeys).to.have.same.members(otherDevices.map(d => d.key)); + } + }); + }); +}); diff --git a/ts/test/test-utils/testUtils.ts b/ts/test/test-utils/testUtils.ts index be8f3113d..e6ed7db56 100644 --- a/ts/test/test-utils/testUtils.ts +++ b/ts/test/test-utils/testUtils.ts @@ -22,7 +22,7 @@ type DataFunction = typeof DataShape; * Note: This uses a custom sandbox. * Please call `restoreStubs()` or `stub.restore()` to restore original functionality. */ -export function stubData(fn: keyof DataFunction): sinon.SinonStub { +export function stubData(fn: K): sinon.SinonStub { return sandbox.stub(Data, fn); } @@ -73,6 +73,11 @@ export function generateFakePubkey(): PubKey { return new PubKey(pubkeyString); } +export function generateFakePubKeys(amount: number): Array { + // tslint:disable-next-line: no-unnecessary-callback-wrapper + return new Array(amount).fill(0).map(() => generateFakePubkey()); +} + export function generateChatMessage(): ChatMessage { return new ChatMessage({ body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',