chore: add e2e tests covering invalidation of other sessions (#2385)

This commit is contained in:
Karol Sójko
2023-08-07 12:01:04 +02:00
committed by GitHub
parent 4b278c4193
commit cedd8e1006
4 changed files with 320 additions and 252 deletions

View File

@@ -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])

View File

@@ -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)
})
})