diff --git a/app/lib/encryption/encryption.test.ts b/app/lib/encryption/encryption.test.ts index bfc2c312c7..342ec49125 100644 --- a/app/lib/encryption/encryption.test.ts +++ b/app/lib/encryption/encryption.test.ts @@ -167,29 +167,29 @@ describe('Encryption.encryptMessage', () => { }); }); +// Mimics a WatermelonDB Model: prepareUpdate throws while a previous prepared +// update has not been committed yet. +const makeRecord = (table: string, id: string, fields: Record = {}) => { + const record: any = { + id, + ...fields, + _preparedState: null, + prepareUpdate(recordUpdater: (m: any) => void) { + if (record._preparedState) { + throw new Error(`Cannot update a record with pending changes (${table}#${id})`); + } + recordUpdater(record); + record._preparedState = 'update'; + return record; + } + }; + return record; +}; + describe('Encryption.decryptPendingMessages', () => { const rid = 'r1'; - // Mimics a WatermelonDB Model: prepareUpdate throws while a previous prepared - // update has not been committed yet. - const makeMessageRecord = (id: string) => { - const record: any = { - id, - t: 'e2e', - msg: 'cipher', - subscription: { id: rid }, - _preparedState: null as string | null, - prepareUpdate(recordUpdater: (m: any) => void) { - if (record._preparedState) { - throw new Error(`Cannot update a record with pending changes (messages#${id})`); - } - recordUpdater(record); - record._preparedState = 'update'; - return record; - } - }; - return record; - }; + const makeMessageRecord = (id: string) => makeRecord('messages', id, { t: 'e2e', msg: 'cipher', subscription: { id: rid } }); const deferred = () => { let resolve: () => void = () => undefined; @@ -263,3 +263,39 @@ describe('Encryption.decryptPendingMessages', () => { expect(failing.msg).toBe('cipher'); }); }); + +describe('Encryption.decryptPendingSubscriptions', () => { + const makeSubscriptionRecord = (id: string) => + makeRecord('subscriptions', id, { lastMessage: { t: 'e2e', e2e: 'pending', msg: 'cipher' } }); + + beforeEach(() => { + jest.clearAllMocks(); + mockQueryRows.subscriptions = []; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('leaves no subscription prepared-but-uncommitted when one decryption rejects', async () => { + const healthy = makeSubscriptionRecord('s1'); + const failing = makeSubscriptionRecord('s2'); + mockQueryRows.subscriptions = [healthy, failing]; + jest.spyOn(encryption, 'decryptSubscription').mockImplementation((sub: any) => { + if (sub.id === failing.id) { + return Promise.reject(new Error('decrypt failed')); + } + return Promise.resolve({ lastMessage: { t: 'e2e', e2e: 'done', msg: 'plain' } } as any); + }); + + await encryption.decryptPendingSubscriptions(); + + expect(healthy._preparedState).toBeNull(); + expect(failing._preparedState).toBeNull(); + + const batched = mockDbBatch.mock.calls.flatMap(call => call.flat()); + expect(batched).toContain(healthy); + expect(healthy.lastMessage).toEqual({ t: 'e2e', e2e: 'done', msg: 'plain' }); + expect(failing.lastMessage).toEqual({ t: 'e2e', e2e: 'pending', msg: 'cipher' }); + }); +}); diff --git a/app/lib/encryption/encryption.ts b/app/lib/encryption/encryption.ts index 3261c44a91..be803ebcd4 100644 --- a/app/lib/encryption/encryption.ts +++ b/app/lib/encryption/encryption.ts @@ -1,4 +1,4 @@ -import { type Model, Q } from '@nozbe/watermelondb'; +import { Q } from '@nozbe/watermelondb'; import EJSON from 'ejson'; import { deleteAsync } from 'expo-file-system/legacy'; import { @@ -64,6 +64,7 @@ import { } from './utils'; const ROOM_KEY_EXCHANGE_SIZE = 10; + class Encryption { ready: boolean; privateKey: string | null; @@ -379,7 +380,7 @@ class Encryption { return null; } }); - await db.batch(...prepared); + await db.batch(prepared.filter(record => record !== null)); }); } catch (e) { log(e); @@ -404,9 +405,23 @@ class Encryption { sub => sub.lastMessage?.t === E2E_MESSAGE_TYPE && sub.lastMessage?.e2e !== E2E_STATUS.DONE ); - const preparedSubscriptions: (Model | null)[] = await Promise.all( + const decrypted = await Promise.all( subsEncryptedToDecrypt.map(async (sub: TSubscriptionModel) => { - const newSub = await this.decryptSubscription(sub); + try { + return { sub, newSub: await this.decryptSubscription(sub) }; + } catch (e) { + log(e); + return { sub, newSub: null }; + } + }) + ); + + if (!decrypted.length) { + return; + } + + await db.write(async () => { + const prepared = decrypted.map(({ sub, newSub }) => { try { return sub.prepareUpdate( protectedFunction((m: TSubscriptionModel) => { @@ -415,14 +430,12 @@ class Encryption { } }) ); - } catch { + } catch (e) { + log(e); return null; } - }) - ); - - await db.write(async () => { - await db.batch(preparedSubscriptions.filter((record): record is Model => record !== null)); + }); + await db.batch(prepared.filter(record => record !== null)); }); } catch (e) { log(e); diff --git a/app/lib/methods/subscriptions/rooms.ts b/app/lib/methods/subscriptions/rooms.ts index db818059bd..1f18ac1bff 100644 --- a/app/lib/methods/subscriptions/rooms.ts +++ b/app/lib/methods/subscriptions/rooms.ts @@ -51,6 +51,7 @@ const createOrUpdateSubscription = async (subscription: ISubscription, room: ISe const db = database.active; const subCollection = db.get('subscriptions'); const roomsCollection = db.get('rooms'); + const messagesCollection = db.get('messages'); if (!subscription) { try { @@ -152,72 +153,69 @@ const createOrUpdateSubscription = async (subscription: ISubscription, room: ISe const tmp = merge(subscription, room); const sub = await getSubscriptionByRoomId(tmp.rid); - const batch: Model[] = []; - if (sub) { - try { - const update = sub.prepareUpdate(s => { - Object.assign(s, tmp); - if (subscription.announcement) { - if (subscription.announcement !== sub.announcement) { - s.bannerClosed = false; - } - } - if (sub.hideUnreadStatus && subscription.hasOwnProperty('hideUnreadStatus')) { - if (sub.hideUnreadStatus !== subscription.hideUnreadStatus) { - s.hideUnreadStatus = !!subscription.hideUnreadStatus; - } - } - }); - batch.push(update); - } catch (e) { - console.log(e); - } - } else { + const { subscribedRoom } = store.getState().room; + const lastMessage = tmp.lastMessage && subscribedRoom !== tmp.rid ? buildMessage(tmp.lastMessage) : null; + const messageRecord = lastMessage ? await getMessageById(lastMessage._id) : null; + + await db.write(async () => { + const batch: Model[] = []; + try { - const create = subCollection.prepareCreate(s => { - s._raw = sanitizedRaw({ id: tmp.rid }, subCollection.schema); - Object.assign(s, tmp); - if (s.roomUpdatedAt) { - s.roomUpdatedAt = new Date(); - } - }); - batch.push(create); + if (sub) { + batch.push( + sub.prepareUpdate(s => { + Object.assign(s, tmp); + if (subscription.announcement) { + if (subscription.announcement !== sub.announcement) { + s.bannerClosed = false; + } + } + if (sub.hideUnreadStatus && subscription.hasOwnProperty('hideUnreadStatus')) { + if (sub.hideUnreadStatus !== subscription.hideUnreadStatus) { + s.hideUnreadStatus = !!subscription.hideUnreadStatus; + } + } + }) + ); + } else { + batch.push( + subCollection.prepareCreate(s => { + s._raw = sanitizedRaw({ id: tmp.rid }, subCollection.schema); + Object.assign(s, tmp); + if (s.roomUpdatedAt) { + s.roomUpdatedAt = new Date(); + } + }) + ); + } } catch (e) { - console.log(e); + log(e); } - } - const { subscribedRoom } = store.getState().room; - if (tmp.lastMessage && subscribedRoom !== tmp.rid) { - const lastMessage = buildMessage(tmp.lastMessage); - const messagesCollection = db.get('messages'); - let messageRecord = {} as TMessageModel | null; if (lastMessage) { - messageRecord = await getMessageById(lastMessage._id); - } - - if (messageRecord) { - batch.push( - messageRecord.prepareUpdate(() => { - Object.assign(messageRecord, lastMessage); - }) - ); - } else { - batch.push( - messagesCollection.prepareCreate(m => { - if (lastMessage) { - m._raw = sanitizedRaw({ id: lastMessage._id }, messagesCollection.schema); - if (m.subscription) { - m.subscription.id = lastMessage.rid; - } - } - return Object.assign(m, lastMessage); - }) - ); + try { + if (messageRecord) { + batch.push( + messageRecord.prepareUpdate(() => { + Object.assign(messageRecord, lastMessage); + }) + ); + } else { + batch.push( + messagesCollection.prepareCreate(m => { + m._raw = sanitizedRaw({ id: lastMessage._id }, messagesCollection.schema); + if (m.subscription) { + m.subscription.id = lastMessage.rid; + } + return Object.assign(m, lastMessage); + }) + ); + } + } catch (e) { + log(e); + } } - } - await db.write(async () => { await db.batch(batch); });