diff --git a/ts/session/crypto/MessageEncrypter.ts b/ts/session/crypto/MessageEncrypter.ts index 660c2448f..b72e1076e 100644 --- a/ts/session/crypto/MessageEncrypter.ts +++ b/ts/session/crypto/MessageEncrypter.ts @@ -50,7 +50,7 @@ export async function encrypt( } let innerCipherText: CipherTextObject; - if (encryptionType === EncryptionType.SessionReset) { + if (encryptionType === EncryptionType.SessionRequest) { const cipher = new libloki.crypto.FallBackSessionCipher(address); innerCipherText = await cipher.encrypt(plainText.buffer); } else { diff --git a/ts/session/messages/outgoing/content/EndSessionMessage.ts b/ts/session/messages/outgoing/content/EndSessionMessage.ts index f19b111dd..5368dcf85 100644 --- a/ts/session/messages/outgoing/content/EndSessionMessage.ts +++ b/ts/session/messages/outgoing/content/EndSessionMessage.ts @@ -1,7 +1,7 @@ -import { SessionResetMessage } from './SessionResetMessage'; +import { SessionRequestMessage } from './SessionRequestMessage'; import { SignalService } from '../../../../protobuf'; -export class EndSessionMessage extends SessionResetMessage { +export class EndSessionMessage extends SessionRequestMessage { public ttl(): number { return 4 * 24 * 60 * 60 * 1000; // 4 days } diff --git a/ts/session/messages/outgoing/content/SessionResetMessage.ts b/ts/session/messages/outgoing/content/SessionRequestMessage.ts similarity index 86% rename from ts/session/messages/outgoing/content/SessionResetMessage.ts rename to ts/session/messages/outgoing/content/SessionRequestMessage.ts index b4fb8ebb0..89563d9e0 100644 --- a/ts/session/messages/outgoing/content/SessionResetMessage.ts +++ b/ts/session/messages/outgoing/content/SessionRequestMessage.ts @@ -12,14 +12,14 @@ export interface PreKeyBundleType { signature: Uint8Array; } -interface SessionResetParams extends MessageParams { +interface SessionRequestParams extends MessageParams { preKeyBundle: PreKeyBundleType; } -export class SessionResetMessage extends ContentMessage { +export class SessionRequestMessage extends ContentMessage { private readonly preKeyBundle: PreKeyBundleType; - constructor(params: SessionResetParams) { + constructor(params: SessionRequestParams) { super({ timestamp: params.timestamp, identifier: params.identifier }); this.preKeyBundle = params.preKeyBundle; } diff --git a/ts/session/messages/outgoing/content/index.ts b/ts/session/messages/outgoing/content/index.ts index 2089b6932..364fceab0 100644 --- a/ts/session/messages/outgoing/content/index.ts +++ b/ts/session/messages/outgoing/content/index.ts @@ -1,7 +1,7 @@ export * from './ContentMessage'; export * from './EndSessionMessage'; export * from './SessionEstablishedMessage'; -export * from './SessionResetMessage'; +export * from './SessionRequestMessage'; export * from './TypingMessage'; export * from './data'; export * from './link'; diff --git a/ts/session/protocols/SessionProtocol.ts b/ts/session/protocols/SessionProtocol.ts index f150e70d9..ea02e3246 100644 --- a/ts/session/protocols/SessionProtocol.ts +++ b/ts/session/protocols/SessionProtocol.ts @@ -1,56 +1,315 @@ -// TODO: Need to flesh out these functions -// Structure of this can be changed for example sticking this all in a class -// The reason i haven't done it is to avoid having instances of the protocol, rather you should be able to call the functions directly +import { SessionRequestMessage } from '../messages/outgoing'; +// import { MessageSender } from '../sending'; +import { createOrUpdateItem, getItemById } from '../../../js/modules/data'; +import { libloki, libsignal, textsecure } from '../../window'; +import { MessageSender } from '../sending'; +import * as MessageUtils from '../utils'; +import { PubKey } from '../types'; -import { SessionResetMessage } from '../messages/outgoing'; - -export function hasSession(device: string): boolean { - return false; // TODO: Implement +interface StringToNumberMap { + [key: string]: number; } +// tslint:disable: no-unnecessary-class +export class SessionProtocol { + private static dbLoaded: Boolean = false; + /** + * This map olds the sent session timestamps, i.e. session requests message effectively sent to the recipient. + * It is backed by a database entry so it's loaded from db on startup. + * This map should not be used directly, but instead through + * `updateSendSessionTimestamp()`, or `hasSendSessionRequest()` + */ + private static sentSessionsTimestamp: StringToNumberMap; -export function hasSentSessionRequest(device: string): boolean { - // TODO: need a way to keep track of if we've sent a session request - // My idea was to use the timestamp of when it was sent but there might be another better approach - return false; -} + /** + * This map olds the processed session timestamps, i.e. when we received a session request and handled it. + * It is backed by a database entry so it's loaded from db on startup. + * This map should not be used directly, but instead through + * `updateProcessedSessionTimestamp()`, `getProcessedSessionRequest()` or `hasProcessedSessionRequest()` + */ + private static processedSessionsTimestamp: StringToNumberMap; -export async function sendSessionRequestIfNeeded( - device: string -): Promise { - if (hasSession(device) || hasSentSessionRequest(device)) { - return Promise.resolve(); + /** + * This map olds the timestamp on which a sent session reset is triggered for a specific device. + * Once the message is sent or failed to sent, this device is removed from here. + * This is a memory only map. Which means that on app restart it's starts empty. + */ + private static readonly pendingSendSessionsTimestamp: Set = new Set(); + + public static getSentSessionsTimestamp(): Readonly { + return SessionProtocol.sentSessionsTimestamp; } - // TODO: Call sendSessionRequest with SessionReset - return Promise.reject(new Error('Need to implement this function')); -} + public static getProcessedSessionsTimestamp(): Readonly { + return SessionProtocol.processedSessionsTimestamp; + } -export async function sendSessionRequest( - message: SessionResetMessage -): Promise { - // TODO: Optimistically store timestamp of when session request was sent - // TODO: Send out the request via MessageSender - // TODO: On failure, unset the timestamp - return Promise.resolve(); -} + public static getPendingSendSessionTimestamp(): Readonly> { + return SessionProtocol.pendingSendSessionsTimestamp; + } -export function sessionEstablished(device: string) { - // TODO: this is called when we receive an encrypted message from the other user - // Maybe it should be renamed to something else - // TODO: This should make `hasSentSessionRequest` return `false` -} + /** Returns true if we already have a session with that device */ + public static async hasSession(pubkey: PubKey): Promise { + // Session does not use the concept of a deviceId, thus it's always 1 + const address = new libsignal.SignalProtocolAddress(pubkey.key, 1); + const sessionCipher = new libsignal.SessionCipher( + textsecure.storage.protocol, + address + ); -export function shouldProcessSessionRequest( - device: string, - messageTimestamp: number -): boolean { - // TODO: Need to do the following here - // messageTimestamp > session request sent timestamp && messageTimestamp > session request processed timestamp - return false; -} + return sessionCipher.hasOpenSession(); + } + + /** + * Returns true if we sent a session request to that device already OR + * if a session request to that device is right now being sent. + */ + public static async hasSentSessionRequest(pubkey: PubKey): Promise { + const pendingSend = SessionProtocol.pendingSendSessionsTimestamp.has( + pubkey.key + ); + const hasSent = await SessionProtocol.hasAlreadySentSessionRequest( + pubkey.key + ); + + return pendingSend || hasSent; + } + + /** + * Triggers a SessionRequestMessage to be sent if: + * - we do not already have a session and + * - we did not sent a session request already to that device and + * - we do not have a session request currently being sent to that device + */ + public static async sendSessionRequestIfNeeded( + pubkey: PubKey + ): Promise { + if ( + (await SessionProtocol.hasSession(pubkey)) || + (await SessionProtocol.hasSentSessionRequest(pubkey)) + ) { + return; + } + + const preKeyBundle = await libloki.storage.getPreKeyBundleForContact( + pubkey.key + ); + + const sessionReset = new SessionRequestMessage({ + preKeyBundle, + timestamp: Date.now(), + }); + + try { + await SessionProtocol.sendSessionRequest(sessionReset, pubkey); + } catch (error) { + window.console.warn( + 'Failed to send session request to:', + pubkey.key, + error + ); + } + } + + /** + * Sends a session request message to that pubkey. + * We store the sent timestamp only if the message is effectively sent. + */ + public static async sendSessionRequest( + message: SessionRequestMessage, + pubkey: PubKey + ): Promise { + const timestamp = Date.now(); + + // mark the session as being pending send with current timestamp + // so we know we already triggered a new session with that device + // so sendSessionRequestIfNeeded does not sent another session request + SessionProtocol.pendingSendSessionsTimestamp.add(pubkey.key); + + try { + const rawMessage = MessageUtils.toRawMessage(pubkey, message); + await MessageSender.send(rawMessage); + await SessionProtocol.updateSentSessionTimestamp(pubkey.key, timestamp); + } catch (e) { + throw e; + } finally { + SessionProtocol.pendingSendSessionsTimestamp.delete(pubkey.key); + } + } + + /** + * Called when a session is establish so we store on database this info. + */ + public static async onSessionEstablished(pubkey: PubKey) { + // remove our existing sent timestamp for that device + return SessionProtocol.updateSentSessionTimestamp(pubkey.key, undefined); + } + + public static async shouldProcessSessionRequest( + pubkey: PubKey, + messageTimestamp: number + ): Promise { + const existingSentTimestamp = + (await SessionProtocol.getSentSessionRequest(pubkey.key)) || 0; + const existingProcessedTimestamp = + (await SessionProtocol.getProcessedSessionRequest(pubkey.key)) || 0; + + return ( + messageTimestamp > existingSentTimestamp && + messageTimestamp > existingProcessedTimestamp + ); + } + + public static async onSessionRequestProcessed(pubkey: PubKey) { + return SessionProtocol.updateProcessedSessionTimestamp( + pubkey.key, + Date.now() + ); + } -export function sessionRequestProcessed(device: string) { - // TODO: this is called when we process the session request - // This should store the processed timestamp - // Again naming is crap so maybe some other name is better + public static reset() { + SessionProtocol.dbLoaded = false; + SessionProtocol.sentSessionsTimestamp = {}; + SessionProtocol.processedSessionsTimestamp = {}; + } + + /** + * We only need to fetch once from the database, because we are the only one writing to it + */ + private static async fetchFromDBIfNeeded(): Promise { + if (!SessionProtocol.dbLoaded) { + const sentItem = await getItemById('sentSessionsTimestamp'); + if (sentItem) { + SessionProtocol.sentSessionsTimestamp = sentItem.value; + } else { + SessionProtocol.sentSessionsTimestamp = {}; + } + + const processedItem = await getItemById('processedSessionsTimestamp'); + if (processedItem) { + SessionProtocol.processedSessionsTimestamp = processedItem.value; + } else { + SessionProtocol.processedSessionsTimestamp = {}; + } + SessionProtocol.dbLoaded = true; + } + } + + private static async writeToDBSentSessions(): Promise { + const data = { + id: 'sentSessionsTimestamp', + value: JSON.stringify(SessionProtocol.sentSessionsTimestamp), + }; + + await createOrUpdateItem(data); + } + + private static async writeToDBProcessedSessions(): Promise { + const data = { + id: 'processedSessionsTimestamp', + value: JSON.stringify(SessionProtocol.processedSessionsTimestamp), + }; + + await createOrUpdateItem(data); + } + + /** + * This is a utility function to avoid duplicated code of updateSentSessionTimestamp and updateProcessedSessionTimestamp + */ + private static async updateSessionTimestamp( + device: string, + timestamp: number | undefined, + map: StringToNumberMap + ): Promise { + if (!timestamp) { + if (device in map) { + // tslint:disable-next-line: no-dynamic-delete + delete map[device]; + + return true; + } + + return false; + } + map[device] = timestamp; + + return true; + } + + /** + * + * @param device the device id + * @param timestamp undefined to remove the key/value pair, otherwise updates the sent timestamp and write to DB + */ + private static async updateSentSessionTimestamp( + device: string, + timestamp: number | undefined + ): Promise { + await SessionProtocol.fetchFromDBIfNeeded(); + if ( + SessionProtocol.updateSessionTimestamp( + device, + timestamp, + SessionProtocol.sentSessionsTimestamp + ) + ) { + await SessionProtocol.writeToDBSentSessions(); + } + } + + /** + * timestamp undefined to remove the key/value pair, otherwise updates the processed timestamp and writes to DB + */ + private static async updateProcessedSessionTimestamp( + device: string, + timestamp: number | undefined + ): Promise { + await SessionProtocol.fetchFromDBIfNeeded(); + if ( + SessionProtocol.updateSessionTimestamp( + device, + timestamp, + SessionProtocol.processedSessionsTimestamp + ) + ) { + await SessionProtocol.writeToDBProcessedSessions(); + } + } + + /** + * This is a utility function to avoid duplicate code between `getProcessedSessionRequest()` and `getSentSessionRequest()` + */ + private static async getSessionRequest( + device: string, + map: StringToNumberMap + ): Promise { + await SessionProtocol.fetchFromDBIfNeeded(); + + return map[device]; + } + + private static async getSentSessionRequest( + device: string + ): Promise { + return SessionProtocol.getSessionRequest( + device, + SessionProtocol.sentSessionsTimestamp + ); + } + + private static async getProcessedSessionRequest( + device: string + ): Promise { + return SessionProtocol.getSessionRequest( + device, + SessionProtocol.processedSessionsTimestamp + ); + } + + private static async hasAlreadySentSessionRequest( + device: string + ): Promise { + await SessionProtocol.fetchFromDBIfNeeded(); + + return !!SessionProtocol.sentSessionsTimestamp[device]; + } } diff --git a/ts/session/protocols/index.ts b/ts/session/protocols/index.ts index e0cfeb680..38fe021c0 100644 --- a/ts/session/protocols/index.ts +++ b/ts/session/protocols/index.ts @@ -1,4 +1,4 @@ -import * as SessionProtocol from './SessionProtocol'; +import { SessionProtocol } from './SessionProtocol'; import * as MultiDeviceProtocol from './MultiDeviceProtocol'; export { SessionProtocol, MultiDeviceProtocol }; diff --git a/ts/session/types/EncryptionType.ts b/ts/session/types/EncryptionType.ts index ed27e1023..86d1aeda7 100644 --- a/ts/session/types/EncryptionType.ts +++ b/ts/session/types/EncryptionType.ts @@ -1,5 +1,5 @@ export enum EncryptionType { Signal, - SessionReset, + SessionRequest, MediumGroup, } diff --git a/ts/test/session/crypto/MessageEncrypter_test.ts b/ts/test/session/crypto/MessageEncrypter_test.ts index 5d6b4155d..6f16e77c7 100644 --- a/ts/test/session/crypto/MessageEncrypter_test.ts +++ b/ts/test/session/crypto/MessageEncrypter_test.ts @@ -7,6 +7,7 @@ import { Stubs, TestUtils } from '../../test-utils'; import { UserUtil } from '../../../util'; import { SignalService } from '../../../protobuf'; +// tslint:disable-next-line: max-func-body-length describe('MessageEncrypter', () => { const sandbox = sinon.createSandbox(); const ourNumber = 'ourNumber'; @@ -58,14 +59,18 @@ describe('MessageEncrypter', () => { }); }); - describe('SessionReset', () => { + describe('SessionRequest', () => { it('should call FallbackSessionCipher encrypt', async () => { const data = crypto.randomBytes(10); const spy = sandbox.spy( Stubs.FallBackSessionCipherStub.prototype, 'encrypt' ); - await MessageEncrypter.encrypt('1', data, EncryptionType.SessionReset); + await MessageEncrypter.encrypt( + '1', + data, + EncryptionType.SessionRequest + ); expect(spy.called).to.equal( true, 'FallbackSessionCipher.encrypt should be called.' @@ -78,7 +83,11 @@ describe('MessageEncrypter', () => { Stubs.FallBackSessionCipherStub.prototype, 'encrypt' ); - await MessageEncrypter.encrypt('1', data, EncryptionType.SessionReset); + await MessageEncrypter.encrypt( + '1', + data, + EncryptionType.SessionRequest + ); const paddedData = MessageEncrypter.padPlainTextBuffer(data); const firstArgument = new Uint8Array(spy.args[0][0]); @@ -90,7 +99,7 @@ describe('MessageEncrypter', () => { const result = await MessageEncrypter.encrypt( '1', data, - EncryptionType.SessionReset + EncryptionType.SessionRequest ); expect(result.envelopeType).to.deep.equal( SignalService.Envelope.Type.UNIDENTIFIED_SENDER @@ -135,7 +144,7 @@ describe('MessageEncrypter', () => { describe('Sealed Sender', () => { it('should pass the correct values to SecretSessionCipher encrypt', async () => { - const types = [EncryptionType.SessionReset, EncryptionType.Signal]; + const types = [EncryptionType.SessionRequest, EncryptionType.Signal]; for (const type of types) { const spy = sandbox.spy( Stubs.SecretSessionCipherStub.prototype, diff --git a/ts/test/session/messages/SessionResetMessage_test.ts b/ts/test/session/messages/SessionResetMessage_test.ts index 33e6ac20e..6f7bb758d 100644 --- a/ts/test/session/messages/SessionResetMessage_test.ts +++ b/ts/test/session/messages/SessionResetMessage_test.ts @@ -1,12 +1,12 @@ import { expect } from 'chai'; import { beforeEach } from 'mocha'; -import { SessionResetMessage } from '../../../session/messages/outgoing'; +import { SessionRequestMessage } from '../../../session/messages/outgoing'; import { SignalService } from '../../../protobuf'; import { TextDecoder, TextEncoder } from 'util'; -describe('SessionResetMessage', () => { - let message: SessionResetMessage; +describe('SessionRequestMessage', () => { + let message: SessionRequestMessage; const preKeyBundle = { deviceId: 123456, preKeyId: 654321, @@ -19,7 +19,7 @@ describe('SessionResetMessage', () => { beforeEach(() => { const timestamp = Date.now(); - message = new SessionResetMessage({ timestamp, preKeyBundle }); + message = new SessionRequestMessage({ timestamp, preKeyBundle }); }); it('has a preKeyBundle', () => { diff --git a/ts/test/session/protocols/SessionProtocol_test.ts b/ts/test/session/protocols/SessionProtocol_test.ts new file mode 100644 index 000000000..b78d48e24 --- /dev/null +++ b/ts/test/session/protocols/SessionProtocol_test.ts @@ -0,0 +1,329 @@ +import { expect } from 'chai'; +import { SessionProtocol } from '../../../session/protocols'; +import * as sinon from 'sinon'; +import { Stubs, TestUtils, timeout } from '../../test-utils'; +import { UserUtil } from '../../../util'; +import { SessionRequestMessage } from '../../../session/messages/outgoing'; +import { TextEncoder } from 'util'; +import { MessageSender } from '../../../session/sending'; +import { PubKey } from '../../../session/types'; + +// tslint:disable-next-line: max-func-body-length +describe('SessionProtocol', () => { + const sandbox = sinon.createSandbox(); + const ourNumber = 'ourNumber'; + const pubkey = new PubKey('deviceid'); + let getItemById: sinon.SinonStub; + let send: sinon.SinonStub; + + const resetMessage: SessionRequestMessage = new SessionRequestMessage({ + timestamp: Date.now(), + preKeyBundle: { + identityKey: new TextEncoder().encode('identityKey'), + deviceId: 1, + preKeyId: 2, + signedKeyId: 3, + preKey: new TextEncoder().encode('preKey'), + signedKey: new TextEncoder().encode('signedKey'), + signature: new TextEncoder().encode('signature'), + }, + }); + + beforeEach(() => { + TestUtils.stubWindow('libsignal', { + SignalProtocolAddress: sandbox.stub(), + SessionCipher: Stubs.SessionCipherStub, + } as any); + + TestUtils.stubWindow('libloki', { + storage: { + getPreKeyBundleForContact: sandbox.stub(), + }, + }); + + TestUtils.stubWindow('textsecure', { + storage: { + protocol: sandbox.stub(), + }, + }); + + TestUtils.stubData('createOrUpdateItem'); + + getItemById = TestUtils.stubData('getItemById').resolves({ value: {} }); + + sandbox.stub(UserUtil, 'getCurrentDevicePubKey').resolves(ourNumber); + send = sandbox.stub(MessageSender, 'send' as any); + SessionProtocol.reset(); + }); + + afterEach(() => { + sandbox.restore(); + TestUtils.restoreStubs(); + }); + + describe('db fetch', () => { + it('protocol: should fetch from DB `sentSessionsTimestamp` and `processedSessionsTimestamp`', async () => { + await SessionProtocol.hasSentSessionRequest(pubkey); + expect(getItemById.calledWith('sentSessionsTimestamp')); + expect(getItemById.calledWith('processedSessionsTimestamp')); + expect(getItemById.callCount).to.equal(2); + }); + + it('protocol: should fetch only once', async () => { + await SessionProtocol.hasSentSessionRequest(pubkey); + await SessionProtocol.hasSentSessionRequest(pubkey); + await SessionProtocol.hasSentSessionRequest(pubkey); + await SessionProtocol.hasSentSessionRequest(pubkey); + expect(getItemById.calledWith('sentSessionsTimestamp')); + expect(getItemById.calledWith('processedSessionsTimestamp')); + expect(getItemById.callCount).to.equal(2); + }); + }); + + describe('sendSessionRequest', () => { + beforeEach(async () => { + // trigger a sessionReset + await SessionProtocol.sendSessionRequest(resetMessage, pubkey); + }); + + it('protocol: sendSessionRequest should add the deviceID to the sentMap', async () => { + expect(SessionProtocol.getSentSessionsTimestamp()) + .to.have.property('deviceid') + .to.be.approximately(Date.now(), 100); + }); + + it('protocol: sendSessionRequest should not have pendingSend set after', async () => { + expect( + SessionProtocol.getPendingSendSessionTimestamp() + ).to.not.have.property('deviceid'); + }); + }); + + describe('onSessionEstablished', () => { + beforeEach(async () => { + // add an existing entry in the sentMap + await SessionProtocol.sendSessionRequest(resetMessage, pubkey); + }); + + it('protocol: onSessionEstablished should remove the device in sentTimestamps', async () => { + expect(SessionProtocol.getSentSessionsTimestamp()).to.have.property( + 'deviceid' + ); + await SessionProtocol.onSessionEstablished(pubkey); + expect(SessionProtocol.getSentSessionsTimestamp()).to.not.have.property( + 'deviceid' + ); + }); + + it('protocol: onSessionEstablished should remove the device in sentTimestamps and ONLY that one', async () => { + // add a second item to the map + await SessionProtocol.sendSessionRequest( + resetMessage, + new PubKey('deviceid2') + ); + + expect(SessionProtocol.getSentSessionsTimestamp()).to.have.property( + 'deviceid' + ); + expect(SessionProtocol.getSentSessionsTimestamp()).to.have.property( + 'deviceid2' + ); + + await SessionProtocol.onSessionEstablished(pubkey); + expect(SessionProtocol.getSentSessionsTimestamp()).to.not.have.property( + 'deviceid' + ); + expect(SessionProtocol.getSentSessionsTimestamp()).to.have.property( + 'deviceid2' + ); + }); + }); + + describe('hasSentSessionRequest', () => { + it('protocol: hasSentSessionRequest returns false if a message was not sent to that device', async () => { + const hasSent = await SessionProtocol.hasSentSessionRequest(pubkey); + expect(hasSent).to.be.equal( + false, + 'hasSent should be false for `deviceid`' + ); + }); + + it('protocol: hasSentSessionRequest returns true if a message is already sent for that device', async () => { + // add an existing entry in the sentMap + await SessionProtocol.sendSessionRequest(resetMessage, pubkey); + const hasSent = await SessionProtocol.hasSentSessionRequest(pubkey); + expect(hasSent).to.be.equal( + true, + 'hasSent should be true for `deviceid`' + ); + }); + + // TODO add a test to validate that pending is filled when message is triggered and not yet sent + }); + + describe('sendSessionRequestIfNeeded', () => { + it('protocol: sendSessionRequestIfNeeded should send a new sessionMessage ', async () => { + // not called before, so the message reset sending should be triggered + await SessionProtocol.sendSessionRequestIfNeeded(pubkey); + expect(send.callCount).to.be.equal( + 1, + 'MessageSender.send() should have been called' + ); + + // check that the map is updated with that ID + const hasSent = await SessionProtocol.hasSentSessionRequest(pubkey); + expect(hasSent).to.be.equal( + true, + 'hasSent should be true for `deviceid`' + ); + }); + + it('protocol: sendSessionRequestIfNeeded should NOT send a new sessionMessage on second try ', async () => { + await SessionProtocol.sendSessionRequestIfNeeded(pubkey); + expect(send.callCount).to.be.equal( + 1, + 'MessageSender.send() should have been called' + ); + + // check that the map is updated with that ID + const hasSent = await SessionProtocol.hasSentSessionRequest(pubkey); + expect(hasSent).to.be.equal( + true, + 'hasSent should be true for `deviceid`' + ); + send.resetHistory(); + + // trigger a second call, Message.send().calledCount should still be 1 + await SessionProtocol.sendSessionRequestIfNeeded(pubkey); + expect(send.callCount).to.be.equal( + 0, + 'MessageSender.send() should NOT have been called a second time' + ); + }); + }); + + describe('onSessionRequestProcessed', () => { + it('protocol: onSessionRequestProcessed should insert a new item in the processedMap ', async () => { + // trigger the requestProcessed and check the map is updated + await SessionProtocol.onSessionRequestProcessed(pubkey); + expect(SessionProtocol.getProcessedSessionsTimestamp()) + .to.have.property('deviceid') + .to.be.approximately(Date.now(), 5); + }); + + it('protocol: onSessionRequestProcessed should update an existing item in the processedMap ', async () => { + // trigger the requestProcessed and check the map is updated + // then trigger it a second time, and expect a change in the processed timestamp + + await SessionProtocol.onSessionRequestProcessed(pubkey); + expect(SessionProtocol.getProcessedSessionsTimestamp()) + .to.have.property('deviceid') + .to.be.approximately(Date.now(), 5); + await timeout(5); + const oldTimestamp = SessionProtocol.getProcessedSessionsTimestamp() + .deviceid; + await SessionProtocol.onSessionRequestProcessed(pubkey); + expect(SessionProtocol.getProcessedSessionsTimestamp()) + .to.have.property('deviceid') + .to.be.approximately(Date.now(), 5) + .to.not.be.equal(oldTimestamp); + }); + }); + + describe('shouldProcessSessionRequest', () => { + it('protocol: shouldProcessSessionRequest returns true if timestamp is more recent than processed timestamp', async () => { + await SessionProtocol.onSessionRequestProcessed(pubkey); // adds a Date.now() entry + expect( + SessionProtocol.shouldProcessSessionRequest(pubkey, Date.now() + 1000) + ).to.be.eventually.equal( + true, + 'shouldProcessSessionRequest should return true when existingProcessed is less recent' + ); + }); + + it('protocol: shouldProcessSessionRequest returns true if there is no processed timestamp yet for this device', async () => { + expect( + SessionProtocol.shouldProcessSessionRequest(pubkey, 100) + ).to.be.eventually.equal( + true, + 'shouldProcessSessionRequest should return false when existingProcessed is empty for this device' + ); + }); + + it('protocol: shouldProcessSessionRequest returns false if timestamp is less recent than current processed timestamp', async () => { + await SessionProtocol.onSessionRequestProcessed(pubkey); // adds a Date.now() entry + expect( + SessionProtocol.shouldProcessSessionRequest(pubkey, 100) + ).to.be.eventually.equal( + false, + 'shouldProcessSessionRequest should return false when existingProcessed is more recent' + ); + }); + + it('protocol: shouldProcessSessionRequest returns false if timestamp is less recent than current sent timestamp', async () => { + await SessionProtocol.sendSessionRequest(resetMessage, pubkey); // adds a Date.now() entry + expect( + SessionProtocol.shouldProcessSessionRequest(pubkey, 100) + ).to.be.eventually.equal( + false, + 'shouldProcessSessionRequest should return false when existingSent is more recent' + ); + }); + + it('protocol: shouldProcessSessionRequest returns true if timestamp is more recent than current sent timestamp', async () => { + await SessionProtocol.sendSessionRequest(resetMessage, pubkey); // adds a Date.now() entry + expect( + SessionProtocol.shouldProcessSessionRequest(pubkey, Date.now() + 1000) + ).to.be.eventually.equal( + true, + 'shouldProcessSessionRequest should return true when existingSent is less recent' + ); + }); + + it('protocol: shouldProcessSessionRequest returns true if there is no sent timestamp', async () => { + expect( + SessionProtocol.shouldProcessSessionRequest(pubkey, 100) + ).to.be.eventually.equal( + true, + 'shouldProcessSessionRequest should return true as there is no sent timestamp' + ); + }); + + it('protocol: shouldProcessSessionRequest returns false if there is a more recent sent but a less recent processed', async () => { + await SessionProtocol.sendSessionRequest(resetMessage, pubkey); // adds a Date.now() entry + await timeout(100); + await SessionProtocol.onSessionRequestProcessed(pubkey); // adds a Date.now() entry 100ms after + + expect( + SessionProtocol.shouldProcessSessionRequest(pubkey, Date.now() - 50) + ).to.be.eventually.equal( + false, + 'shouldProcessSessionRequest should return false if there is a more recent sent but a less recent processed' + ); + }); + + it('protocol: shouldProcessSessionRequest returns false if there is a more recent processed but a less recent sent', async () => { + await SessionProtocol.onSessionRequestProcessed(pubkey); // adds a Date.now() entry + await timeout(100); + await SessionProtocol.sendSessionRequest(resetMessage, pubkey); // adds a Date.now() entry 100ms after + + expect( + SessionProtocol.shouldProcessSessionRequest(pubkey, Date.now() - 50) + ).to.be.eventually.equal( + false, + 'shouldProcessSessionRequest should return false if there is a more recent processed but a less recent sent' + ); + }); + + it('protocol: shouldProcessSessionRequest returns true if both sent and processed timestamp are older', async () => { + await SessionProtocol.onSessionRequestProcessed(pubkey); // adds a Date.now() entry + await SessionProtocol.sendSessionRequest(resetMessage, pubkey); // adds a Date.now() entry + expect( + SessionProtocol.shouldProcessSessionRequest(pubkey, Date.now() + 1000) + ).to.be.eventually.equal( + true, + 'shouldProcessSessionRequest should return true if there if both processed and sent are set but are older' + ); + }); + }); +}); diff --git a/ts/test/test-utils/stubs/ciphers/SessionCipherStub.ts b/ts/test/test-utils/stubs/ciphers/SessionCipherStub.ts index 8f35d8ebc..4e246d1f4 100644 --- a/ts/test/test-utils/stubs/ciphers/SessionCipherStub.ts +++ b/ts/test/test-utils/stubs/ciphers/SessionCipherStub.ts @@ -42,7 +42,7 @@ export class SessionCipherStub implements SessionCipher { } public async hasOpenSession(): Promise { - throw new Error('Method not implemented.'); + return false; } public async closeOpenSessionForDevice(): Promise {