diff --git a/.github/scripts/__tests__/fixtures/scenario-catalog.json b/.github/scripts/__tests__/fixtures/scenario-catalog.json index cb774dd3f5..f44d329892 100644 --- a/.github/scripts/__tests__/fixtures/scenario-catalog.json +++ b/.github/scripts/__tests__/fixtures/scenario-catalog.json @@ -102,7 +102,7 @@ "name": "shared view fans wide: RoomView -> nine flows", "category": "real-domain", "input": { "diff": ["app/views/RoomView/index.tsx"] }, - "expectedShards": [3, 7, 9, 11, 12, 13, 14], + "expectedShards": [3, 5, 7, 9, 12, 13, 14], "expectedShouldRun": true, "assertableIn": ["map", "ci"] }, diff --git a/.github/workflows/maestro-ios.yml b/.github/workflows/maestro-ios.yml index 25964602f1..3cf48779f5 100644 --- a/.github/workflows/maestro-ios.yml +++ b/.github/workflows/maestro-ios.yml @@ -14,7 +14,7 @@ jobs: runs-on: macos-26 permissions: contents: read - timeout-minutes: 40 + timeout-minutes: 65 env: MAESTRO_VERSION: 2.5.1 @@ -132,7 +132,7 @@ jobs: run: chmod +x .github/scripts/run-maestro.sh - name: Run Maestro Tests - timeout-minutes: 30 + timeout-minutes: 45 run: ./.github/scripts/run-maestro.sh ios ${{ inputs.shard }} - name: Upload Maestro Logs diff --git a/.maestro/tests/room/jump-to-message.yaml b/.maestro/tests/room/jump-to-message.yaml index 553a4fe587..e6cfb9dcd0 100644 --- a/.maestro/tests/room/jump-to-message.yaml +++ b/.maestro/tests/room/jump-to-message.yaml @@ -5,7 +5,7 @@ onFlowStart: onFlowComplete: - evalScript: ${output.utils.deleteCreatedUsers()} tags: - - test-11 + - test-5 --- - runFlow: diff --git a/app/sagas/__tests__/encryption.settingsRace.test.ts b/app/sagas/__tests__/encryption.settingsRace.test.ts new file mode 100644 index 0000000000..2f9fe95e4e --- /dev/null +++ b/app/sagas/__tests__/encryption.settingsRace.test.ts @@ -0,0 +1,167 @@ +jest.mock('../../lib/methods/userPreferences', () => ({ + __esModule: true, + default: { + getString: jest.fn() + } +})); + +jest.mock('../../lib/services/restApi', () => ({ + e2eFetchMyKeys: jest.fn() +})); + +jest.mock('../../lib/methods/readMessages', () => ({ + readMessages: jest.fn() +})); + +jest.mock('../../lib/methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('../../lib/encryption', () => ({ + Encryption: { + initialize: jest.fn(), + persistKeys: jest.fn(), + createKeys: jest.fn(), + decodePrivateKey: jest.fn(), + stop: jest.fn() + } +})); + +const mockServersFind = jest.fn(); + +jest.mock('../../lib/database', () => ({ + __esModule: true, + default: { + servers: { + get: jest.fn(() => ({ + find: () => mockServersFind() + })) + } + } +})); + +import encryptionRoot from '../encryption'; +import { encryptionInit } from '../../actions/encryption'; +import { addSettings } from '../../actions/settings'; +import { setUser, logout } from '../../actions/login'; +import { selectServerSuccess, selectServerRequest } from '../../actions/server'; +import UserPreferences from '../../lib/methods/userPreferences'; +import { e2eFetchMyKeys } from '../../lib/services/restApi'; +import { Encryption } from '../../lib/encryption'; +import { E2E_BANNER_TYPE } from '../../lib/constants/keys'; +import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; +import type { RecordingStore } from '../../lib/testUtils/sagaStore'; + +const setupStore = (): RecordingStore => createRecordingStore(encryptionRoot); + +const SERVER_URL = 'https://open.rocket.chat'; +const USER = { id: 'user-1', token: 'token-1', username: 'diego' }; + +const selectServerAndLogin = (store: RecordingStore['store']) => { + store.dispatch(selectServerSuccess({ server: SERVER_URL, version: '7.0.0', name: 'Open' })); + store.dispatch(setUser(USER)); +}; + +describe('encryption saga — settings arriving after ENCRYPTION.INIT', () => { + beforeEach(() => { + jest.mocked(UserPreferences.getString).mockReturnValue(null); + jest.mocked(e2eFetchMyKeys).mockResolvedValue({ privateKey: 'private-key' } as any); + mockServersFind.mockReset().mockRejectedValue(new Error('not found')); + }); + + afterEach(() => { + cancelSagaTasks(); + }); + + it('sets the banner once E2E_Enable arrives after ENCRYPTION.INIT', async () => { + const { store } = setupStore(); + selectServerAndLogin(store); + + store.dispatch(encryptionInit()); + await flushSagaMicrotasks(); + + expect(store.getState().encryption.banner).toBe(''); + + store.dispatch(addSettings({ E2E_Enable: true })); + await flushSagaMicrotasks(); + + expect(store.getState().encryption.banner).toBe(E2E_BANNER_TYPE.REQUEST_PASSWORD); + expect(Encryption.initialize).not.toHaveBeenCalled(); + }); + + it('sets the banner immediately when settings are already present', async () => { + const { store } = setupStore(); + selectServerAndLogin(store); + store.dispatch(addSettings({ E2E_Enable: true })); + + store.dispatch(encryptionInit()); + await flushSagaMicrotasks(); + + expect(store.getState().encryption.banner).toBe(E2E_BANNER_TYPE.REQUEST_PASSWORD); + }); + + it('leaves the banner unset when E2E_Enable arrives as false', async () => { + const { store } = setupStore(); + selectServerAndLogin(store); + + store.dispatch(encryptionInit()); + await flushSagaMicrotasks(); + + store.dispatch(addSettings({ E2E_Enable: false })); + await flushSagaMicrotasks(); + + expect(store.getState().encryption.banner).toBe(''); + expect(Encryption.initialize).not.toHaveBeenCalled(); + }); + + it('sets the banner when the servers row exists with E2E_Enable undefined and settings arrive later', async () => { + mockServersFind.mockResolvedValue({ E2E_Enable: undefined }); + + const { store } = setupStore(); + selectServerAndLogin(store); + + store.dispatch(encryptionInit()); + await flushSagaMicrotasks(); + + expect(store.getState().encryption.banner).toBe(''); + + store.dispatch(addSettings({ E2E_Enable: true })); + await flushSagaMicrotasks(); + + expect(store.getState().encryption.banner).toBe(E2E_BANNER_TYPE.REQUEST_PASSWORD); + }); + + it('stops waiting for settings once the user logs out', async () => { + const { store } = setupStore(); + selectServerAndLogin(store); + + store.dispatch(encryptionInit()); + await flushSagaMicrotasks(); + + store.dispatch(logout(true)); + await flushSagaMicrotasks(); + + store.dispatch(addSettings({ E2E_Enable: true })); + await flushSagaMicrotasks(); + + expect(store.getState().encryption.banner).toBe(''); + }); + + it('stops waiting for settings once a server switch is requested', async () => { + const { store } = setupStore(); + selectServerAndLogin(store); + + store.dispatch(encryptionInit()); + await flushSagaMicrotasks(); + + store.dispatch(selectServerRequest('https://other.rocket.chat', '7.0.0')); + await flushSagaMicrotasks(); + + store.dispatch(addSettings({ E2E_Enable: true })); + await flushSagaMicrotasks(); + + expect(store.getState().encryption.banner).toBe(''); + expect(Encryption.initialize).not.toHaveBeenCalled(); + }); +}); diff --git a/app/sagas/encryption.js b/app/sagas/encryption.js index f89be7cce4..59b803f672 100644 --- a/app/sagas/encryption.js +++ b/app/sagas/encryption.js @@ -1,7 +1,7 @@ import EJSON from 'ejson'; -import { put, select, takeLatest } from 'redux-saga/effects'; +import { put, race, select, take, takeLatest } from 'redux-saga/effects'; -import { ENCRYPTION } from '../actions/actionsTypes'; +import { ENCRYPTION, LOGOUT, SERVER, SETTINGS } from '../actions/actionsTypes'; import { encryptionDecodeKeyFailure, encryptionSet } from '../actions/encryption'; import { Encryption } from '../lib/encryption'; import database from '../lib/database'; @@ -15,6 +15,23 @@ import { readMessages } from '../lib/methods/readMessages'; const getServer = state => state.server.server; const getE2eEnable = state => state.settings.E2E_Enable; +const waitForE2eEnable = function* waitForE2eEnable() { + let e2eEnable; + while (e2eEnable === undefined) { + const { logout, selectServer } = yield race({ + settingsAdded: take(SETTINGS.ADD), + logout: take(LOGOUT), + selectServer: take(SERVER.SELECT_REQUEST) + }); + if (logout || selectServer) { + return; + } + + e2eEnable = yield select(getE2eEnable); + } + return e2eEnable; +}; + const handleEncryptionInit = function* handleEncryptionInit() { try { const server = yield select(getServer); @@ -31,8 +48,13 @@ const handleEncryptionInit = function* handleEncryptionInit() { // Server not found } + let e2eEnable = E2E_Enable; + if (serverInfo?.E2E_Enable === undefined && E2E_Enable === undefined) { + e2eEnable = yield* waitForE2eEnable(); + } + // If E2E is disabled on server, skip - if (!serverInfo?.E2E_Enable && !E2E_Enable) { + if (!serverInfo?.E2E_Enable && !e2eEnable) { return; }