chore: add e2e tests covering invalidation of other sessions (#2385)
This commit is contained in:
@@ -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<ItemManagerInterface>
|
||||
let mockPayloads: jest.Mocked<PayloadManagerInterface>
|
||||
let mockStorage: jest.Mocked<StorageServiceInterface>
|
||||
let mockOperators: jest.Mocked<EncryptionOperatorsInterface>
|
||||
let mockKeys: jest.Mocked<KeySystemKeyManagerInterface>
|
||||
let mockFindDefaultItemsKey: jest.Mocked<FindDefaultItemsKey>
|
||||
let mockInternalEventBus: jest.Mocked<InternalEventBusInterface>
|
||||
|
||||
beforeEach(() => {
|
||||
mockItems = {
|
||||
addObserver: jest.fn(),
|
||||
} as unknown as jest.Mocked<ItemManagerInterface>
|
||||
mockPayloads = {} as jest.Mocked<PayloadManagerInterface>
|
||||
mockStorage = {} as jest.Mocked<StorageServiceInterface>
|
||||
mockOperators = {} as jest.Mocked<EncryptionOperatorsInterface>
|
||||
mockKeys = {} as jest.Mocked<KeySystemKeyManagerInterface>
|
||||
mockFindDefaultItemsKey = {} as jest.Mocked<FindDefaultItemsKey>
|
||||
mockInternalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
|
||||
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:...' })
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user