From df991f7d3eeebbd85b2df10888dfc8e13b3a5ca7 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 5 Aug 2026 17:50:07 -0300 Subject: [PATCH 1/3] fix(e2ee): re-fetch subscription inside the write in toggleRoomE2EE --- .../encryption/helpers/toggleRoomE2EE.test.ts | 132 ++++++++++++++++++ app/lib/encryption/helpers/toggleRoomE2EE.ts | 13 +- 2 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 app/lib/encryption/helpers/toggleRoomE2EE.test.ts diff --git a/app/lib/encryption/helpers/toggleRoomE2EE.test.ts b/app/lib/encryption/helpers/toggleRoomE2EE.test.ts new file mode 100644 index 00000000000..0b57757254d --- /dev/null +++ b/app/lib/encryption/helpers/toggleRoomE2EE.test.ts @@ -0,0 +1,132 @@ +import { Alert, type AlertButton } from 'react-native'; + +import database from '../../database'; +import { saveRoomSettings } from '../../services/restApi'; +import { toggleRoomE2EE } from './toggleRoomE2EE'; + +jest.mock('../../database', () => ({ + __esModule: true, + default: { + active: { + get: jest.fn(), + write: jest.fn((callback: () => Promise) => callback()) + } + } +})); + +jest.mock('../../services/restApi', () => ({ + saveRoomSettings: jest.fn() +})); + +jest.mock('../../../i18n', () => ({ + __esModule: true, + default: { t: (key: string) => key } +})); + +jest.mock('../../methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +const mockGet = database.active.get as jest.Mock; +const mockSaveRoomSettings = saveRoomSettings as jest.Mock; + +/** + * Minimal stand-in for a WatermelonDB row plus the staleness check the real one performs: + * a record handle remembers the version it was fetched at, and updating it after another + * writer touched the row throws, the same way WatermelonDB rejects diverged records. + */ +const createStore = (encrypted: boolean) => { + const store = { encrypted, version: 0 }; + const updateErrors: Error[] = []; + + const find = jest.fn(() => { + const fetchedAtVersion = store.version; + return { + get encrypted() { + return store.encrypted; + }, + update: (recipe: (record: { encrypted: boolean }) => void) => { + if (store.version !== fetchedAtVersion) { + const error = new Error('record has pending changes'); + updateErrors.push(error); + throw error; + } + const draft = { encrypted: store.encrypted }; + recipe(draft); + store.encrypted = draft.encrypted; + store.version += 1; + } + }; + }); + + mockGet.mockReturnValue({ find }); + + // Simulates a stream event updating the row while the user stares at the alert + const concurrentWrite = () => { + store.version += 1; + }; + + return { store, updateErrors, concurrentWrite }; +}; + +const getAlertButton = (text: string): AlertButton => { + const buttons = (Alert.alert as jest.Mock).mock.calls[0][2] as AlertButton[]; + const button = buttons.find(b => b.text === text); + if (!button) { + throw new Error(`Alert button "${text}" not found`); + } + return button; +}; + +describe('toggleRoomE2EE', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('reverts on cancel even when a concurrent writer updated the record while the alert was open', async () => { + const { store, updateErrors, concurrentWrite } = createStore(false); + + await toggleRoomE2EE('rid-1'); + expect(store.encrypted).toBe(true); + + concurrentWrite(); + + await getAlertButton('Cancel').onPress?.(); + + expect(store.encrypted).toBe(false); + expect(updateErrors).toHaveLength(0); + }); + + it('reverts on a failed save even when a concurrent writer updated the record during the request', async () => { + const { store, updateErrors, concurrentWrite } = createStore(false); + mockSaveRoomSettings.mockImplementation(() => { + concurrentWrite(); + return Promise.reject(new Error('network error')); + }); + + await toggleRoomE2EE('rid-1'); + expect(store.encrypted).toBe(true); + + await getAlertButton('Enable').onPress?.(); + + expect(store.encrypted).toBe(false); + expect(updateErrors).toHaveLength(0); + }); + + it('keeps the new value when the save succeeds', async () => { + const { store } = createStore(false); + mockSaveRoomSettings.mockResolvedValue({ result: true }); + + await toggleRoomE2EE('rid-1'); + await getAlertButton('Enable').onPress?.(); + + expect(store.encrypted).toBe(true); + expect(mockSaveRoomSettings).toHaveBeenCalledWith('rid-1', { encrypted: true }); + }); +}); diff --git a/app/lib/encryption/helpers/toggleRoomE2EE.ts b/app/lib/encryption/helpers/toggleRoomE2EE.ts index c0d5eae6800..aa09e26372c 100644 --- a/app/lib/encryption/helpers/toggleRoomE2EE.ts +++ b/app/lib/encryption/helpers/toggleRoomE2EE.ts @@ -7,12 +7,17 @@ import log from '../../methods/helpers/log'; import I18n from '../../../i18n'; import { type TSubscriptionModel } from '../../../definitions'; -const optimisticUpdate = async (room: TSubscriptionModel, value: TSubscriptionModel['encrypted']) => { +const optimisticUpdate = async (rid: string, value: TSubscriptionModel['encrypted']) => { try { const db = database.active; // Instantly feedback to the user await db.write(async () => { + // Fetch the room again: a stream event may have updated the record while the alert was open or while the request was in flight + const room = await getSubscriptionByRoomId(rid); + if (!room) { + return; + } await room.update(r => { r.encrypted = value; }); @@ -37,7 +42,7 @@ export const toggleRoomE2EE = async (rid: string): Promise => { const newValue = !room.encrypted; // Instantly feedback to the user - await optimisticUpdate(room, newValue); + await optimisticUpdate(rid, newValue); Alert.alert( title, @@ -48,7 +53,7 @@ export const toggleRoomE2EE = async (rid: string): Promise => { style: 'cancel', onPress: async () => { // Revert to original value - await optimisticUpdate(room, !newValue); + await optimisticUpdate(rid, !newValue); } }, { @@ -68,7 +73,7 @@ export const toggleRoomE2EE = async (rid: string): Promise => { } // If something goes wrong we go back to the previous value - await optimisticUpdate(room, !newValue); + await optimisticUpdate(rid, !newValue); } catch (e) { log(e); } From 671dba9f5cdc09fc8b2b28c4568c618513edcf42 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 5 Aug 2026 18:03:20 -0300 Subject: [PATCH 2/3] code improvements --- .../encryption/helpers/toggleRoomE2EE.test.ts | 78 +++++++++++++++---- app/lib/encryption/helpers/toggleRoomE2EE.ts | 11 ++- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/app/lib/encryption/helpers/toggleRoomE2EE.test.ts b/app/lib/encryption/helpers/toggleRoomE2EE.test.ts index 0b57757254d..095d7dc84b8 100644 --- a/app/lib/encryption/helpers/toggleRoomE2EE.test.ts +++ b/app/lib/encryption/helpers/toggleRoomE2EE.test.ts @@ -1,4 +1,4 @@ -import { Alert, type AlertButton } from 'react-native'; +import { Alert, type AlertButton, type AlertOptions } from 'react-native'; import database from '../../database'; import { saveRoomSettings } from '../../services/restApi'; @@ -31,22 +31,39 @@ jest.mock('../../methods/helpers/log', () => ({ const mockGet = database.active.get as jest.Mock; const mockSaveRoomSettings = saveRoomSettings as jest.Mock; +interface IFakeRow { + encrypted: boolean; + version: number; +} + +interface IFakeRecord { + readonly encrypted: boolean; + update: (recipe: (record: { encrypted: boolean }) => void) => void; +} + +interface IFakeStore { + store: IFakeRow; + updateErrors: Error[]; + /** Simulates a stream event updating the row while the user stares at the alert */ + concurrentWrite: () => void; +} + /** * Minimal stand-in for a WatermelonDB row plus the staleness check the real one performs: * a record handle remembers the version it was fetched at, and updating it after another * writer touched the row throws, the same way WatermelonDB rejects diverged records. */ -const createStore = (encrypted: boolean) => { - const store = { encrypted, version: 0 }; +const createStore = (encrypted: boolean): IFakeStore => { + const store: IFakeRow = { encrypted, version: 0 }; const updateErrors: Error[] = []; - const find = jest.fn(() => { + const find = jest.fn((): IFakeRecord => { const fetchedAtVersion = store.version; return { - get encrypted() { + get encrypted(): boolean { return store.encrypted; }, - update: (recipe: (record: { encrypted: boolean }) => void) => { + update: (recipe: (record: { encrypted: boolean }) => void): void => { if (store.version !== fetchedAtVersion) { const error = new Error('record has pending changes'); updateErrors.push(error); @@ -62,21 +79,29 @@ const createStore = (encrypted: boolean) => { mockGet.mockReturnValue({ find }); - // Simulates a stream event updating the row while the user stares at the alert - const concurrentWrite = () => { + const concurrentWrite = (): void => { store.version += 1; }; return { store, updateErrors, concurrentWrite }; }; -const getAlertButton = (text: string): AlertButton => { +const pressAlertButton = async (text: string): Promise => { const buttons = (Alert.alert as jest.Mock).mock.calls[0][2] as AlertButton[]; const button = buttons.find(b => b.text === text); if (!button) { throw new Error(`Alert button "${text}" not found`); } - return button; + await (button.onPress as (() => Promise) | undefined)?.(); +}; + +/** Android: tapping outside the alert only fires the options' onDismiss */ +const dismissAlert = async (): Promise => { + const options = (Alert.alert as jest.Mock).mock.calls[0][3] as AlertOptions | undefined; + if (!options?.onDismiss) { + throw new Error('Alert has no onDismiss handler'); + } + await (options.onDismiss as () => Promise)(); }; describe('toggleRoomE2EE', () => { @@ -97,7 +122,34 @@ describe('toggleRoomE2EE', () => { concurrentWrite(); - await getAlertButton('Cancel').onPress?.(); + await pressAlertButton('Cancel'); + + expect(store.encrypted).toBe(false); + expect(updateErrors).toHaveLength(0); + }); + + it('reverts when the alert is dismissed by tapping outside it', async () => { + const { store, updateErrors } = createStore(false); + + await toggleRoomE2EE('rid-1'); + expect(store.encrypted).toBe(true); + + await dismissAlert(); + + expect(store.encrypted).toBe(false); + expect(updateErrors).toHaveLength(0); + expect(mockSaveRoomSettings).not.toHaveBeenCalled(); + }); + + it('reverts on an outside dismissal even when a concurrent writer updated the record', async () => { + const { store, updateErrors, concurrentWrite } = createStore(false); + + await toggleRoomE2EE('rid-1'); + expect(store.encrypted).toBe(true); + + concurrentWrite(); + + await dismissAlert(); expect(store.encrypted).toBe(false); expect(updateErrors).toHaveLength(0); @@ -113,7 +165,7 @@ describe('toggleRoomE2EE', () => { await toggleRoomE2EE('rid-1'); expect(store.encrypted).toBe(true); - await getAlertButton('Enable').onPress?.(); + await pressAlertButton('Enable'); expect(store.encrypted).toBe(false); expect(updateErrors).toHaveLength(0); @@ -124,7 +176,7 @@ describe('toggleRoomE2EE', () => { mockSaveRoomSettings.mockResolvedValue({ result: true }); await toggleRoomE2EE('rid-1'); - await getAlertButton('Enable').onPress?.(); + await pressAlertButton('Enable'); expect(store.encrypted).toBe(true); expect(mockSaveRoomSettings).toHaveBeenCalledWith('rid-1', { encrypted: true }); diff --git a/app/lib/encryption/helpers/toggleRoomE2EE.ts b/app/lib/encryption/helpers/toggleRoomE2EE.ts index aa09e26372c..91021e2fa37 100644 --- a/app/lib/encryption/helpers/toggleRoomE2EE.ts +++ b/app/lib/encryption/helpers/toggleRoomE2EE.ts @@ -7,7 +7,7 @@ import log from '../../methods/helpers/log'; import I18n from '../../../i18n'; import { type TSubscriptionModel } from '../../../definitions'; -const optimisticUpdate = async (rid: string, value: TSubscriptionModel['encrypted']) => { +const optimisticUpdate = async (rid: string, value: TSubscriptionModel['encrypted']): Promise => { try { const db = database.active; @@ -80,6 +80,13 @@ export const toggleRoomE2EE = async (rid: string): Promise => { } } ], - { cancelable: true } + { + cancelable: true, + // Android only: tapping outside the alert dismisses it without calling any button's onPress + onDismiss: async () => { + // Revert to original value + await optimisticUpdate(rid, !newValue); + } + } ); }; From 28126943918872ff7468d673eda482d467119150 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 13 Aug 2026 11:48:13 -0300 Subject: [PATCH 3/3] removed unused comment --- app/lib/encryption/helpers/toggleRoomE2EE.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/lib/encryption/helpers/toggleRoomE2EE.ts b/app/lib/encryption/helpers/toggleRoomE2EE.ts index 91021e2fa37..0b2e11edb01 100644 --- a/app/lib/encryption/helpers/toggleRoomE2EE.ts +++ b/app/lib/encryption/helpers/toggleRoomE2EE.ts @@ -13,7 +13,6 @@ const optimisticUpdate = async (rid: string, value: TSubscriptionModel['encrypte // Instantly feedback to the user await db.write(async () => { - // Fetch the room again: a stream event may have updated the record while the alert was open or while the request was in flight const room = await getSubscriptionByRoomId(rid); if (!room) { return;