diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 0cddfdadac..406ea6dcf5 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -93,14 +93,15 @@ jest.mock('../../lib/methods/helpers', () => ({ // ─── Real imports (after mocks) ─────────────────────────────────────────────── import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLinking'; -import { loginSuccess } from '../../actions/login'; -import { selectServerSuccess } from '../../actions/server'; +import { loginFailure, loginSuccess } from '../../actions/login'; +import { selectServerFailure, selectServerSuccess } from '../../actions/server'; import { appStart } from '../../actions/app'; -import { APP, SERVER } from '../../actions/actionsTypes'; +import { APP, LOGOUT, SERVER } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import deepLinkingRoot from '../deepLinking'; import UserPreferences from '../../lib/methods/userPreferences'; import { getServerById } from '../../lib/database/services/Server'; +import { localAuthenticate } from '../../lib/methods/helpers/localAuthentication'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; import { getServerInfo } from '../../lib/methods/getServerInfo'; import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom'; @@ -601,3 +602,94 @@ describe('deepLinking saga — unknown host hands off to the add-server flow', ( emit.mockRestore(); }); }); + +describe('deepLinking saga — handleShareExtension user-facing roots', () => { + beforeEach(() => { + jest.mocked(UserPreferences.getString).mockReset(); + jest.mocked(getServerById).mockReset(); + jest.mocked(UserPreferences.getString).mockImplementation((key: string) => { + if (key === 'currentServer') return HOST; + return makeStoredUser(); + }); + jest.mocked(sdk).current.client.host = ''; + }); + + afterEach(() => { + cancelSagaTasks(); + jest.mocked(sdk).current.client.host = ''; + }); + + it('lands on ROOT_OUTSIDE, not the loading root, when the server record is missing', async () => { + jest.mocked(getServerById).mockResolvedValue(null as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when the login that the share sheet waits on fails', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + expect(store.getState().app.root).toBe(RootEnum.ROOT_LOADING_SHARE_EXTENSION); + + store.dispatch(loginFailure({ message: 'connect failed' })); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when selecting the server fails while the share sheet waits', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + store.dispatch(selectServerFailure()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when the server logs the share sheet out instead of failing the login', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + store.dispatch({ type: LOGOUT }); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when local authentication throws', async () => { + jest.mocked(localAuthenticate).mockRejectedValueOnce(new Error('biometrics unavailable')); + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('still reaches ROOT_SHARE_EXTENSION when the login succeeds', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: TOKEN } as any)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION); + }); +}); diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts new file mode 100644 index 0000000000..b23547588b --- /dev/null +++ b/app/sagas/__tests__/init.test.ts @@ -0,0 +1,201 @@ +jest.mock('../../lib/methods/userPreferences', () => ({ + __esModule: true, + default: { + getString: jest.fn() + } +})); + +jest.mock('../../lib/database/services/Server', () => ({ + getServerById: jest.fn() +})); + +jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ + localAuthenticate: jest.fn() +})); + +jest.mock('../../lib/methods/userPreferencesMethods', () => ({ + getSortPreferences: jest.fn(() => ({})) +})); + +jest.mock('react-native-bootsplash', () => ({ + __esModule: true, + default: { hide: jest.fn(() => Promise.resolve()) } +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve(null)) + } +})); + +jest.mock('../../lib/database', () => ({ + __esModule: true, + default: { + servers: { + get: jest.fn() + } + } +})); + +import RNBootSplash from 'react-native-bootsplash'; + +import { appInit, appStart } from '../../actions/app'; +import { RootEnum } from '../../definitions'; +import initRoot from '../init'; +import UserPreferences from '../../lib/methods/userPreferences'; +import { getServerById } from '../../lib/database/services/Server'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { DEEP_LINKING } from '../../actions/actionsTypes'; +import { TOKEN_KEY } from '../../lib/constants/keys'; +import database from '../../lib/database'; +import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; +import type { RecordingStore } from '../../lib/testUtils/sagaStore'; + +const setupStore = (): RecordingStore => createRecordingStore(initRoot); + +const HOST = 'https://open.rocket.chat'; +const OTHER_HOST = 'https://other.rocket.chat'; + +describe('init saga — restore user-facing roots', () => { + beforeEach(() => { + jest.mocked(UserPreferences.getString).mockReset(); + jest.mocked(getServerById).mockReset(); + jest.mocked(RNBootSplash.hide).mockClear(); + jest.mocked(AsyncStorage.getItem).mockResolvedValue(null as any); + jest.mocked(AsyncStorage.removeItem).mockClear(); + jest.mocked(database.servers.get).mockReset(); + jest.mocked(UserPreferences.getString).mockImplementation(() => HOST); + }); + + afterEach(() => { + cancelSagaTasks(); + }); + + it('lands on ROOT_OUTSIDE and hides the splash when the stored server has no database record', async () => { + jest.mocked(getServerById).mockResolvedValue(null); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + expect(jest.mocked(RNBootSplash.hide)).toHaveBeenCalled(); + }); + + it('marks the app ready when the stored server has no database record', async () => { + jest.mocked(getServerById).mockResolvedValue(null); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.ready).toBe(true); + }); + + it('lands on ROOT_OUTSIDE when no server is stored at all', async () => { + jest.mocked(UserPreferences.getString).mockImplementation(() => null); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + expect(store.getState().app.ready).toBe(true); + }); + + it('lands on ROOT_OUTSIDE when neither the stored server nor any other has a token', async () => { + jest.mocked(UserPreferences.getString).mockImplementation(key => (key.startsWith(`${TOKEN_KEY}-`) ? null : HOST)); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: () => Promise.resolve([{ id: OTHER_HOST, version: '7.0.0' }]) }) + } as any); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + expect(store.getState().app.ready).toBe(true); + }); + + it('selects another logged in server with its own version when the stored server has no token', async () => { + jest.mocked(UserPreferences.getString).mockImplementation(key => { + if (key === `${TOKEN_KEY}-${OTHER_HOST}`) return 'token'; + if (key.startsWith(`${TOKEN_KEY}-`)) return null; + return HOST; + }); + jest.mocked(database.servers.get).mockReturnValue({ + query: () => ({ fetch: () => Promise.resolve([{ id: OTHER_HOST, version: '7.0.0' }]) }) + } as any); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().server.server).toBe(OTHER_HOST); + expect(store.getState().server.version).toBe('7.0.0'); + expect(store.getState().app.ready).toBe(true); + }); + + it('delivers the pending push notification without stranding the boot', async () => { + jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); + jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any); + const { store, dispatchedActions } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(dispatchedActions).toContainEqual({ type: DEEP_LINKING.OPEN_VIDEO_CONF, params: { rid: 'room-1' } }); + expect(store.getState().server.server).toBe(HOST); + }); + + it('keeps the selected server when the stored push notification payload is malformed', async () => { + jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); + jest.mocked(AsyncStorage.getItem).mockResolvedValue('not json' as any); + const { store, dispatchedActions } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().server.server).toBe(HOST); + expect(store.getState().app.root).not.toBe(RootEnum.ROOT_OUTSIDE); + expect(dispatchedActions).not.toContainEqual(expect.objectContaining({ type: DEEP_LINKING.OPEN_VIDEO_CONF })); + }); + + it('delivers the pending push notification even when the root has already moved outside', async () => { + jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); + jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any); + const { store, dispatchedActions } = setupStore(); + + store.dispatch(appInit()); + store.dispatch(appStart({ root: RootEnum.ROOT_OUTSIDE })); + await flushSagaMicrotasks(); + + expect(dispatchedActions).toContainEqual({ type: DEEP_LINKING.OPEN_VIDEO_CONF, params: { rid: 'room-1' } }); + }); + + it('drops the pending push notification when the boot lands on ROOT_OUTSIDE', async () => { + jest.mocked(getServerById).mockResolvedValue(null); + jest.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify({ rid: 'room-1' }) as any); + const { store, dispatchedActions } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + expect(jest.mocked(AsyncStorage.removeItem)).toHaveBeenCalledWith('pushNotification'); + expect(dispatchedActions).not.toContainEqual(expect.objectContaining({ type: DEEP_LINKING.OPEN_VIDEO_CONF })); + }); + + it('selects the stored server and marks the app ready when the record exists', async () => { + jest.mocked(getServerById).mockResolvedValue({ id: HOST, version: '6.0.0' } as any); + const { store } = setupStore(); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(store.getState().app.ready).toBe(true); + expect(store.getState().server.server).toBe(HOST); + }); +}); diff --git a/app/sagas/__tests__/selectServer.test.ts b/app/sagas/__tests__/selectServer.test.ts index 204cd8e782..7302338e5a 100644 --- a/app/sagas/__tests__/selectServer.test.ts +++ b/app/sagas/__tests__/selectServer.test.ts @@ -65,6 +65,8 @@ import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import selectServerRoot from '../selectServer'; import { selectServerRequest } from '../../actions/server'; +import { appStart } from '../../actions/app'; +import { RootEnum } from '../../definitions'; import { SERVER } from '../../actions/actionsTypes'; import UserPreferences from '../../lib/methods/userPreferences'; import { BASIC_AUTH_KEY, setBasicAuth } from '../../lib/methods/helpers/fetch'; @@ -202,3 +204,55 @@ describe('selectServer saga — version and name fallback', () => { expect(success).toMatchObject({ server: SERVER_URL, version: '6.9.0', name: 'Stored A' }); }); }); + +describe('selectServer saga — user-facing root after a failed switch', () => { + beforeEach(() => { + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + jest.mocked(getLoggedUserById).mockRejectedValue(new Error('database unavailable')); + }); + + it('lands on ROOT_OUTSIDE when the switch fails during boot, before any root is set', async () => { + const { store } = setupStore(); + expect(store.getState().app.root).toBeUndefined(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when the switch fails while the app is on the loading root', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_LOADING })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when the switch fails while the share sheet is on its loading root', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('keeps the current root when the switch fails while the app is already inside', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_INSIDE); + }); + + it('keeps the current root when the switch fails while the share sheet is up', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION); + }); +}); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index fac9e3292d..680e6b5e80 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -1,7 +1,7 @@ import { InteractionManager } from 'react-native'; import RNCallKeep from 'react-native-callkeep'; import I18n from 'i18n-js'; -import { all, call, delay, put, select, take, takeLatest } from 'redux-saga/effects'; +import { all, call, delay, put, race, select, take, takeLatest } from 'redux-saga/effects'; import { shareSetParams } from '../actions/share'; import * as types from '../actions/actionsTypes'; @@ -153,17 +153,32 @@ const handleShareExtension = function* handleOpen({ params }) { } yield put(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION })); - yield localAuthenticate(server); - const serverRecord = yield getServerById(server); - if (!serverRecord) { - return; - } - yield put(selectServerRequest(server, serverRecord.version)); - if (sdk.current?.client?.host !== server) { - yield take(types.LOGIN.SUCCESS); + try { + yield localAuthenticate(server); + const serverRecord = yield getServerById(server); + if (!serverRecord) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + return; + } + yield put(selectServerRequest(server, serverRecord.version)); + if (sdk.current?.client?.host !== server) { + const { loginSuccess } = yield race({ + loginSuccess: take(types.LOGIN.SUCCESS), + loginFailure: take(types.LOGIN.FAILURE), + selectServerFailure: take(types.SERVER.SELECT_FAILURE), + logout: take(types.LOGOUT) + }); + if (!loginSuccess) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + return; + } + } + yield put(shareSetParams(params)); + yield put(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); + } catch (e) { + log(e); + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } - yield put(shareSetParams(params)); - yield put(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); }; const handleOpen = function* handleOpen({ params }) { diff --git a/app/sagas/init.js b/app/sagas/init.js index 4e4a89a38b..7dc6ff9f66 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -21,43 +21,47 @@ export const initLocalSettings = function* initLocalSettings() { yield put(setAllPreferences(sortPreferences)); }; +const isLoggedIn = server => !!UserPreferences.getString(`${TOKEN_KEY}-${server}`); + +const serverToRestore = function* serverToRestore(server) { + if (!server) { + return null; + } + + if (!isLoggedIn(server)) { + const serversDB = database.servers; + const serversCollection = serversDB.get('servers'); + const servers = yield serversCollection.query().fetch(); + + return servers.find(({ id }) => isLoggedIn(id)) || null; + } + + yield localAuthenticate(server); + return (yield getServerById(server)) || null; +}; + const restore = function* restore() { try { const server = UserPreferences.getString(CURRENT_SERVER); - let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const restoredServer = yield* serverToRestore(server); - if (!server) { - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); - } else if (!userId) { - const serversDB = database.servers; - const serversCollection = serversDB.get('servers'); - const servers = yield serversCollection.query().fetch(); - - // Check if there're other logged in servers and picks first one - if (servers.length > 0) { - for (let i = 0; i < servers.length; i += 1) { - const { id: newServer, version } = servers[i]; - userId = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); - if (userId) { - return yield put(selectServerRequest(newServer, version)); - } - } - } - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + if (restoredServer) { + yield put(selectServerRequest(restoredServer.id, restoredServer.version)); } else { - yield localAuthenticate(server); - const serverRecord = yield getServerById(server); - if (!serverRecord) { - return; - } - yield put(selectServerRequest(server, serverRecord.version)); + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } yield put(appReady({})); const pushNotification = yield call(AsyncStorage.getItem, 'pushNotification'); if (pushNotification) { - const pushNotification = yield call(AsyncStorage.removeItem, 'pushNotification'); - yield call(deepLinkingClickCallPush, JSON.parse(pushNotification)); + yield call(AsyncStorage.removeItem, 'pushNotification'); + if (restoredServer) { + try { + yield put(deepLinkingClickCallPush(JSON.parse(pushNotification))); + } catch (e) { + log(e); + } + } } } catch (e) { log(e); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fc..a203c59f3f 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -218,6 +218,10 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch yield put(selectServerSuccess({ server, version: serverVersion, name: serverInfo?.name || 'Rocket.Chat' })); } catch (e) { yield put(selectServerFailure()); + const currentRoot = yield* appSelector(state => state.app.root); + if (currentRoot !== RootEnum.ROOT_INSIDE && currentRoot !== RootEnum.ROOT_SHARE_EXTENSION) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + } log(e); } };