From 9492fdc51e1e62b1f6fb13f64e685904e125da90 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Thu, 12 Oct 2023 13:10:17 +1100 Subject: [PATCH] fix: first working test ios to desktop still have some tests to fix --- ts/receiver/contentMessage.ts | 34 +- ts/receiver/receiver.ts | 18 +- ts/session/apis/snode_api/swarmPolling.ts | 82 +++- ts/session/sending/MessageSender.ts | 53 ++- .../utils/job_runners/jobs/UserSyncJob.ts | 11 +- .../libsession_util/libsession_utils_test.ts | 2 +- .../group_sync_job/GroupSyncJob_test.ts | 3 +- .../user_sync_job/UserSyncJob_test.ts | 367 ++++++++++++++++++ 8 files changed, 505 insertions(+), 65 deletions(-) create mode 100644 ts/test/session/unit/utils/job_runner/user_sync_job/UserSyncJob_test.ts diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index bdee814ba..1642e17ec 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -30,14 +30,10 @@ import { assertUnreachable } from '../types/sqlSharedTypes'; import { BlockedNumberController } from '../util'; import { ReadReceipts } from '../util/readReceipts'; import { Storage } from '../util/storage'; +import { ContactsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface'; import { handleCallMessage } from './callMessage'; import { getAllCachedECKeyPair, sentAtMoreRecentThanWrapper } from './closedGroups'; import { ECKeyPair } from './keypairs'; -import { - ContactsWrapperActions, - MetaGroupWrapperActions, -} from '../webworker/workers/browser/libsession_worker_interface'; -import { PreConditionFailed } from '../session/utils/errors'; export async function handleSwarmContentMessage(envelope: EnvelopePlus, messageHash: string) { try { @@ -58,27 +54,6 @@ export async function handleSwarmContentMessage(envelope: EnvelopePlus, messageH } } -async function decryptForGroupV2(envelope: EnvelopePlus) { - window?.log?.info('received closed group message v2'); - try { - const groupPk = envelope.source; - if (!PubKey.isClosedGroupV2(groupPk)) { - throw new PreConditionFailed('decryptForGroupV2: not a 03 prefixed group'); - } - - const decrypted = await MetaGroupWrapperActions.decryptMessage(groupPk, envelope.content); - - // the receiving pipeline relies on the envelope.senderIdentity field to know who is the author of a message - // eslint-disable-next-line no-param-reassign - envelope.senderIdentity = decrypted.pubkeyHex; - - return decrypted.plaintext; - } catch (e) { - window.log.warn('failed to decrypt message with error: ', e.message); - return null; - } -} - async function decryptForClosedGroup(envelope: EnvelopePlus) { window?.log?.info('received closed group message'); try { @@ -291,10 +266,11 @@ async function decrypt(envelope: EnvelopePlus): Promise { break; case SignalService.Envelope.Type.CLOSED_GROUP_MESSAGE: if (PubKey.isClosedGroupV2(envelope.source)) { - plaintext = await decryptForGroupV2(envelope); - } else { - plaintext = await decryptForClosedGroup(envelope); + // groupv2 messages are decrypted way earlier than this via libsession, and what we get here is already decrypted + return envelope.content; } + plaintext = await decryptForClosedGroup(envelope); + break; default: assertUnreachable(envelope.type, `Unknown message type:${envelope.type}`); diff --git a/ts/receiver/receiver.ts b/ts/receiver/receiver.ts index 15938d25e..48f10bedd 100644 --- a/ts/receiver/receiver.ts +++ b/ts/receiver/receiver.ts @@ -1,6 +1,6 @@ /* eslint-disable more/no-then */ +import _, { isEmpty, last } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import _ from 'lodash'; import { EnvelopePlus } from './types'; @@ -70,18 +70,22 @@ function queueSwarmEnvelope(envelope: EnvelopePlus, messageHash: string) { } } +function contentIsEnvelope(content: Uint8Array | EnvelopePlus): content is EnvelopePlus { + return !isEmpty((content as EnvelopePlus).content); +} + async function handleRequestDetail( - plaintext: Uint8Array, + data: Uint8Array | EnvelopePlus, inConversation: string | null, lastPromise: Promise, messageHash: string ): Promise { - const envelope: any = SignalService.Envelope.decode(plaintext); + const envelope: any = contentIsEnvelope(data) ? data : SignalService.Envelope.decode(data); // After this point, decoding errors are not the server's // fault, and we should handle them gracefully and tell the // user they received an invalid message - // The message is for a medium size group + // The message is for a group if (inConversation) { const ourNumber = UserUtils.getOurPubKeyStrFromCache(); const senderIdentity = envelope.source; @@ -95,7 +99,7 @@ async function handleRequestDetail( envelope.source = inConversation; // eslint-disable-next-line no-param-reassign - plaintext = SignalService.Envelope.encode(envelope).finish(); + data = SignalService.Envelope.encode(envelope).finish(); envelope.senderIdentity = senderIdentity; } @@ -109,7 +113,7 @@ async function handleRequestDetail( // need to handle senderIdentity separately)... perfStart(`addToCache-${envelope.id}`); - await addToCache(envelope, plaintext, messageHash); + await addToCache(envelope, contentIsEnvelope(data) ? data.content : data, messageHash); perfEnd(`addToCache-${envelope.id}`, 'addToCache'); // To ensure that we queue in the same order we receive messages @@ -133,7 +137,7 @@ export function handleRequest( inConversation: string | null, messageHash: string ): void { - const lastPromise = _.last(incomingMessagePromises) || Promise.resolve(); + const lastPromise = last(incomingMessagePromises) || Promise.resolve(); const promise = handleRequestDetail(plaintext, inConversation, lastPromise, messageHash).catch( e => { diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index 45d09d0fc..a96347d4b 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -3,6 +3,7 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ import { GroupPubkeyType } from 'libsession_util_nodejs'; import { compact, concat, difference, flatten, isArray, last, sample, uniqBy } from 'lodash'; +import { v4 } from 'uuid'; import { Data, Snode } from '../../../data/data'; import { SignalService } from '../../../protobuf'; import * as Receiver from '../../../receiver/receiver'; @@ -12,6 +13,8 @@ import * as snodePool from './snodePool'; import { ConversationModel } from '../../../models/conversation'; import { ConversationTypeEnum } from '../../../models/conversationAttributes'; +import { signalservice } from '../../../protobuf/compiled'; +import { EnvelopePlus } from '../../../receiver/types'; import { updateIsOnline } from '../../../state/ducks/onion'; import { assertUnreachable } from '../../../types/sqlSharedTypes'; import { @@ -24,6 +27,7 @@ import { ConvoHub } from '../../conversations'; import { ed25519Str } from '../../onions/onionPath'; import { StringUtils, UserUtils } from '../../utils'; import { perfEnd, perfStart } from '../../utils/Performance'; +import { PreConditionFailed } from '../../utils/errors'; import { LibSessionUtil } from '../../utils/libsession/libsession_utils'; import { SnodeNamespace, SnodeNamespaces, UserConfigNamespaces } from './namespaces'; import { PollForGroup, PollForLegacy, PollForUs } from './pollingTypes'; @@ -37,13 +41,7 @@ import { RetrieveRequestResult, } from './types'; -export function extractWebSocketContent( - message: string, - messageHash: string -): null | { - body: Uint8Array; - messageHash: string; -} { +export function extractWebSocketContent(message: string): null | Uint8Array { try { const dataPlaintext = new Uint8Array(StringUtils.encode(message, 'base64')); const messageBuf = SignalService.WebSocketMessage.decode(dataPlaintext); @@ -51,11 +49,9 @@ export function extractWebSocketContent( messageBuf.type === SignalService.WebSocketMessage.Type.REQUEST && messageBuf.request?.body?.length ) { - return { - body: messageBuf.request.body, - messageHash, - }; + return messageBuf.request.body; } + return null; } catch (error) { window?.log?.warn('extractWebSocketContent from message failed with:', error.message); @@ -374,21 +370,38 @@ export class SwarmPolling { perfStart(`handleSeenMessages-${pubkey}`); const newMessages = await this.handleSeenMessages(uniqOtherMsgs); perfEnd(`handleSeenMessages-${pubkey}`, 'handleSeenMessages'); + if (type === ConversationTypeEnum.GROUPV3) { + for (let index = 0; index < newMessages.length; index++) { + const msg = newMessages[index]; + const retrieveResult = new Uint8Array(StringUtils.encode(msg.data, 'base64')); + try { + const envelopePlus = await decryptForGroupV2({ + content: retrieveResult, + groupPk: pubkey, + sentTimestamp: msg.timestamp, + }); + if (!envelopePlus) { + throw new Error('decryptForGroupV2 returned empty envelope'); + } + + // this is the processing of the message itself, which can be long. + Receiver.handleRequest(envelopePlus.content, envelopePlus.source, msg.hash); + } catch (e) { + window.log.warn('failed to handle groupv2 otherMessage because of: ', e.message); + } + } + return; + } // trigger the handling of all the other messages, not shared config related newMessages.forEach(m => { - const content = extractWebSocketContent(m.data, m.hash); + const content = extractWebSocketContent(m.data); + if (!content) { return; } - Receiver.handleRequest( - content.body, - type === ConversationTypeEnum.GROUP || type === ConversationTypeEnum.GROUPV3 - ? pubkey - : null, - content.messageHash - ); + Receiver.handleRequest(content, type === ConversationTypeEnum.GROUP ? pubkey : null, m.hash); }); } @@ -721,3 +734,34 @@ function filterMessagesPerTypeOfConvo( return { confMessages: null, otherMessages: [] }; } } + +async function decryptForGroupV2(retrieveResult: { + groupPk: string; + content: Uint8Array; + sentTimestamp: number; +}): Promise { + window?.log?.info('received closed group message v2'); + try { + const groupPk = retrieveResult.groupPk; + if (!PubKey.isClosedGroupV2(groupPk)) { + throw new PreConditionFailed('decryptForGroupV2: not a 03 prefixed group'); + } + + const decrypted = await MetaGroupWrapperActions.decryptMessage(groupPk, retrieveResult.content); + const envelopePlus: EnvelopePlus = { + id: v4(), + senderIdentity: decrypted.pubkeyHex, + receivedAt: Date.now(), + content: decrypted.plaintext, + source: groupPk, + type: signalservice.Envelope.Type.CLOSED_GROUP_MESSAGE, + timestamp: retrieveResult.sentTimestamp, + }; + // the receiving pipeline relies on the envelope.senderIdentity field to know who is the author of a message + + return envelopePlus; + } catch (e) { + window.log.warn('failed to decrypt message with error: ', e.message); + return null; + } +} diff --git a/ts/session/sending/MessageSender.ts b/ts/session/sending/MessageSender.ts index d85f1d8b2..c6dc63e84 100644 --- a/ts/session/sending/MessageSender.ts +++ b/ts/session/sending/MessageSender.ts @@ -307,6 +307,50 @@ type EncryptAndWrapMessageResults = { namespace: number; } & SharedEncryptAndWrap; +async function encryptForGroupV2( + params: EncryptAndWrapMessage +): Promise { + // Group v2 encryption works a bit differently: we encrypt the envelope itself through libsession. + // We essentially need to do the opposite of the usual encryption which is send envelope unencrypted with content encrypted. + const { + destination, + identifier, + isSyncMessage: syncMessage, + namespace, + plainTextBuffer, + ttl, + } = params; + + const { overRiddenTimestampBuffer, networkTimestamp } = + overwriteOutgoingTimestampWithNetworkTimestamp({ plainTextBuffer }); + const envelope = await buildEnvelope( + SignalService.Envelope.Type.CLOSED_GROUP_MESSAGE, + destination, + networkTimestamp, + overRiddenTimestampBuffer + ); + + const recipient = PubKey.cast(destination); + + const { cipherText } = await MessageEncrypter.encrypt( + recipient, + SignalService.Envelope.encode(envelope).finish(), + encryptionBasedOnConversation(recipient) + ); + + const data64 = ByteBuffer.wrap(cipherText).toString('base64'); + + return { + data64, + networkTimestamp, + data: cipherText, + namespace, + ttl, + identifier, + isSyncMessage: syncMessage, + }; +} + async function encryptMessageAndWrap( params: EncryptAndWrapMessage ): Promise { @@ -319,6 +363,10 @@ async function encryptMessageAndWrap( ttl, } = params; + if (PubKey.isClosedGroupV2(destination)) { + return encryptForGroupV2(params); + } + const { overRiddenTimestampBuffer, networkTimestamp } = overwriteOutgoingTimestampWithNetworkTimestamp({ plainTextBuffer }); const recipient = PubKey.cast(destination); @@ -330,8 +378,7 @@ async function encryptMessageAndWrap( ); const envelope = await buildEnvelope(envelopeType, recipient.key, networkTimestamp, cipherText); - - const data = wrapEnvelope(envelope); + const data = wrapEnvelopeInWebSocketMessage(envelope); const data64 = ByteBuffer.wrap(data).toString('base64'); return { @@ -423,7 +470,7 @@ async function buildEnvelope( * This is an outdated practice and we should probably just send the envelope data directly. * Something to think about in the future. */ -function wrapEnvelope(envelope: SignalService.Envelope): Uint8Array { +function wrapEnvelopeInWebSocketMessage(envelope: SignalService.Envelope): Uint8Array { const request = SignalService.WebSocketRequestMessage.create({ id: 0, body: SignalService.Envelope.encode(envelope).finish(), diff --git a/ts/session/utils/job_runners/jobs/UserSyncJob.ts b/ts/session/utils/job_runners/jobs/UserSyncJob.ts index bbb5ff64f..3c04d93a1 100644 --- a/ts/session/utils/job_runners/jobs/UserSyncJob.ts +++ b/ts/session/utils/job_runners/jobs/UserSyncJob.ts @@ -1,6 +1,6 @@ /* eslint-disable no-await-in-loop */ import { PubkeyType } from 'libsession_util_nodejs'; -import { isArray, isEmpty, isNumber } from 'lodash'; +import { isArray, isEmpty, isNumber, isString } from 'lodash'; import { v4 } from 'uuid'; import { UserUtils } from '../..'; import { ConfigDumpData } from '../../../../data/configDump/configDump'; @@ -17,9 +17,9 @@ import { LibSessionUtil, UserSuccessfulChange } from '../../libsession/libsessio import { runners } from '../JobRunner'; import { AddJobCheckReturn, - UserSyncPersistedData, PersistedJob, RunJobResult, + UserSyncPersistedData, } from '../PersistedJob'; const defaultMsBetweenRetries = 15000; // a long time between retries, to avoid running multiple jobs at the same time, when one was postponed at the same time as one already planned (5s) @@ -61,8 +61,8 @@ function triggerConfSyncJobDone() { window.Whisper.events.trigger(UserSyncJobDone); } -function isPubkey(us: string): us is PubkeyType { - return us.startsWith('05'); +function isPubkey(us: unknown): us is PubkeyType { + return isString(us) && us.startsWith('05'); } async function pushChangesToUserSwarmIfNeeded() { @@ -153,7 +153,7 @@ class UserSyncJob extends PersistedJob { return RunJobResult.PermanentFailure; } - return await pushChangesToUserSwarmIfNeeded(); + return await UserSync.pushChangesToUserSwarmIfNeeded(); // eslint-disable-next-line no-useless-catch } catch (e) { throw e; @@ -228,5 +228,6 @@ async function queueNewJobIfNeeded() { export const UserSync = { UserSyncJob, + pushChangesToUserSwarmIfNeeded, queueNewJobIfNeeded: () => allowOnlyOneAtATime('UserSyncJob-oneAtAtTime', queueNewJobIfNeeded), }; diff --git a/ts/test/session/unit/libsession_util/libsession_utils_test.ts b/ts/test/session/unit/libsession_util/libsession_utils_test.ts index 8879d1cea..cfa5254db 100644 --- a/ts/test/session/unit/libsession_util/libsession_utils_test.ts +++ b/ts/test/session/unit/libsession_util/libsession_utils_test.ts @@ -215,7 +215,7 @@ describe('LibSessionUtil pendingChangesForGroup', () => { }); }); -describe('LibSessionUtil pendingChangesForUser', () => { +describe('LibSessionUtil pendingChangesForUs', () => { beforeEach(async () => {}); afterEach(() => { diff --git a/ts/test/session/unit/utils/job_runner/group_sync_job/GroupSyncJob_test.ts b/ts/test/session/unit/utils/job_runner/group_sync_job/GroupSyncJob_test.ts index 950cf63e1..686368cde 100644 --- a/ts/test/session/unit/utils/job_runner/group_sync_job/GroupSyncJob_test.ts +++ b/ts/test/session/unit/utils/job_runner/group_sync_job/GroupSyncJob_test.ts @@ -281,6 +281,7 @@ describe('GroupSyncJob pushChangesToGroupSwarmIfNeeded', () => { }); it('calls sendEncryptedDataToSnode with the right data and retry if network returned nothing', async () => { + throw null; // this test might not be right const info = validInfo(sodium); const member = validMembers(sodium); const networkTimestamp = 4444; @@ -300,7 +301,6 @@ describe('GroupSyncJob pushChangesToGroupSwarmIfNeeded', () => { expect(saveDumpsToDbStub.firstCall.args).to.be.deep.eq([groupPk]); function expected(details: any) { - console.warn('details', details); return { namespace: details.namespace, data: details.ciphertext, @@ -320,6 +320,7 @@ describe('GroupSyncJob pushChangesToGroupSwarmIfNeeded', () => { }); it('calls sendEncryptedDataToSnode with the right data (and keys) and retry if network returned nothing', async () => { + throw null; // this test might not be right const info = validInfo(sodium); const member = validMembers(sodium); const keys = validKeys(sodium); diff --git a/ts/test/session/unit/utils/job_runner/user_sync_job/UserSyncJob_test.ts b/ts/test/session/unit/utils/job_runner/user_sync_job/UserSyncJob_test.ts new file mode 100644 index 000000000..bac7105d5 --- /dev/null +++ b/ts/test/session/unit/utils/job_runner/user_sync_job/UserSyncJob_test.ts @@ -0,0 +1,367 @@ +import { expect } from 'chai'; +import { PubkeyType } from 'libsession_util_nodejs'; +import { omit } from 'lodash'; +import Long from 'long'; +import Sinon from 'sinon'; +import { getSodiumNode } from '../../../../../../node/sodiumNode'; +import { NotEmptyArrayOfBatchResults } from '../../../../../../session/apis/snode_api/SnodeRequestTypes'; +import { GetNetworkTime } from '../../../../../../session/apis/snode_api/getNetworkTime'; +import { + SnodeNamespaces, + UserConfigNamespaces, +} from '../../../../../../session/apis/snode_api/namespaces'; +import { TTL_DEFAULT } from '../../../../../../session/constants'; +import { ConvoHub } from '../../../../../../session/conversations'; +import { LibSodiumWrappers } from '../../../../../../session/crypto'; +import { MessageSender } from '../../../../../../session/sending'; +import { UserUtils } from '../../../../../../session/utils'; +import { RunJobResult } from '../../../../../../session/utils/job_runners/PersistedJob'; +import { UserSync } from '../../../../../../session/utils/job_runners/jobs/UserSyncJob'; +import { + LibSessionUtil, + PendingChangesForUs, + UserDestinationChanges, + UserSuccessfulChange, +} from '../../../../../../session/utils/libsession/libsession_utils'; +import { GenericWrapperActions } from '../../../../../../webworker/workers/browser/libsession_worker_interface'; +import { TestUtils } from '../../../../../test-utils'; +import { TypedStub, stubConfigDumpData } from '../../../../../test-utils/utils'; + +function userChange( + sodium: LibSodiumWrappers, + namespace: UserConfigNamespaces, + seqno: number +): PendingChangesForUs { + return { + ciphertext: sodium.randombytes_buf(120), + namespace, + seqno: Long.fromNumber(seqno), + }; +} + +describe('UserSyncJob run()', () => { + afterEach(() => { + Sinon.restore(); + }); + it('throws if no user keys', async () => { + const job = new UserSync.UserSyncJob({}); + + const func = async () => job.run(); + await expect(func()).to.be.eventually.rejected; + }); + + it('throws if our pubkey is set but not valid', async () => { + const job = new UserSync.UserSyncJob({}); + Sinon.stub(UserUtils, 'getOurPubKeyStrFromCache').returns({ something: false } as any); + Sinon.stub(UserUtils, 'getUserED25519KeyPairBytes').resolves({ something: true } as any); + Sinon.stub(ConvoHub.use(), 'get').resolves({}); // anything not falsy + + const func = async () => job.run(); + await expect(func()).to.be.eventually.rejected; + }); + + it('permanent failure if user has no ed keypair', async () => { + Sinon.stub(UserUtils, 'getOurPubKeyStrFromCache').returns(TestUtils.generateFakePubKeyStr()); + Sinon.stub(UserUtils, 'getUserED25519KeyPairBytes').resolves(undefined); + Sinon.stub(ConvoHub.use(), 'get').resolves({}); // anything not falsy + const job = new UserSync.UserSyncJob({}); + const result = await job.run(); + expect(result).to.be.eq(RunJobResult.PermanentFailure); + }); + + it('permanent failure if user has no own conversation', async () => { + Sinon.stub(UserUtils, 'getOurPubKeyStrFromCache').returns(TestUtils.generateFakePubKeyStr()); + Sinon.stub(UserUtils, 'getUserED25519KeyPairBytes').resolves({} as any); // anything not falsy + Sinon.stub(ConvoHub.use(), 'get').returns(undefined as any); + const job = new UserSync.UserSyncJob({}); + const result = await job.run(); + expect(result).to.be.eq(RunJobResult.PermanentFailure); + }); + + it('calls pushChangesToUserSwarmIfNeeded if preconditions are fine', async () => { + Sinon.stub(UserUtils, 'getOurPubKeyStrFromCache').returns(TestUtils.generateFakePubKeyStr()); + Sinon.stub(UserUtils, 'getUserED25519KeyPairBytes').resolves({} as any); // anything not falsy + const taskedRun = Sinon.stub(UserSync, 'pushChangesToUserSwarmIfNeeded').resolves( + RunJobResult.Success + ); + Sinon.stub(ConvoHub.use(), 'get').returns({} as any); // anything not falsy + const job = new UserSync.UserSyncJob({}); + const result = await job.run(); + expect(result).to.be.eq(RunJobResult.Success); + expect(taskedRun.callCount).to.be.eq(1); + }); +}); + +describe('UserSyncJob resultsToSuccessfulChange', () => { + let sodium: LibSodiumWrappers; + beforeEach(async () => { + sodium = await getSodiumNode(); + }); + it('no or empty results return empty array', () => { + expect( + LibSessionUtil.batchResultsToUserSuccessfulChange(null, { + allOldHashes: new Set(), + messages: [], + }) + ).to.be.deep.eq([]); + + expect( + LibSessionUtil.batchResultsToUserSuccessfulChange([] as any as NotEmptyArrayOfBatchResults, { + allOldHashes: new Set(), + messages: [], + }) + ).to.be.deep.eq([]); + }); + + it('extract one result with 200 and messagehash', () => { + const profile = userChange(sodium, SnodeNamespaces.UserProfile, 321); + const contact = userChange(sodium, SnodeNamespaces.UserContacts, 123); + const batchResults: NotEmptyArrayOfBatchResults = [{ code: 200, body: { hash: 'hash1' } }]; + const request: UserDestinationChanges = { + allOldHashes: new Set(), + messages: [profile, contact], + }; + const results = LibSessionUtil.batchResultsToUserSuccessfulChange(batchResults, request); + expect(results).to.be.deep.eq([ + { + updatedHash: 'hash1', + pushed: profile, + }, + ]); + }); + + it('extract two results with 200 and messagehash', () => { + const profile = userChange(sodium, SnodeNamespaces.UserProfile, 321); + const contact = userChange(sodium, SnodeNamespaces.UserContacts, 123); + const batchResults: NotEmptyArrayOfBatchResults = [ + { code: 200, body: { hash: 'hash1' } }, + { code: 200, body: { hash: 'hash2' } }, + ]; + const request: UserDestinationChanges = { + allOldHashes: new Set(), + messages: [contact, profile], + }; + const results = LibSessionUtil.batchResultsToUserSuccessfulChange(batchResults, request); + expect(results).to.be.deep.eq([ + { + updatedHash: 'hash1', + pushed: contact, + }, + { + updatedHash: 'hash2', + pushed: profile, + }, + ]); + }); + + it('skip message hashes not a string', () => { + const profile = userChange(sodium, SnodeNamespaces.UserProfile, 321); + const contact = userChange(sodium, SnodeNamespaces.UserContacts, 123); + const batchResults: NotEmptyArrayOfBatchResults = [ + { code: 200, body: { hash: 123 as any as string } }, + { code: 200, body: { hash: 'hash2' } }, + ]; + const request: UserDestinationChanges = { + allOldHashes: new Set(), + messages: [profile, contact], + }; + const results = LibSessionUtil.batchResultsToUserSuccessfulChange(batchResults, request); + expect(results).to.be.deep.eq([ + { + updatedHash: 'hash2', + pushed: contact, + }, + ]); + }); + + it('skip request item without data', () => { + const profile = userChange(sodium, SnodeNamespaces.UserProfile, 321); + const contact = userChange(sodium, SnodeNamespaces.UserContacts, 123); + const profileNoData = omit(profile, 'ciphertext'); + const batchResults: NotEmptyArrayOfBatchResults = [ + { code: 200, body: { hash: 'hash1' } }, + { code: 200, body: { hash: 'hash2' } }, + ]; + const request: UserDestinationChanges = { + allOldHashes: new Set(), + messages: [profileNoData as any as PendingChangesForUs, contact], + }; + const results = LibSessionUtil.batchResultsToUserSuccessfulChange(batchResults, request); + expect(results).to.be.deep.eq([ + { + updatedHash: 'hash2', + pushed: contact, + }, + ]); + }); + + it('skip request item without 200 code', () => { + const profile = userChange(sodium, SnodeNamespaces.UserProfile, 321); + const contact = userChange(sodium, SnodeNamespaces.UserContacts, 123); + const batchResults: NotEmptyArrayOfBatchResults = [ + { code: 200, body: { hash: 'hash1' } }, + { code: 401, body: { hash: 'hash2' } }, + ]; + const request: UserDestinationChanges = { + allOldHashes: new Set(), + messages: [profile, contact], + }; + const results = LibSessionUtil.batchResultsToUserSuccessfulChange(batchResults, request); + expect(results).to.be.deep.eq([ + { + updatedHash: 'hash1', + pushed: profile, + }, + ]); + + // another test swapping the results + batchResults[0].code = 401; + batchResults[1].code = 200; + const results2 = LibSessionUtil.batchResultsToUserSuccessfulChange(batchResults, request); + expect(results2).to.be.deep.eq([ + { + updatedHash: 'hash2', + pushed: contact, + }, + ]); + }); +}); + +describe('UserSyncJob pushChangesToUserSwarmIfNeeded', () => { + let sessionId: PubkeyType; + let userkeys: TestUtils.TestUserKeyPairs; + let sodium: LibSodiumWrappers; + + let sendStub: TypedStub; + let pendingChangesForUsStub: TypedStub; + let dump: TypedStub; + + beforeEach(async () => { + sodium = await getSodiumNode(); + userkeys = await TestUtils.generateUserKeyPairs(); + sessionId = userkeys.x25519KeyPair.pubkeyHex; + + Sinon.stub(UserUtils, 'getOurPubKeyStrFromCache').returns(userkeys.x25519KeyPair.pubkeyHex); + Sinon.stub(UserUtils, 'getUserED25519KeyPairBytes').resolves(userkeys.ed25519KeyPair); + + window.Whisper = {}; + window.Whisper.events = {}; + window.Whisper.events.trigger = Sinon.mock(); + stubConfigDumpData('saveConfigDump').resolves(); + + pendingChangesForUsStub = Sinon.stub(LibSessionUtil, 'pendingChangesForUs'); + dump = Sinon.stub(GenericWrapperActions, 'dump').resolves(new Uint8Array()); + sendStub = Sinon.stub(MessageSender, 'sendEncryptedDataToSnode'); + }); + afterEach(() => { + Sinon.restore(); + }); + + it('call savesDumpToDb even if no changes are required on the serverside', async () => { + Sinon.stub(GenericWrapperActions, 'needsDump').resolves(true); + const result = await UserSync.pushChangesToUserSwarmIfNeeded(); + + pendingChangesForUsStub.resolves(undefined); + expect(result).to.be.eq(RunJobResult.Success); + expect(sendStub.callCount).to.be.eq(0); + expect(pendingChangesForUsStub.callCount).to.be.eq(1); + expect(dump.callCount).to.be.eq(4); + expect(dump.getCalls().map(m => m.args)).to.be.deep.eq([ + ['UserConfig'], + ['ContactsConfig'], + ['UserGroupsConfig'], + ['ConvoInfoVolatileConfig'], + ]); + }); + + it('calls sendEncryptedDataToSnode with the right data x2 and retry if network returned nothing', async () => { + Sinon.stub(GenericWrapperActions, 'needsDump').resolves(false); + + const profile = userChange(sodium, SnodeNamespaces.UserProfile, 321); + const contact = userChange(sodium, SnodeNamespaces.UserContacts, 123); + const networkTimestamp = 4444; + const ttl = TTL_DEFAULT.TTL_CONFIG; + Sinon.stub(GetNetworkTime, 'getNowWithNetworkOffset').returns(networkTimestamp); + pendingChangesForUsStub.resolves({ + messages: [profile, contact], + allOldHashes: new Set('123'), + }); + const result = await UserSync.pushChangesToUserSwarmIfNeeded(); + + sendStub.resolves(undefined); + expect(result).to.be.eq(RunJobResult.RetryJobIfPossible); // not returning anything in the sendstub so network issue happened + expect(sendStub.callCount).to.be.eq(1); + expect(pendingChangesForUsStub.callCount).to.be.eq(1); + expect(dump.callCount).to.be.eq(0); + + function expected(details: any) { + return { + namespace: details.namespace, + data: details.ciphertext, + ttl, + networkTimestamp, + pubkey: sessionId, + }; + } + throw null; // + // this test is not right. check for dump logic and make sure this matches + + const expectedProfile = expected(profile); + const expectedContact = expected(contact); + expect(sendStub.firstCall.args).to.be.deep.eq([ + [expectedProfile, expectedContact], + sessionId, + new Set('123'), + ]); + }); + + it('calls sendEncryptedDataToSnode with the right data x3 and retry if network returned nothing then success', async () => { + const profile = userChange(sodium, SnodeNamespaces.UserProfile, 321); + const contact = userChange(sodium, SnodeNamespaces.UserContacts, 123); + const groups = userChange(sodium, SnodeNamespaces.UserGroups, 111); + + throw null; // + // this test is not right. check for dump logic and make sure this matches + pendingChangesForUsStub.resolves({ + messages: [profile, contact, groups], + allOldHashes: new Set('123'), + }); + const changes: Array = [ + { + pushed: profile, + updatedHash: 'hashprofile', + }, + { + pushed: contact, + updatedHash: 'hashcontact', + }, + { + pushed: groups, + updatedHash: 'hashgroup', + }, + ]; + Sinon.stub(LibSessionUtil, 'batchResultsToUserSuccessfulChange').returns(changes); + const confirmPushed = Sinon.stub(GenericWrapperActions, 'confirmPushed').resolves(); + + const needsDump = Sinon.stub(GenericWrapperActions, 'needsDump').resolves(); + + sendStub.resolves([ + { code: 200, body: { hash: 'hashprofile' } }, + { code: 200, body: { hash: 'hashcontact' } }, + { code: 200, body: { hash: 'hashgroup' } }, + { code: 200, body: {} }, // because we are giving a set of allOldHashes + ]); + const result = await UserSync.pushChangesToUserSwarmIfNeeded(); + + expect(sendStub.callCount).to.be.eq(1); + expect(pendingChangesForUsStub.callCount).to.be.eq(1); + expect(dump.callCount).to.be.eq(2); + expect(dump.firstCall.args).to.be.deep.eq([sessionId]); + expect(needsDump.callCount).to.be.eq(4); + expect(needsDump.getCalls().map(m => m.args)).to.be.deep.eq([[sessionId, []]]); + + expect(confirmPushed.callCount).to.be.eq(4); + expect(confirmPushed.getCalls().map(m => m.args)).to.be.deep.eq([[sessionId, [123, 'hash1']]]); + expect(result).to.be.eq(RunJobResult.Success); + }); +});