From cedd8e100645a3c8f28766f52fa3e3ac1ffa3c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Mon, 7 Aug 2023 12:01:04 +0200 Subject: [PATCH] chore: add e2e tests covering invalidation of other sessions (#2385) --- .../ItemsEncryption/ItemsEncryption.spec.ts | 222 ++++++++++++++ .../Domain/ItemsEncryption/ItemsEncryption.ts | 4 +- packages/snjs/mocha/lib/AppContext.js | 73 +++-- .../snjs/mocha/session-invalidation.test.js | 273 ++++-------------- 4 files changed, 320 insertions(+), 252 deletions(-) create mode 100644 packages/services/src/Domain/ItemsEncryption/ItemsEncryption.spec.ts diff --git a/packages/services/src/Domain/ItemsEncryption/ItemsEncryption.spec.ts b/packages/services/src/Domain/ItemsEncryption/ItemsEncryption.spec.ts new file mode 100644 index 000000000..5aa3a7f1d --- /dev/null +++ b/packages/services/src/Domain/ItemsEncryption/ItemsEncryption.spec.ts @@ -0,0 +1,222 @@ +import { ItemsEncryptionService } from './ItemsEncryption' +import { ItemManagerInterface } from '../Item/ItemManagerInterface' +import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface' +import { StorageServiceInterface } from '../Storage/StorageServiceInterface' +import { + DecryptedParameters, + EncryptionOperatorsInterface, + KeySystemItemsKey, + StandardException, +} from '@standardnotes/encryption' +import { KeySystemKeyManagerInterface } from '../KeySystem/KeySystemKeyManagerInterface' +import { FindDefaultItemsKey } from './../Encryption/UseCase/ItemsKey/FindDefaultItemsKey' +import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface' +import { EncryptedOutputParameters } from '@standardnotes/encryption' +import { PkcKeyPair } from '@standardnotes/sncrypto-common' +import { + DecryptedPayloadInterface, + EncryptedPayloadInterface, + ItemsKeyInterface, + KeySystemItemsKeyInterface, +} from '@standardnotes/models' + +describe('ItemsEncryptionService', () => { + let itemsEncryptionService: ItemsEncryptionService + let mockItems: jest.Mocked + let mockPayloads: jest.Mocked + let mockStorage: jest.Mocked + let mockOperators: jest.Mocked + let mockKeys: jest.Mocked + let mockFindDefaultItemsKey: jest.Mocked + let mockInternalEventBus: jest.Mocked + + beforeEach(() => { + mockItems = { + addObserver: jest.fn(), + } as unknown as jest.Mocked + mockPayloads = {} as jest.Mocked + mockStorage = {} as jest.Mocked + mockOperators = {} as jest.Mocked + mockKeys = {} as jest.Mocked + mockFindDefaultItemsKey = {} as jest.Mocked + mockInternalEventBus = {} as jest.Mocked + + itemsEncryptionService = new ItemsEncryptionService( + mockItems, + mockPayloads, + mockStorage, + mockOperators, + mockKeys, + mockFindDefaultItemsKey, + mockInternalEventBus, + ) + }) + + describe('decryptPayloadWithKeyLookup', () => { + it('returns decrypted parameters when a key is found', async () => { + const mockPayload = { + uuid: 'payload-uuid', + } as EncryptedPayloadInterface + + const mockKey = { + uuid: 'key-uuid', + } as KeySystemItemsKey + + const mockDecryptedParameters = { + uuid: 'decrypted-uuid', + content: {}, + } as DecryptedParameters + + itemsEncryptionService.keyToUseForDecryptionOfPayload = jest.fn().mockReturnValue(mockKey) + + itemsEncryptionService.decryptPayload = jest.fn().mockResolvedValue(mockDecryptedParameters) + + const result = await itemsEncryptionService.decryptPayloadWithKeyLookup(mockPayload) + + expect(itemsEncryptionService.keyToUseForDecryptionOfPayload).toHaveBeenCalledWith(mockPayload) + expect(itemsEncryptionService.decryptPayload).toHaveBeenCalledWith(mockPayload, mockKey) + expect(result).toEqual(mockDecryptedParameters) + }) + + it('returns error parameters when no key is found', async () => { + const mockPayload = { + uuid: 'payload-uuid', + } as EncryptedPayloadInterface + + itemsEncryptionService.keyToUseForDecryptionOfPayload = jest.fn().mockReturnValue(undefined) + + const result = await itemsEncryptionService.decryptPayloadWithKeyLookup(mockPayload) + + expect(itemsEncryptionService.keyToUseForDecryptionOfPayload).toHaveBeenCalledWith(mockPayload) + expect(result).toEqual({ + uuid: mockPayload.uuid, + errorDecrypting: true, + waitingForKey: true, + }) + }) + }) + + describe('keyToUseForDecryptionOfPayload', () => { + it('returns itemsKey when payload has items_key_id', () => { + const mockPayload: EncryptedPayloadInterface = { + items_key_id: 'test-key-id', + } as EncryptedPayloadInterface + + const mockItemsKey: ItemsKeyInterface = {} as ItemsKeyInterface + itemsEncryptionService.itemsKeyForEncryptedPayload = jest.fn().mockReturnValue(mockItemsKey) + + const result = itemsEncryptionService.keyToUseForDecryptionOfPayload(mockPayload) + + expect(itemsEncryptionService.itemsKeyForEncryptedPayload).toHaveBeenCalledWith(mockPayload) + expect(result).toBe(mockItemsKey) + }) + + it('returns defaultKey when payload does not have items_key_id', () => { + const mockPayload: EncryptedPayloadInterface = {} as EncryptedPayloadInterface + const mockDefaultKey: KeySystemItemsKeyInterface = {} as KeySystemItemsKeyInterface + + itemsEncryptionService.defaultItemsKeyForItemVersion = jest.fn().mockReturnValue(mockDefaultKey) + + const result = itemsEncryptionService.keyToUseForDecryptionOfPayload(mockPayload) + + expect(itemsEncryptionService.defaultItemsKeyForItemVersion).toHaveBeenCalledWith(mockPayload.version) + expect(result).toBe(mockDefaultKey) + }) + }) + + describe('encryptPayloadWithKeyLookup', () => { + it('throws an error when keyToUseForItemEncryption returns an instance of StandardException', async () => { + const mockPayload: DecryptedPayloadInterface = {} as DecryptedPayloadInterface + const mockError: StandardException = new StandardException('test-error') + + itemsEncryptionService.keyToUseForItemEncryption = jest.fn().mockReturnValue(mockError) + + await expect(() => itemsEncryptionService.encryptPayloadWithKeyLookup(mockPayload)).rejects.toThrow('test-error') + }) + + it('encrypts the payload using the provided key', async () => { + const mockPayload: DecryptedPayloadInterface = {} as DecryptedPayloadInterface + const mockKey: ItemsKeyInterface = {} as ItemsKeyInterface + const encryptedOutputParameters: EncryptedOutputParameters = { + content: 'encrypted-content', + } as EncryptedOutputParameters + + itemsEncryptionService.keyToUseForItemEncryption = jest.fn().mockReturnValue(mockKey) + itemsEncryptionService.encryptPayload = jest.fn().mockResolvedValue(encryptedOutputParameters) + + const result = await itemsEncryptionService.encryptPayloadWithKeyLookup(mockPayload) + + expect(itemsEncryptionService.encryptPayload).toHaveBeenCalledWith(mockPayload, mockKey, undefined) + expect(result).toBe(encryptedOutputParameters) + }) + }) + + describe('encryptPayload', () => { + it('throws an error when the payload has no content', async () => { + const mockPayload: DecryptedPayloadInterface = { content: null } as unknown as DecryptedPayloadInterface + const mockKey: ItemsKeyInterface = {} as ItemsKeyInterface + + await expect(() => itemsEncryptionService.encryptPayload(mockPayload, mockKey)).rejects.toThrow( + 'Attempting to encrypt payload with no content.', + ) + }) + + it('throws an error when the payload has no UUID', async () => { + const mockPayload: DecryptedPayloadInterface = { uuid: null, content: {} } as unknown as DecryptedPayloadInterface + const mockKey: ItemsKeyInterface = {} as ItemsKeyInterface + + await expect(() => itemsEncryptionService.encryptPayload(mockPayload, mockKey)).rejects.toThrow( + 'Attempting to encrypt payload with no UuidGenerator.', + ) + }) + + it('returns encrypted output parameters', async () => { + const mockPayload: DecryptedPayloadInterface = { + uuid: 'test-uuid', + content: 'test-content', + } as unknown as DecryptedPayloadInterface + const mockKey: ItemsKeyInterface = {} as ItemsKeyInterface + const encryptedOutputParameters: EncryptedOutputParameters = { + content: 'encrypted-content', + } as EncryptedOutputParameters + + jest.spyOn(itemsEncryptionService, 'encryptPayload').mockResolvedValue(encryptedOutputParameters) + + const result = await itemsEncryptionService.encryptPayload(mockPayload, mockKey) + + expect(result).toBe(encryptedOutputParameters) + }) + }) + + it('repersistAllItems', async () => { + Object.defineProperty(mockItems, 'items', { + get: jest.fn().mockReturnValue([{ payload: { uuid: '123' } }]), + }) + + mockStorage.savePayloads = jest.fn().mockResolvedValue(undefined) + + await itemsEncryptionService.repersistAllItems() + + expect(mockStorage.savePayloads).toHaveBeenCalledWith([{ uuid: '123' }]) + }) + + it('encryptPayloadWithKeyLookup', async () => { + const mockPayload: DecryptedPayloadInterface = { + key_system_identifier: 'test-identifier', + } as DecryptedPayloadInterface + + const mockKeyPair: PkcKeyPair = { publicKey: 'publicKey', privateKey: 'privateKey' } + const mockKey = { uuid: 'key-id', keyVersion: '004' } as KeySystemItemsKey + + mockKeys.getPrimaryKeySystemItemsKey = jest.fn().mockReturnValue(mockKey) + itemsEncryptionService.encryptPayload = jest + .fn() + .mockResolvedValue({ content: '004:...' } as EncryptedOutputParameters) + + const result = await itemsEncryptionService.encryptPayloadWithKeyLookup(mockPayload, mockKeyPair) + + expect(mockKeys.getPrimaryKeySystemItemsKey).toHaveBeenCalledWith(mockPayload.key_system_identifier) + expect(itemsEncryptionService.encryptPayload).toHaveBeenCalledWith(mockPayload, mockKey, mockKeyPair) + expect(result).toEqual({ content: '004:...' }) + }) +}) diff --git a/packages/services/src/Domain/ItemsEncryption/ItemsEncryption.ts b/packages/services/src/Domain/ItemsEncryption/ItemsEncryption.ts index 6b5aa504e..059e35cf3 100644 --- a/packages/services/src/Domain/ItemsEncryption/ItemsEncryption.ts +++ b/packages/services/src/Domain/ItemsEncryption/ItemsEncryption.ts @@ -97,7 +97,7 @@ export class ItemsEncryptionService extends AbstractService { return this._findDefaultItemsKey.execute(this.getItemsKeys()).getValue() } - private keyToUseForItemEncryption( + keyToUseForItemEncryption( payload: DecryptedPayloadInterface, ): ItemsKeyInterface | KeySystemItemsKeyInterface | KeySystemRootKeyInterface | StandardException { if (payload.key_system_identifier) { @@ -132,7 +132,7 @@ export class ItemsEncryptionService extends AbstractService { return result } - private keyToUseForDecryptionOfPayload( + keyToUseForDecryptionOfPayload( payload: EncryptedPayloadInterface, ): ItemsKeyInterface | KeySystemItemsKeyInterface | undefined { if (payload.items_key_id) { diff --git a/packages/snjs/mocha/lib/AppContext.js b/packages/snjs/mocha/lib/AppContext.js index d6ea82cb0..c2820adef 100644 --- a/packages/snjs/mocha/lib/AppContext.js +++ b/packages/snjs/mocha/lib/AppContext.js @@ -198,7 +198,7 @@ export class AppContext { const responses = [] - const accountPassword = this.passwordToUseForAccountPasswordChallenge || this.password + const accountPassword = this.password for (const prompt of challenge.prompts) { if (prompt.validation === ChallengeValidation.LocalPasscode) { @@ -300,6 +300,17 @@ export class AppContext { }) } + awaitNextSyncError() { + return new Promise((resolve) => { + const removeObserver = this.application.sync.addEventObserver((event, data) => { + if (event === SyncEvent.SyncError) { + removeObserver() + resolve(data) + } + }) + }) + } + awaitNextSyncEvent(eventName) { return new Promise((resolve) => { const removeObserver = this.application.sync.addEventObserver((event, data) => { @@ -322,10 +333,30 @@ export class AppContext { }) } + async restoreSession(latestPassword) { + const promise = this.resolveWhenSessionIsReauthenticated() + this.password = latestPassword + await this.sync() + await promise + await this.sync() + } + + resolveWhenSessionIsReauthenticated() { + return new Promise((resolve) => { + const removeObserver = this.application.sessions.addEventObserver((event, data) => { + if (event === SessionEvent.Restored) { + removeObserver() + resolve() + } + }) + }) + } + resolveWithUploadedPayloads() { return new Promise((resolve) => { - this.application.sync.addEventObserver((event, data) => { + const removeObserver = this.application.sync.addEventObserver((event, data) => { if (event === SyncEvent.PaginatedSyncRequestCompleted) { + removeObserver() resolve(data.uploadedPayloads) } }) @@ -334,8 +365,9 @@ export class AppContext { resolveWithSyncRetrievedPayloads() { return new Promise((resolve) => { - this.application.sync.addEventObserver((event, data) => { + const removeObserver = this.application.sync.addEventObserver((event, data) => { if (event === SyncEvent.PaginatedSyncRequestCompleted) { + removeObserver() resolve(data.retrievedPayloads) } }) @@ -344,8 +376,9 @@ export class AppContext { resolveWithConflicts() { return new Promise((resolve) => { - this.application.sync.addEventObserver((event, response) => { + const removeObserver = this.application.sync.addEventObserver((event, response) => { if (event === SyncEvent.PaginatedSyncRequestCompleted) { + removeObserver() resolve(response.rawConflictObjects) } }) @@ -354,10 +387,11 @@ export class AppContext { resolveWhenSavedSyncPayloadsIncludesItemUuid(uuid) { return new Promise((resolve) => { - this.application.sync.addEventObserver((event, response) => { + const removeObserver = this.application.sync.addEventObserver((event, response) => { if (event === SyncEvent.PaginatedSyncRequestCompleted) { const savedPayload = response.savedPayloads.find((payload) => payload.uuid === uuid) if (savedPayload) { + removeObserver() resolve() } } @@ -367,10 +401,11 @@ export class AppContext { resolveWhenSavedSyncPayloadsIncludesItemThatIsDuplicatedOf(uuid) { return new Promise((resolve) => { - this.application.sync.addEventObserver((event, response) => { + const removeObserver = this.application.sync.addEventObserver((event, response) => { if (event === SyncEvent.PaginatedSyncRequestCompleted) { const savedPayload = response.savedPayloads.find((payload) => payload.duplicate_of === uuid) if (savedPayload) { + removeObserver() resolve() } } @@ -513,6 +548,12 @@ export class AppContext { this.password = newPassword } + async changeEmail(newEmail) { + await this.application.changeEmail(newEmail, this.password, this.passcode) + + this.email = newEmail + } + findItem(uuid) { return this.application.items.findItem(uuid) } @@ -535,26 +576,6 @@ export class AppContext { } } - disableKeyRecoveryServerSignIn() { - this.keyRecovery.performServerSignIn = () => { - console.warn('application.keyRecovery.performServerSignIn has been stubbed with an empty implementation') - } - } - - preventKeyRecoveryOfKeys(ids) { - const originalImpl = this.keyRecovery.handleUndecryptableItemsKeys - - this.keyRecovery.handleUndecryptableItemsKeys = function (keys) { - const filtered = keys.filter((k) => !ids.includes(k.uuid)) - - originalImpl.apply(this, [filtered]) - } - } - - respondToAccountPasswordChallengeWith(password) { - this.passwordToUseForAccountPasswordChallenge = password - } - spyOnChangedItems(callback) { this.application.items.addObserver(ContentType.TYPES.Any, ({ changed, unerrored }) => { callback([...changed, ...unerrored]) diff --git a/packages/snjs/mocha/session-invalidation.test.js b/packages/snjs/mocha/session-invalidation.test.js index c78b1eeb2..348d68304 100644 --- a/packages/snjs/mocha/session-invalidation.test.js +++ b/packages/snjs/mocha/session-invalidation.test.js @@ -1,12 +1,10 @@ import * as Factory from './lib/factory.js' -describe.skip('session invalidation tests to revisit', function () { - this.timeout(Factory.TwentySecondTimeout) +chai.use(chaiAsPromised) +const expect = chai.expect - const syncOptions = { - checkIntegrity: true, - awaitAll: true, - } +describe('session invalidation', function () { + this.timeout(Factory.TwentySecondTimeout) beforeEach(function () { localStorage.clear() @@ -16,254 +14,81 @@ describe.skip('session invalidation tests to revisit', function () { localStorage.clear() }) - it('recovered keys with key params not matching servers should not be synced if local root key does not match server', async function () { - /** - * Assume Application A has been through these states: - * 1. Registration + Items Key A + Root Key A - * 2. Password change + Items Key B + Root Key B - * 3. Password change + Items Key C + Root Key C + Failure to correctly re-encrypt Items Key A and B with Root Key C - * - * Application B is not correctly in sync, and is only at State 1 (Registration + Items Key A) - * - * Application B receives Items Key B of Root Key B but for whatever reason ignores Items Key C of Root Key C. - * - * When it recovers Items Key B, it should not re-upload it to the server, because Application B's Root Key is not - * the current account's root key. - */ - + it('changing password on one client should invalidate other sessions', async function () { const contextA = await Factory.createAppContextWithFakeCrypto() await contextA.launch() await contextA.register() - contextA.preventKeyRecoveryOfKeys() - const contextB = await Factory.createAppContextWithFakeCrypto('app-b', contextA.email, contextA.password) + const contextB = await Factory.createAppContextWithFakeCrypto('other', contextA.email, contextA.password) await contextB.launch() await contextB.signIn() - await contextA.changePassword('new-password-1') - const itemsKeyARootKeyB = contextA.itemsKeys[0] - const itemsKeyBRootKeyB = contextA.itemsKeys[1] + await contextB.changePassword('new-password') + const note = await contextB.createSyncedNote() - contextA.disableSyncingOfItems([itemsKeyARootKeyB.uuid, itemsKeyBRootKeyB.uuid]) - await contextA.changePassword('new-password-2') - const itemsKeyCRootKeyC = contextA.itemsKeys[2] + contextA.ignoreChallenges() - contextB.disableKeyRecoveryServerSignIn() - contextB.preventKeyRecoveryOfKeys([itemsKeyCRootKeyC.uuid]) - contextB.respondToAccountPasswordChallengeWith('new-password-1') + const errorPromise = contextA.awaitNextSyncError() + await contextA.sync() + const error = await errorPromise - const recoveryPromise = Promise.all([ - contextB.resolveWhenKeyRecovered(itemsKeyARootKeyB.uuid), - contextB.resolveWhenKeyRecovered(itemsKeyBRootKeyB.uuid), - ]) - - const observedDirtyItemUuids = [] - contextB.spyOnChangedItems((changed) => { - const dirty = changed.filter((i) => i.dirty) - extendArray(observedDirtyItemUuids, Uuids(dirty)) - }) - - await contextB.sync() - await recoveryPromise - - expect(observedDirtyItemUuids.includes(itemsKeyARootKeyB.uuid)).to.equal(false) - expect(observedDirtyItemUuids.includes(itemsKeyBRootKeyB.uuid)).to.equal(false) + expect(error).to.be.ok + expect(contextA.items.findItem(note.uuid)).to.not.be.ok await contextA.deinit() await contextB.deinit() }) - it('when changing password on client B, client A should perform recovery flow', async function () { + it('changing email on one client should invalidate other sessions', async function () { + const contextA = await Factory.createAppContextWithFakeCrypto() + await contextA.launch() + await contextA.register() + + const contextB = await Factory.createAppContextWithFakeCrypto('other', contextA.email, contextA.password) + await contextB.launch() + await contextB.signIn() + + await contextB.changeEmail('new-email') + const note = await contextB.createSyncedNote() + + contextA.ignoreChallenges() + + const errorPromise = contextA.awaitNextSyncError() + await contextA.sync() + const error = await errorPromise + + expect(error).to.be.ok + expect(contextA.items.findItem(note.uuid)).to.not.be.ok + + await contextA.deinit() + await contextB.deinit() + }) + + it('should restore session on second client by reauthenticating', async function () { const contextA = await Factory.createAppContextWithFakeCrypto() await contextA.launch() await contextA.register() - const originalItemsKey = contextA.application.items.getDisplayableItemsKeys()[0] const contextB = await Factory.createAppContextWithFakeCrypto( - 'another-namespace', + new Date().toDateString(), contextA.email, contextA.password, ) - - contextB.ignoreChallenges() await contextB.launch() await contextB.signIn() - const newPassword = `${Math.random()}` + await contextB.changePassword('new-password') + const note = await contextB.createSyncedNote('foo', 'bar') - const result = await contextB.application.changePassword(contextA.password, newPassword) + await contextA.restoreSession('new-password') - expect(result.error).to.not.be.ok - expect(contextB.application.items.getAnyItems(ContentType.TYPES.ItemsKey).length).to.equal(2) - - const newItemsKey = contextB.application.items - .getDisplayableItemsKeys() - .find((k) => k.uuid !== originalItemsKey.uuid) - - const note = await Factory.createSyncedNote(contextB.application) - - const recoveryPromise = contextA.resolveWhenKeyRecovered(newItemsKey.uuid) - - contextA.password = newPassword - - await contextA.sync(syncOptions) - await recoveryPromise - - /** Same previously errored key should now no longer be errored, */ - expect(contextA.application.items.getAnyItems(ContentType.TYPES.ItemsKey).length).to.equal(2) - for (const key of contextA.application.items.getDisplayableItemsKeys()) { - expect(key.errorDecrypting).to.not.be.ok - } - - const aKey = await contextA.application.encryption.getRootKey() - const bKey = await contextB.application.encryption.getRootKey() - expect(aKey.compare(bKey)).to.equal(true) - - expect(contextA.application.items.findItem(note.uuid).errorDecrypting).to.not.be.ok - expect(contextB.application.items.findItem(note.uuid).errorDecrypting).to.not.be.ok - - expect(contextA.application.sync.isOutOfSync()).to.equal(false) - expect(contextB.application.sync.isOutOfSync()).to.equal(false) + const contextANote = contextA.items.findItem(note.uuid) + expect(contextANote).to.be.ok + expect(contextANote.errorDecrypting).to.not.be.ok + expect(contextANote.title).to.equal('foo') + expect(contextANote.text).to.equal('bar') await contextA.deinit() await contextB.deinit() - }).timeout(80000) - - it('when items key associated with item is errored, item should be marked waiting for key', async function () { - const namespace = Factory.randomString() - const newPassword = `${Math.random()}` - const contextA = await Factory.createAppContextWithFakeCrypto(namespace) - const appA = contextA.application - await appA.prepareForLaunch({ receiveChallenge: () => {} }) - await appA.launch(true) - - await Factory.registerUserToApplication({ - application: appA, - email: contextA.email, - password: contextA.password, - }) - - expect(appA.items.getItems(ContentType.TYPES.ItemsKey).length).to.equal(1) - - /** Create simultaneous appB signed into same account */ - const appB = await Factory.createApplicationWithFakeCrypto('another-namespace') - await appB.prepareForLaunch({ receiveChallenge: () => {} }) - await appB.launch(true) - - await Factory.loginToApplication({ - application: appB, - email: contextA.email, - password: contextA.password, - }) - - /** Change password on appB */ - await appB.changePassword(contextA.password, newPassword) - const note = await Factory.createSyncedNote(appB) - await appB.sync.sync() - - /** We expect the item in appA to be errored at this point, but we do not want it to recover */ - await appA.sync.sync() - expect(appA.payloads.findOne(note.uuid).waitingForKey).to.equal(true) - - console.warn('Expecting exceptions below as we destroy app during key recovery') - await Factory.safeDeinit(appA) - await Factory.safeDeinit(appB) - - const recreatedAppA = await Factory.createApplicationWithFakeCrypto(namespace) - await recreatedAppA.prepareForLaunch({ receiveChallenge: () => {} }) - await recreatedAppA.launch(true) - - expect(recreatedAppA.payloads.findOne(note.uuid).errorDecrypting).to.equal(true) - expect(recreatedAppA.payloads.findOne(note.uuid).waitingForKey).to.equal(true) - await Factory.safeDeinit(recreatedAppA) - }) - - it('errored second client should not upload its items keys', async function () { - /** - * The original source of this issue was that when changing password on client A and syncing with B, - * the newly encrypted items key retrieved on B would be included as "ignored", so its timestamps - * would not be emitted, and thus the application would be in sync. The app would then download - * the items key independently, and make duplicates erroneously. - */ - const contextA = this.context - - const email = Utils.generateUuid() - const password = Utils.generateUuid() - await Factory.registerUserToApplication({ - application: contextA.application, - email, - password: password, - }) - - const contextB = await Factory.createAppContext({ email, password }) - await contextB.launch() - await contextB.signIn() - - contextA.ignoreChallenges() - contextB.ignoreChallenges() - - const newPassword = Utils.generateUuid() - - await contextA.application.user.changeCredentials({ - currentPassword: password, - newPassword: newPassword, - origination: KeyParamsOrigination.PasswordChange, - }) - - await contextB.syncWithIntegrityCheck() - await contextA.syncWithIntegrityCheck() - - const clientAUndecryptables = contextA.keyRecovery.getUndecryptables() - const clientBUndecryptables = contextB.keyRecovery.getUndecryptables() - - expect(Object.keys(clientBUndecryptables).length).to.equal(1) - expect(Object.keys(clientAUndecryptables).length).to.equal(0) - - await contextB.deinit() - }) - - it('changing password on one client should not invalidate other sessions', async function () { - await Factory.registerUserToApplication({ - application: this.application, - email: this.email, - password: this.password, - }) - - const appA = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) - await appA.prepareForLaunch({}) - await appA.launch(true) - - const email = `${Math.random()}` - const password = `${Math.random()}` - - await Factory.registerUserToApplication({ - application: appA, - email: email, - password: password, - }) - - /** Create simultaneous appB signed into same account */ - const appB = await Factory.createApplicationWithFakeCrypto('another-namespace') - await appB.prepareForLaunch({}) - await appB.launch(true) - await Factory.loginToApplication({ - application: appB, - email: email, - password: password, - }) - - /** Change password on appB */ - const newPassword = 'random' - await appB.changePassword(password, newPassword) - - /** Create an item and sync it */ - const note = await Factory.createSyncedNote(appB) - - /** Expect appA session to still be valid */ - await appA.sync.sync() - expect(appA.items.findItem(note.uuid)).to.be.ok - - await Factory.safeDeinit(appA) - await Factory.safeDeinit(appB) }) })