diff --git a/.github/scripts/__tests__/fixtures/scenario-catalog.json b/.github/scripts/__tests__/fixtures/scenario-catalog.json index ac8c59666cf..cb774dd3f5c 100644 --- a/.github/scripts/__tests__/fixtures/scenario-catalog.json +++ b/.github/scripts/__tests__/fixtures/scenario-catalog.json @@ -108,10 +108,10 @@ }, { "id": "C3", - "name": "shared saga fans wide: sagas/rooms.js -> twelve flows", + "name": "shared saga fans wide: sagas/rooms.js -> thirteen flows", "category": "real-domain", "input": { "diff": ["app/sagas/rooms.js"] }, - "expectedShards": [1, 5, 6, 7, 8, 11, 12, 13, 14], + "expectedShards": [1, 3, 5, 6, 7, 8, 11, 12, 13, 14], "expectedShouldRun": true, "assertableIn": ["map", "ci"] }, diff --git a/.maestro/scripts/data-setup.js b/.maestro/scripts/data-setup.js index e5e1f06d46c..39c239541d8 100644 --- a/.maestro/scripts/data-setup.js +++ b/.maestro/scripts/data-setup.js @@ -263,5 +263,6 @@ output.utils = { post, login, getDeepLink, - createDM + createDM, + sleep }; \ No newline at end of file diff --git a/.maestro/tests/room/messages-received-while-offline.yaml b/.maestro/tests/room/messages-received-while-offline.yaml new file mode 100644 index 00000000000..ba523aa153b --- /dev/null +++ b/.maestro/tests/room/messages-received-while-offline.yaml @@ -0,0 +1,55 @@ +appId: ${APP_ID} +name: Messages received while device is offline +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +onFlowComplete: + - setAirplaneMode: disabled + - stopApp: ${APP_ID} + - evalScript: ${output.utils.deleteCreatedUsers()} +tags: + - test-3 + - android-only + +--- +- evalScript: ${output.user = output.utils.createUser()} +- evalScript: ${output.sender = output.utils.createUser()} +- evalScript: ${output.tag = 'offline-' + output.random(6)} +- evalScript: ${output.utils.sendMessage(output.sender.username, output.sender.password, '@' + output.user.username, output.tag + '-baseline')} + +- runFlow: + file: '../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${output.user.username} + PASSWORD: ${output.user.password} + CLEAR_STATE: true +- runFlow: + file: '../../helpers/navigate-to-room.yaml' + env: + ROOM: ${output.sender.username} +- extendedWaitUntil: + visible: + id: 'message-content-${output.tag}-baseline' + timeout: 60000 + +# should deliver every message that arrived while the device had no network +- setAirplaneMode: enabled +- evalScript: ${output.utils.sendMessage(output.sender.username, output.sender.password, '@' + output.user.username, output.tag + '-1')} +- evalScript: ${output.utils.sendMessage(output.sender.username, output.sender.password, '@' + output.user.username, output.tag + '-2')} +- evalScript: ${output.utils.sendMessage(output.sender.username, output.sender.password, '@' + output.user.username, output.tag + '-3')} +- evalScript: ${output.utils.sleep(5000)} +- assertNotVisible: + id: 'message-content-${output.tag}-1' +- assertNotVisible: + id: 'message-content-${output.tag}-2' +- assertNotVisible: + id: 'message-content-${output.tag}-3' +- setAirplaneMode: disabled + +- extendedWaitUntil: + visible: + id: 'message-content-${output.tag}-3' + timeout: 120000 +- assertVisible: + id: 'message-content-${output.tag}-1' +- assertVisible: + id: 'message-content-${output.tag}-2' diff --git a/.sniffler/test-map.json b/.sniffler/test-map.json index 08de4f27bda..8fa1d384893 100644 --- a/.sniffler/test-map.json +++ b/.sniffler/test-map.json @@ -40,7 +40,9 @@ "app/views/RegisterView/**", "app/views/RoomsListView/**", "app/sagas/login.js", - "app/sagas/rooms.js" + "app/sagas/rooms.js", + "app/sagas/selectServer.ts", + "app/lib/services/connect.ts" ] }, { @@ -66,7 +68,9 @@ "app/views/RegisterView/**", "app/views/RoomsListView/**", "app/sagas/login.js", - "app/sagas/rooms.js" + "app/sagas/rooms.js", + "app/sagas/selectServer.ts", + "app/lib/services/connect.ts" ] }, { @@ -249,6 +253,18 @@ "test": ".maestro/tests/room/mark-as-unread.yaml", "dependsOn": ["app/views/RoomsListView/**", "app/containers/MessageActions/**", "app/sagas/rooms.js"] }, + { + "test": ".maestro/tests/room/messages-received-while-offline.yaml", + "dependsOn": [ + "app/lib/services/sdk.ts", + "app/lib/services/socketHealth.ts", + "app/lib/methods/loadMissedMessages.ts", + "app/lib/methods/subscriptions/room.ts", + "app/lib/methods/subscribeRooms.ts", + "app/sagas/rooms.js", + "app/views/RoomView/**" + ] + }, { "test": ".maestro/tests/room/message-markdown-click.yaml", "dependsOn": ["app/views/RoomView/**", "app/containers/markdown/**", "app/sagas/room.js"] diff --git a/CONTEXT.md b/CONTEXT.md index 25a741e22f8..7bee7fe7ef2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,18 +2,18 @@ ## Rooms & Conversations -| Term | Definition | Aliases to avoid | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- | -| **Room** | A server-side conversation container with shared state (name, type, settings) | Chat, conversation | -| **Subscription** | A user's personal relationship to a Room, holding per-user state (unread count, favorite, muted, open) | Membership, room entry | -| **Channel** | A public Room (type `'c'`) visible to all server users | Public room | -| **Group** | A private Room (type `'p'`) visible only to invited members | Private room, private channel | -| **Direct Message** | A 1-on-1 private Room (type `'d'`) between two users | DM, PM, private message | -| **Thread** | A branched conversation spawned from a single Message, identified by `tmid` (thread message id) | Reply chain | -| **Discussion** | A separate Room spawned from a parent Room, identified by `prid` (parent room id) — unlike Threads, Discussions are full Rooms | Sub-room, sub-channel | -| **Team** | An organizational container that groups multiple Channels and users under a single entity | Workspace (ambiguous) | -| **Broadcast Room** | A Room where only authorized users can send Messages; other users can only Reply Broadcast to existing Messages | Broadcast channel | -| **Reply Broadcast** | The action of replying to a Message in a Broadcast Room when the current user cannot send regular Messages | Broadcast reply | +| Term | Definition | Aliases to avoid | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| **Room** | A server-side conversation container with shared state (name, type, settings) | Chat, conversation | +| **Subscription** | A user's personal relationship to a Room, holding per-user state (unread count, favorite, muted, open) — never a **DDP Subscription** | Membership, room entry | +| **Channel** | A public Room (type `'c'`) visible to all server users | Public room | +| **Group** | A private Room (type `'p'`) visible only to invited members | Private room, private channel | +| **Direct Message** | A 1-on-1 private Room (type `'d'`) between two users | DM, PM, private message | +| **Thread** | A branched conversation spawned from a single Message, identified by `tmid` (thread message id) | Reply chain | +| **Discussion** | A separate Room spawned from a parent Room, identified by `prid` (parent room id) — unlike Threads, Discussions are full Rooms | Sub-room, sub-channel | +| **Team** | An organizational container that groups multiple Channels and users under a single entity | Workspace (ambiguous) | +| **Broadcast Room** | A Room where only authorized users can send Messages; other users can only Reply Broadcast to existing Messages | Broadcast channel | +| **Reply Broadcast** | The action of replying to a Message in a Broadcast Room when the current user cannot send regular Messages | Broadcast reply | ## Messages @@ -197,12 +197,13 @@ A **Message Action** is the active mode on a Message in the Room view. The three ## Server & Connection -| Term | Definition | Aliases to avoid | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | -| **Server** | A Rocket.Chat server instance the app connects to, with version, settings, and enterprise modules | Workspace (used by web but not consistently in mobile), instance | -| **Server History** | List of previously connected Servers for quick reconnection | Recent servers | -| **Meteor Connect** | The WebSocket connection to the Server's DDP (Distributed Data Protocol) endpoint | Socket, connection | -| **Socket Health** | Whether the Meteor Connect socket is genuinely alive — confirmed by a round trip when in doubt, reopened when known dead | Staleness (stale/gray/fresh), socket probe | +| Term | Definition | Aliases to avoid | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| **Server** | A Rocket.Chat server instance the app connects to, with version, settings, and enterprise modules | Workspace (used by web but not consistently in mobile), instance | +| **Server History** | List of previously connected Servers for quick reconnection | Recent servers | +| **Meteor Connect** | The WebSocket connection to the Server's DDP (Distributed Data Protocol) endpoint | Socket, connection | +| **Socket Health** | Whether the Meteor Connect socket is genuinely alive — confirmed by a round trip when in doubt, reopened when known dead | Staleness (stale/gray/fresh), socket probe | +| **DDP Subscription** | A live server-push feed on Meteor Connect, opened by name and parameters (`stream-room-messages`, `stream-notify-user`); the SDK derives its id from those parameters, so two callers asking for the same feed share one — distinct from a **Subscription**, which is a membership record | Stream, DDP stream, sub | ## Navigation & Layout diff --git a/app/containers/Avatar/useAvatarETag.ts b/app/containers/Avatar/useAvatarETag.ts index 4b8a2596c91..c1178571ab0 100644 --- a/app/containers/Avatar/useAvatarETag.ts +++ b/app/containers/Avatar/useAvatarETag.ts @@ -13,7 +13,7 @@ export const useAvatarETag = ({ id }: { type?: string; - username: string; + username?: string; text: string; rid?: string; id: string; @@ -61,7 +61,7 @@ export const useAvatarETag = ({ } }; } - }, [text]); + }, [text, username, type, rid, id]); return { avatarETag }; }; diff --git a/app/containers/LoginServices/serviceLogin.ts b/app/containers/LoginServices/serviceLogin.ts index 8eafda139bc..1b7b446028a 100644 --- a/app/containers/LoginServices/serviceLogin.ts +++ b/app/containers/LoginServices/serviceLogin.ts @@ -137,7 +137,11 @@ export const onPressAppleLogin = async () => { AppleAuthentication.AppleAuthenticationScope.EMAIL ] }); - await loginOAuthOrSso({ fullName, email, identityToken }); + if (!identityToken) { + logEvent(events.ENTER_WITH_APPLE_F); + return; + } + await loginOAuthOrSso({ fullName: fullName ?? {}, email, identityToken }); } catch { logEvent(events.ENTER_WITH_APPLE_F); } diff --git a/app/containers/TwoFactor/index.test.tsx b/app/containers/TwoFactor/index.test.tsx new file mode 100644 index 00000000000..ae5700d4f69 --- /dev/null +++ b/app/containers/TwoFactor/index.test.tsx @@ -0,0 +1,38 @@ +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; + +import TwoFactor from '.'; +import { isTwoFactorCancelled, twoFactor } from '../../lib/services/twoFactor'; + +jest.mock('../../lib/services/restApi', () => ({ + sendEmailCode: jest.fn() +})); + +jest.mock('../../lib/hooks/useMasterDetail', () => ({ + useMasterDetail: () => false +})); + +const requestTwoFactor = () => twoFactor({ method: 'totp', invalid: false }); + +describe('TwoFactor', () => { + it('cancels the displaced prompt and resolves the newest one', async () => { + const { getByTestId } = render(); + + let displacedResult: Promise | undefined; + let newest: ReturnType | undefined; + await act(() => { + displacedResult = requestTwoFactor().catch(error => error); + newest = requestTwoFactor(); + }); + + await waitFor(() => expect(getByTestId('two-factor-input')).toBeTruthy()); + + expect(isTwoFactorCancelled(await displacedResult!)).toBe(true); + + fireEvent.changeText(getByTestId('two-factor-input'), '123456'); + await act(() => { + fireEvent.press(getByTestId('two-factor-send')); + }); + + await expect(newest!).resolves.toEqual({ twoFactorCode: '123456', twoFactorMethod: 'totp' }); + }); +}); diff --git a/app/containers/TwoFactor/index.tsx b/app/containers/TwoFactor/index.tsx index 0aa2acae2c2..fe158025920 100644 --- a/app/containers/TwoFactor/index.tsx +++ b/app/containers/TwoFactor/index.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, memo } from 'react'; +import { useEffect, useRef, useState, memo } from 'react'; import { AccessibilityInfo, Text, View } from 'react-native'; import isEmpty from 'lodash/isEmpty'; import { sha256 } from 'js-sha256'; @@ -16,7 +16,7 @@ import { useTheme } from '../../theme'; import Button from '../Button'; import sharedStyles from '../../views/Styles'; import styles from './styles'; -import { type ICredentials } from '../../definitions'; +import { type ILoginCredentials } from '../../definitions'; import { sendEmailCode } from '../../lib/services/restApi'; import { useMasterDetail } from '../../lib/hooks/useMasterDetail'; import Toast from '../Toast'; @@ -38,7 +38,7 @@ interface IMethods { } interface EventListenerMethod { - params?: ICredentials; + params?: ILoginCredentials; method?: keyof IMethods; submit?: (param: string) => void; cancel?: () => void; @@ -70,6 +70,7 @@ const TwoFactor = memo(() => { const isMasterDetail = useMasterDetail(); const [visible, setVisible] = useState(false); const [data, setData] = useState({}); + const pendingCancel = useRef(undefined); const { control, setValue, @@ -87,12 +88,13 @@ const TwoFactor = memo(() => { const method = data.method ? methods[data.method] : null; const isEmail = data.method === 'email'; const params = data?.params; + const emailCodeRecipient = params && 'user' in params ? params.user : undefined; const sendEmail = async () => { try { - if (params?.user) { + if (emailCodeRecipient) { clearErrors(); - const response = await sendEmailCode(params?.user); + const response = await sendEmailCode(emailCodeRecipient); if (response.success) { showToast(I18n.t('Two_Factor_Success_message')); @@ -113,6 +115,8 @@ const TwoFactor = memo(() => { }, [data]); const showTwoFactor = (args: EventListenerMethod) => { + pendingCancel.current?.(); + pendingCancel.current = args.cancel; setData(args); if (args.invalid) { setError('code', { message: I18n.t('Invalid_code'), type: 'validate' }); @@ -128,6 +132,7 @@ const TwoFactor = memo(() => { const onCancel = () => { const { cancel } = data; + pendingCancel.current = undefined; if (cancel) { cancel(); } @@ -136,6 +141,7 @@ const TwoFactor = memo(() => { const onSubmit = () => { const { submit } = data; + pendingCancel.current = undefined; if (submit) { const { code } = getValues(); if (data.method === 'password') { diff --git a/app/definitions/ICredentials.ts b/app/definitions/ICredentials.ts deleted file mode 100644 index 99cab9ea536..00000000000 --- a/app/definitions/ICredentials.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { type AppleAuthenticationFullName } from 'expo-apple-authentication'; - -export interface ICredentials { - resume?: string; - user?: string; - password?: string; - username?: string; - ldapPass?: string; - ldap?: boolean; - ldapOptions?: object; - crowdPassword?: string; - crowd?: boolean; - code?: string; - totp?: { - login: ICredentials; - code: string; - }; - fullName?: AppleAuthenticationFullName | null; - email?: string | null; - identityToken?: string | null; - credentialToken?: string; - saml?: boolean; - cas?: { credentialToken?: string }; -} diff --git a/app/definitions/ILoggedUser.ts b/app/definitions/ILoggedUser.ts index ecd78aa5fd1..60c3600d5b6 100644 --- a/app/definitions/ILoggedUser.ts +++ b/app/definitions/ILoggedUser.ts @@ -1,13 +1,13 @@ import type Model from '@nozbe/watermelondb/Model'; -import { type IUserEmail, type IUserSettings } from './IUser'; +import { type IUserEmail } from './IUser'; import { type TStatusSource } from './TStatusSource'; import { type TUserStatus } from './TUserStatus'; export interface ILoggedUser { id: string; token: string; - username: string; + username?: string; name?: string; language?: string; status: TUserStatus; @@ -30,18 +30,4 @@ export interface ILoggedUser { requirePasswordChange?: boolean; } -export interface ILoggedUserResultFromServer extends Omit< - ILoggedUser, - 'enableMessageParserEarlyAdoption' | 'showMessageInMainThread' -> { - settings: IUserSettings; -} - -export interface ILoginResultFromServer { - status: string; - authToken: string; - userId: string; - me: ILoggedUserResultFromServer; -} - export type TLoggedUserModel = ILoggedUser & Model; diff --git a/app/definitions/ILoginCredentials.ts b/app/definitions/ILoginCredentials.ts new file mode 100644 index 00000000000..11b9020fe8c --- /dev/null +++ b/app/definitions/ILoginCredentials.ts @@ -0,0 +1,12 @@ +export type { + ICredentialsAppleAPI, + ICredentialsAuthenticated, + ICredentialsCasAPI, + ICredentialsCrowdAPI, + ICredentialsLdapAPI, + ICredentialsOAuth, + ICredentialsPasswordAPI, + ICredentialsSamlAPI, + ICredentialsTotpAPI, + ILoginCredentials +} from '@rocket.chat/sdk/interfaces'; diff --git a/app/definitions/IProfile.ts b/app/definitions/IProfile.ts index 0692e530edc..2f6a2dfddf6 100644 --- a/app/definitions/IProfile.ts +++ b/app/definitions/IProfile.ts @@ -3,7 +3,7 @@ import { type ReactNode } from 'react'; export interface IProfileParams { realname?: string; name?: string; - username: string; + username?: string; email: string | null; newPassword: string; currentPassword: string; diff --git a/app/definitions/index.ts b/app/definitions/index.ts index b2566469043..45d4bdc2dd6 100644 --- a/app/definitions/index.ts +++ b/app/definitions/index.ts @@ -9,7 +9,7 @@ export * from './ERoomType'; export * from './IAttachment'; export * from './ICannedResponse'; export * from './ICertificate'; -export * from './ICredentials'; +export * from './ILoginCredentials'; export * from './IEmoji'; export * from './ILivechatDepartment'; export * from './ILivechatTag'; diff --git a/app/definitions/rest/v1/push.ts b/app/definitions/rest/v1/push.ts index 3062bcb90c2..2db3495cbc1 100644 --- a/app/definitions/rest/v1/push.ts +++ b/app/definitions/rest/v1/push.ts @@ -14,6 +14,7 @@ export type PushEndpoints = { userId: string; }; }; + DELETE: (params: { token: string }) => { success: boolean }; }; 'push.info': { GET: () => TPushInfo; diff --git a/app/externalModules.d.ts b/app/externalModules.d.ts index 1b220ba2dd3..4935ca2295f 100644 --- a/app/externalModules.d.ts +++ b/app/externalModules.d.ts @@ -1,5 +1,4 @@ declare module 'remove-markdown'; -declare module '@rocket.chat/sdk'; declare module 'react-native-mime-types'; declare module 'react-native-restart'; declare module 'react-native-math-view'; diff --git a/app/lib/hooks/useUserData.ts b/app/lib/hooks/useUserData.ts index b2cdbb4c7ae..6b1b1a91ab8 100644 --- a/app/lib/hooks/useUserData.ts +++ b/app/lib/hooks/useUserData.ts @@ -30,6 +30,9 @@ const useUserData = (rid: string) => { const result = await getUserInfo(rid); if (result.success) { const { user } = result; + if (!user.username) { + return; + } const username = useRealName && user.name ? user.name : user.username; setUser({ username, diff --git a/app/lib/methods/actions.test.ts b/app/lib/methods/actions.test.ts index 6bf08853022..f9706e730ce 100644 --- a/app/lib/methods/actions.test.ts +++ b/app/lib/methods/actions.test.ts @@ -21,15 +21,11 @@ jest.mock('../navigation/appNavigation', () => ({ jest.mock('../services/sdk', () => ({ __esModule: true, default: { - current: { - currentLogin: { - userId: 'user-id', - authToken: 'auth-token' - }, - client: { - host: 'https://chat.example.com' - } - } + currentLogin: { + userId: 'user-id', + authToken: 'auth-token' + }, + host: 'https://chat.example.com' } })); diff --git a/app/lib/methods/actions.ts b/app/lib/methods/actions.ts index 7945f9ad7a5..f6da330fabc 100644 --- a/app/lib/methods/actions.ts +++ b/app/lib/methods/actions.ts @@ -108,8 +108,11 @@ export async function triggerAction({ const payload = rest.payload ?? rest.value; try { - const { userId, authToken } = sdk.current.currentLogin; - const { host } = sdk.current.client; + const { host, currentLogin } = sdk; + if (!host || !currentLogin) { + throw new Error('triggerAction requires an initialized, authenticated session'); + } + const { userId, authToken } = currentLogin; const interaction = toUserInteraction({ type, actionId, diff --git a/app/lib/methods/getRoles.ts b/app/lib/methods/getRoles.ts index a266b91222c..745d35bfad1 100644 --- a/app/lib/methods/getRoles.ts +++ b/app/lib/methods/getRoles.ts @@ -121,8 +121,8 @@ export function getRoles(): Promise { setRoles(); return allRecords.length; }); - return resolve(); } + return resolve(); } catch (e) { log(e); return resolve(); diff --git a/app/lib/methods/getSettings.ts b/app/lib/methods/getSettings.ts index e1f3a7d733b..5cfab330f75 100644 --- a/app/lib/methods/getSettings.ts +++ b/app/lib/methods/getSettings.ts @@ -143,13 +143,13 @@ export async function setSettings(): Promise { reduxStore.dispatch(addSettings(parseSettings(parsed.slice(0, parsed.length)))); } -export function subscribeSettings(): void { - return sdk.subscribe('stream-notify-all', 'public-settings-changed'); +export async function subscribeSettings(): Promise { + await sdk.subscribe('stream-notify-all', 'public-settings-changed'); } type IData = ISettingsIcon | IPreparedSettings; -export async function getSettings(): Promise { +export async function getSettings(server: string): Promise { try { const db = database.active; const settingsParams = Object.keys(defaultSettings).filter(key => !loginSettings.includes(key)); @@ -159,8 +159,8 @@ export async function getSettings(): Promise { let settings: IData[] = []; const serverVersion = reduxStore.getState().server.version; const url = compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '7.0.0') - ? `${sdk.current.client.host}/api/v1/settings.public?_id=${settingsParams.join(',')}` - : `${sdk.current.client.host}/api/v1/settings.public?query={"_id":{"$in":${JSON.stringify(settingsParams)}}}`; + ? `${server}/api/v1/settings.public?_id=${settingsParams.join(',')}` + : `${server}/api/v1/settings.public?query={"_id":{"$in":${JSON.stringify(settingsParams)}}}`; // Iterate over paginated results to retrieve all settings do { // TODO: why is no-await-in-loop enforced in the first place? diff --git a/app/lib/methods/getUsersPresence.ts b/app/lib/methods/getUsersPresence.ts index 48b9ba536a5..4bf08304ae8 100644 --- a/app/lib/methods/getUsersPresence.ts +++ b/app/lib/methods/getUsersPresence.ts @@ -72,7 +72,7 @@ export async function getUsersPresence(usersParams: string[]) { const result = (await sdk.get('users.presence' as any, params as any)) as any; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '4.1.0')) { - sdk.subscribeRaw('stream-user-presence', ['', { added: usersParams }]); + sdk.subscribeRaw('stream-user-presence', ['', { added: usersParams }]).catch(log); } if (result.success) { diff --git a/app/lib/methods/helpers/events.ts b/app/lib/methods/helpers/events.ts index f8f52140cee..f9953e2c731 100644 --- a/app/lib/methods/helpers/events.ts +++ b/app/lib/methods/helpers/events.ts @@ -1,4 +1,4 @@ -import { type ICredentials } from '../../../definitions'; +import { type ILoginCredentials } from '../../../definitions'; import { type IEmitUserInteraction } from '../../../containers/UIKit/interfaces'; import log from './log'; @@ -13,7 +13,7 @@ type TEventEmitterEmmitArgs = | { visible: boolean; onCancel?: null | Function } | { cancel: () => void } | { submit: (param: string) => void } - | { params: ICredentials } + | { params: ILoginCredentials } | IEmitUserInteraction; class EventEmitter { diff --git a/app/lib/methods/helpers/fileUpload/Upload.android.ts b/app/lib/methods/helpers/fileUpload/Upload.android.ts index 975ae4f7a66..9b910608d40 100644 --- a/app/lib/methods/helpers/fileUpload/Upload.android.ts +++ b/app/lib/methods/helpers/fileUpload/Upload.android.ts @@ -11,7 +11,7 @@ export class Upload { name: string | undefined; fieldName?: string; } | null; - private headers: { [key: string]: string }; + private headers: Record; private formData: any; private uploadTask: FileSystem.UploadTask | null; private isCancelled: boolean; @@ -28,7 +28,7 @@ export class Upload { public setupRequest( url: string, - headers: { [key: string]: string }, + headers: Record, progressCallback?: (loaded: number, total: number) => void ): void { this.uploadUrl = url; diff --git a/app/lib/methods/helpers/fileUpload/Upload.ts b/app/lib/methods/helpers/fileUpload/Upload.ts index b37656a8221..5eb8037e693 100644 --- a/app/lib/methods/helpers/fileUpload/Upload.ts +++ b/app/lib/methods/helpers/fileUpload/Upload.ts @@ -14,7 +14,7 @@ export class Upload { public setupRequest( url: string, - headers: { [key: string]: string }, + headers: Record, progressCallback?: (loaded: number, total: number) => void ): void { this.xhr.open('POST', url); diff --git a/app/lib/methods/helpers/fileUpload/definitions.ts b/app/lib/methods/helpers/fileUpload/definitions.ts index 7aaf980261b..0c4f188d98b 100644 --- a/app/lib/methods/helpers/fileUpload/definitions.ts +++ b/app/lib/methods/helpers/fileUpload/definitions.ts @@ -1,5 +1,7 @@ import { type TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; +export type TUploadHeaders = Record; + export interface IFormData { name: string; uri?: string; diff --git a/app/lib/methods/helpers/fileUpload/index.test.ts b/app/lib/methods/helpers/fileUpload/index.test.ts new file mode 100644 index 00000000000..142adb79228 --- /dev/null +++ b/app/lib/methods/helpers/fileUpload/index.test.ts @@ -0,0 +1,64 @@ +import FileUpload, { MissingUploadAuthHeadersError } from './index'; + +const mockSetupRequest = jest.fn(); +const mockAppendFile = jest.fn(); +const mockSend = jest.fn(() => Promise.resolve({ success: true })); +const mockCancel = jest.fn(); + +jest.mock('./Upload', () => ({ + Upload: jest.fn().mockImplementation(() => ({ + setupRequest: mockSetupRequest, + appendFile: mockAppendFile, + send: mockSend, + cancel: mockCancel + })) +})); + +const formData = [{ name: 'file', uri: 'file://image.jpg', type: 'image/jpeg', filename: 'image.jpg' }]; + +describe('FileUpload', () => { + beforeEach(() => jest.clearAllMocks()); + + it.each([ + ['both auth headers missing', { 'Content-Type': 'multipart/form-data' }], + ['token missing', { 'X-Auth-Token': undefined, 'X-User-Id': 'user-id' }], + ['user id missing', { 'X-Auth-Token': 'token', 'X-User-Id': undefined }], + ['token empty', { 'X-Auth-Token': '', 'X-User-Id': 'user-id' }] + ])('refuses to send when %s', async (_, headers) => { + const upload = new FileUpload('https://open.rocket.chat/api/v1/users.setAvatar', headers, formData); + + await expect(upload.send()).rejects.toThrow(MissingUploadAuthHeadersError); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('sends an authenticated upload keeping optional headers out of the request', async () => { + const progressCallback = jest.fn(); + const upload = new FileUpload( + 'https://open.rocket.chat/api/v1/rooms.media/rid', + { + 'Content-Type': 'multipart/form-data', + 'X-Auth-Token': 'token', + 'X-User-Id': 'user-id', + 'X-Optional': undefined + }, + formData, + progressCallback + ); + + expect(mockSetupRequest).toHaveBeenCalledWith( + 'https://open.rocket.chat/api/v1/rooms.media/rid', + { + 'Content-Type': 'multipart/form-data', + 'X-Auth-Token': 'token', + 'X-User-Id': 'user-id' + }, + progressCallback + ); + expect(mockAppendFile).toHaveBeenCalledWith(formData[0]); + + await expect(upload.send()).resolves.toEqual({ success: true }); + + upload.cancel(); + expect(mockCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/lib/methods/helpers/fileUpload/index.ts b/app/lib/methods/helpers/fileUpload/index.ts index 413b0c7db9d..46edcba2095 100644 --- a/app/lib/methods/helpers/fileUpload/index.ts +++ b/app/lib/methods/helpers/fileUpload/index.ts @@ -1,23 +1,45 @@ import { type TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; +import i18n from '../../../../i18n'; import { Upload } from './Upload'; -import { type IFormData } from './definitions'; +import { type IFormData, type TUploadHeaders } from './definitions'; + +const authHeaders = ['X-Auth-Token', 'X-User-Id']; + +export class MissingUploadAuthHeadersError extends Error { + constructor() { + super(i18n.t('Token_expired')); + } +} + +const dropUndefinedHeaders = (headers: TUploadHeaders): Record => + Object.fromEntries(Object.entries(headers).filter(([, value]) => value !== undefined)) as Record; + +const assertAuthHeaders = (headers: TUploadHeaders): void => { + if (authHeaders.some(header => !headers[header])) { + throw new MissingUploadAuthHeadersError(); + } +}; class FileUpload { private upload: Upload; + private headers: TUploadHeaders; + constructor( url: string, - headers: { [key: string]: string }, + headers: TUploadHeaders, data: IFormData[], progressCallback?: (loaded: number, total: number) => void ) { + this.headers = headers; this.upload = new Upload(); - this.upload.setupRequest(url, headers, progressCallback); + this.upload.setupRequest(url, dropUndefinedHeaders(headers), progressCallback); data.forEach(item => this.upload.appendFile(item)); } - public send(): Promise { - return this.upload.send(); + public async send(): Promise { + assertAuthHeaders(this.headers); + return await this.upload.send(); } public cancel(): void { diff --git a/app/lib/methods/helpers/handleSaveUserProfileError.ts b/app/lib/methods/helpers/handleSaveUserProfileError.ts index 8e7250226ea..ec2c3890329 100644 --- a/app/lib/methods/helpers/handleSaveUserProfileError.ts +++ b/app/lib/methods/helpers/handleSaveUserProfileError.ts @@ -1,7 +1,11 @@ import I18n from '../../../i18n'; import { showErrorAlert } from '.'; +import { isTwoFactorCancelled } from '../../services/twoFactorCancelled'; const handleSaveUserProfileError = (e: any, action: string) => { + if (isTwoFactorCancelled(e)) { + return; + } if (e.data && e.data.error.includes('[error-too-many-requests]')) { return showErrorAlert(e.data.error); } diff --git a/app/lib/methods/helpers/info.ts b/app/lib/methods/helpers/info.ts index d34c7b215e5..29977c0ffe7 100644 --- a/app/lib/methods/helpers/info.ts +++ b/app/lib/methods/helpers/info.ts @@ -1,11 +1,15 @@ import { Alert } from 'react-native'; import I18n from '../../../i18n'; +import { isTwoFactorCancelled } from '../../services/twoFactorCancelled'; export const showErrorAlert = (message: string, title?: string, onPress = () => {}): void => Alert.alert(title || '', message, [{ text: 'OK', onPress }], { cancelable: true }); export const showErrorAlertWithEMessage = (e: any, title?: string): void => { + if (isTwoFactorCancelled(e)) { + return; + } let errorMessage: string = e?.data?.error; if (errorMessage?.includes('[error-too-many-requests]')) { diff --git a/app/lib/methods/helpers/isReadOnly.ts b/app/lib/methods/helpers/isReadOnly.ts index 226cd5350e9..345f51ecbeb 100644 --- a/app/lib/methods/helpers/isReadOnly.ts +++ b/app/lib/methods/helpers/isReadOnly.ts @@ -2,7 +2,7 @@ import { store as reduxStore } from '../../store/auxStore'; import { type ISubscription } from '../../../definitions'; import { hasPermission } from './helpers'; -const canPostReadOnly = async (room: Partial, username: string) => { +const canPostReadOnly = async (room: Partial, username?: string) => { // RC 6.4.0 const isUnmuted = !!room?.unmuted?.find(m => m === username); // TODO: this is not reactive. If this permission changes, the component won't be updated @@ -11,10 +11,10 @@ const canPostReadOnly = async (room: Partial, username: string) = return permission[0] || isUnmuted; }; -const isMuted = (room: Partial, username: string) => +const isMuted = (room: Partial, username?: string) => room && room.muted && room.muted.find && !!room.muted.find(m => m === username); -export const isReadOnly = async (room: Partial, username: string): Promise => { +export const isReadOnly = async (room: Partial, username?: string): Promise => { if (room.archived) { return true; } diff --git a/app/lib/methods/helpers/log/index.ts b/app/lib/methods/helpers/log/index.ts index 023252aa7d4..7c4fcf17253 100644 --- a/app/lib/methods/helpers/log/index.ts +++ b/app/lib/methods/helpers/log/index.ts @@ -3,6 +3,7 @@ import { getCrashlytics as crashlytics } from '@react-native-firebase/crashlytic import bugsnag from '@bugsnag/react-native'; import events from './events'; +import { isTwoFactorCancelled } from '../../../services/twoFactorCancelled'; export { events }; @@ -57,6 +58,9 @@ export const toggleAnalyticsEventsReport = (value: boolean): boolean => { }; const log = (e: any): void => { + if (isTwoFactorCancelled(e)) { + return; + } if (e instanceof Error && bugsnag && e.message !== 'Aborted' && !__DEV__) { bugsnag.notify(e, (event: { addMetadata: (arg0: string, arg1: {}) => void }) => { event.addMetadata('details', { ...metadata }); diff --git a/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts b/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts index 89d5a72f52a..f5c8ddb9f61 100644 --- a/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts +++ b/app/lib/methods/helpers/parseSamlOrCasRedirect.test.ts @@ -46,11 +46,8 @@ describe('parseSamlOrCasRedirect', () => { expect(parseSamlOrCasRedirect('https://server.example/login', 'cas', 'sso-token')).toBeNull(); }); - it('passes credentialToken through as undefined when ssoToken is not provided', () => { - expect(parseSamlOrCasRedirect('https://server.example/_cas/validate/xyz', 'cas')).toEqual({ - kind: 'cas', - payload: { cas: { credentialToken: undefined } } - }); + it('returns null when authType is cas and no ssoToken is provided', () => { + expect(parseSamlOrCasRedirect('https://server.example/_cas/validate/xyz', 'cas')).toBeNull(); }); it('returns null when authType is cas and the URL only has a SAML-style token', () => { diff --git a/app/lib/methods/helpers/parseSamlOrCasRedirect.ts b/app/lib/methods/helpers/parseSamlOrCasRedirect.ts index 99d9c59c9a0..6db4300999b 100644 --- a/app/lib/methods/helpers/parseSamlOrCasRedirect.ts +++ b/app/lib/methods/helpers/parseSamlOrCasRedirect.ts @@ -1,16 +1,22 @@ import parse from 'url-parse'; -import { type ICredentials } from '../../../definitions'; +import { type ICredentialsCasAPI, type ICredentialsSamlAPI } from '../../../definitions'; -export type SamlOrCasRedirect = { kind: 'saml'; payload: ICredentials } | { kind: 'cas'; payload: ICredentials } | null; +export type SamlOrCasRedirect = + | { kind: 'saml'; payload: ICredentialsSamlAPI } + | { kind: 'cas'; payload: ICredentialsCasAPI } + | null; export const parseSamlOrCasRedirect = (url: string, authType: string, ssoToken?: string): SamlOrCasRedirect => { const parsedUrl = parse(url, true); - if (authType === 'saml' && parsedUrl.query?.saml_idp_credentialToken) { - const token = parsedUrl.query.saml_idp_credentialToken || ssoToken; - return { kind: 'saml', payload: { credentialToken: token, saml: true } }; + const samlCredentialToken = parsedUrl.query?.saml_idp_credentialToken; + if (authType === 'saml' && samlCredentialToken) { + return { kind: 'saml', payload: { credentialToken: samlCredentialToken, saml: true } }; } if (authType === 'cas' && (parsedUrl.pathname?.includes('validate') || parsedUrl.query?.ticket)) { + if (!ssoToken) { + return null; + } return { kind: 'cas', payload: { cas: { credentialToken: ssoToken } } }; } return null; diff --git a/app/lib/methods/helpers/twoFactorCancellation.test.ts b/app/lib/methods/helpers/twoFactorCancellation.test.ts new file mode 100644 index 00000000000..d9b6e7fa667 --- /dev/null +++ b/app/lib/methods/helpers/twoFactorCancellation.test.ts @@ -0,0 +1,64 @@ +import { Alert } from 'react-native'; +import bugsnag from '@bugsnag/react-native'; + +import log from './log'; +import { showErrorAlertWithEMessage } from './info'; +import handleSaveUserProfileError from './handleSaveUserProfileError'; +import { handleLoginErrors } from '../../../views/LoginView/handleLoginErrors'; +import { TwoFactorCancelledError } from '../../services/twoFactorCancelled'; + +jest.mock('../../../i18n', () => ({ + t: (key: string) => key, + isTranslated: () => true +})); + +describe('two-factor cancellation', () => { + const cancelled = new TwoFactorCancelledError(); + const genuineFailure = { data: { error: 'error-invalid-password' } }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Alert, 'alert').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(bugsnag, 'notify').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not report a cancellation to crash logging or the console', () => { + log(cancelled); + expect(bugsnag.notify).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + }); + + it('still reports a genuine failure', () => { + log(new Error('boom')); + expect(console.error).toHaveBeenCalled(); + }); + + it('does not alert when a cancellation reaches showErrorAlertWithEMessage', () => { + showErrorAlertWithEMessage(cancelled); + expect(Alert.alert).not.toHaveBeenCalled(); + }); + + it('still alerts when a genuine failure reaches showErrorAlertWithEMessage', () => { + showErrorAlertWithEMessage(genuineFailure); + expect(Alert.alert).toHaveBeenCalled(); + }); + + it('does not alert when a cancellation reaches handleSaveUserProfileError', () => { + handleSaveUserProfileError(cancelled, 'saving_profile'); + expect(Alert.alert).not.toHaveBeenCalled(); + }); + + it('still alerts when a genuine failure reaches handleSaveUserProfileError', () => { + handleSaveUserProfileError({ error: 'error-invalid-password' }, 'saving_profile'); + expect(Alert.alert).toHaveBeenCalled(); + }); + + it('surfaces a generic login error when the login path reports a cancellation', () => { + expect(handleLoginErrors((cancelled as any).error)).toBe('Login_error'); + }); +}); diff --git a/app/lib/methods/loggedInServer.ts b/app/lib/methods/loggedInServer.ts new file mode 100644 index 00000000000..d6e392bbbd9 --- /dev/null +++ b/app/lib/methods/loggedInServer.ts @@ -0,0 +1,13 @@ +import { type TServerModel } from '../../definitions'; +import { TOKEN_KEY } from '../constants/keys'; +import database from '../database'; +import { SERVERS_TABLE } from '../database/model'; +import UserPreferences from './userPreferences'; + +export const hasStoredLoginToken = (serverId: string): boolean => !!UserPreferences.getString(`${TOKEN_KEY}-${serverId}`); + +export const findLoggedInServer = function* findLoggedInServer(): Generator { + const serversCollection = database.servers.get(SERVERS_TABLE); + const servers = (yield serversCollection.query().fetch()) as TServerModel[]; + return servers.find(({ id }) => hasStoredLoginToken(id)); +}; diff --git a/app/lib/methods/logout.test.ts b/app/lib/methods/logout.test.ts new file mode 100644 index 00000000000..54ea9b56e48 --- /dev/null +++ b/app/lib/methods/logout.test.ts @@ -0,0 +1,190 @@ +import type * as SdkIntegration from '../testUtils/sdkIntegration'; + +jest.mock('../database', () => ({ + __esModule: true, + default: { + servers: { + get: jest.fn(), + write: jest.fn((block: () => unknown) => Promise.resolve(block())), + batch: jest.fn() + } + }, + getDatabase: jest.fn() +})); + +jest.mock('./helpers/log', () => ({ + ...jest.requireActual('./helpers/log'), + __esModule: true, + default: jest.fn() +})); + +jest.mock('../notifications', () => ({ + getDeviceToken: jest.fn(() => '') +})); + +jest.mock('../services/connect', () => ({ + disconnect: jest.fn() +})); + +jest.mock('../services/restApi', () => ({ + removePushToken: jest.fn() +})); + +const mockSdkLogout = jest.fn(); + +jest.mock('../services/sdk', () => { + const { makeSdkMock } = jest.requireActual('../testUtils/sdkIntegration'); + return { __esModule: true, default: makeSdkMock({ logout: () => mockSdkLogout() }) }; +}); + +import { logout, removeServerData } from './logout'; +import sdk from '../services/sdk'; +import { disconnect } from '../services/connect'; +import database from '../database'; +import UserPreferences from './userPreferences'; +import { BASIC_AUTH_KEY } from './helpers/fetch'; +import { + CERTIFICATE_KEY, + CURRENT_SERVER, + E2E_PRIVATE_KEY, + E2E_PUBLIC_KEY, + E2E_RANDOM_PASSWORD_KEY, + TOKEN_KEY +} from '../constants/keys'; + +const mockSdk = sdk as unknown as SdkIntegration.IMockSdk; + +const SERVER = 'https://a.rocket.chat'; +const OTHER_SERVER = 'https://b.rocket.chat'; +const USER_ID = 'user-a'; +const OTHER_USER_ID = 'user-b'; + +const tokenKey = (suffix: string): string => `${TOKEN_KEY}-${suffix}`; +const certificateKey = (server: string): string => `${CERTIFICATE_KEY}-${server}`; + +const serverKeys = (server: string): string[] => [ + `${BASIC_AUTH_KEY}-${server}`, + `${server}-${E2E_PUBLIC_KEY}`, + `${server}-${E2E_PRIVATE_KEY}`, + `${server}-${E2E_RANDOM_PASSWORD_KEY}` +]; + +const keysToClear = [ + ...serverKeys(SERVER), + ...serverKeys(OTHER_SERVER), + tokenKey(SERVER), + tokenKey(OTHER_SERVER), + tokenKey(USER_ID), + tokenKey(OTHER_USER_ID), + certificateKey(SERVER), + CURRENT_SERVER +]; + +function seedServer(server: string, userId?: string): void { + if (userId) { + UserPreferences.setString(tokenKey(server), userId); + UserPreferences.setString(tokenKey(userId), `token-${userId}`); + } + serverKeys(server).forEach(key => UserPreferences.setString(key, `value-for-${key}`)); +} + +function mockDestroyableServerRecord(): void { + const serverRecord = { prepareDestroyPermanently: jest.fn(() => ({})) }; + jest.mocked(database.servers.get).mockReturnValue({ find: jest.fn(() => Promise.resolve(serverRecord)) } as any); +} + +describe('removeServerData', () => { + beforeEach(() => { + jest.clearAllMocks(); + keysToClear.forEach(key => UserPreferences.removeItem(key)); + mockDestroyableServerRecord(); + }); + + it('clears every per-server key for the removed server', async () => { + seedServer(SERVER, USER_ID); + + await removeServerData({ server: SERVER }); + + expect(UserPreferences.getString(tokenKey(SERVER))).toBeNull(); + expect(UserPreferences.getString(tokenKey(USER_ID))).toBeNull(); + serverKeys(SERVER).forEach(key => expect(UserPreferences.getString(key)).toBeNull()); + }); + + it('leaves another workspace keys untouched', async () => { + seedServer(SERVER, USER_ID); + seedServer(OTHER_SERVER, OTHER_USER_ID); + + await removeServerData({ server: SERVER }); + + expect(UserPreferences.getString(tokenKey(OTHER_SERVER))).toBe(OTHER_USER_ID); + expect(UserPreferences.getString(tokenKey(OTHER_USER_ID))).toBe(`token-${OTHER_USER_ID}`); + serverKeys(OTHER_SERVER).forEach(key => expect(UserPreferences.getString(key)).toBe(`value-for-${key}`)); + }); + + it('leaves CURRENT_SERVER in place', async () => { + seedServer(SERVER, USER_ID); + UserPreferences.setString(CURRENT_SERVER, SERVER); + + await removeServerData({ server: SERVER }); + + expect(UserPreferences.getString(CURRENT_SERVER)).toBe(SERVER); + }); + + it('keeps the pinned certificate so the user does not have to re-enter its password', async () => { + seedServer(SERVER, USER_ID); + UserPreferences.setString(certificateKey(SERVER), 'client-certificate'); + + await removeServerData({ server: SERVER }); + + expect(UserPreferences.getString(certificateKey(SERVER))).toBe('client-certificate'); + }); + + it('skips the user token key when the server has no stored userId', async () => { + seedServer(SERVER); + UserPreferences.setString(tokenKey(USER_ID), `token-${USER_ID}`); + + await removeServerData({ server: SERVER }); + + expect(UserPreferences.getString(tokenKey(USER_ID))).toBe(`token-${USER_ID}`); + serverKeys(SERVER).forEach(key => expect(UserPreferences.getString(key)).toBeNull()); + }); +}); + +describe('logout', () => { + beforeEach(() => { + jest.clearAllMocks(); + keysToClear.forEach(key => UserPreferences.removeItem(key)); + mockDestroyableServerRecord(); + mockSdk.setClient(null); + }); + + it('skips the server-side logout when there is no client', async () => { + seedServer(SERVER, USER_ID); + + await logout({ server: SERVER }); + + expect(mockSdkLogout).not.toHaveBeenCalled(); + expect(disconnect).not.toHaveBeenCalled(); + }); + + it('clears the local logout state when there is no client', async () => { + seedServer(SERVER, USER_ID); + UserPreferences.setString(CURRENT_SERVER, SERVER); + + await logout({ server: SERVER }); + + expect(UserPreferences.getString(CURRENT_SERVER)).toBeNull(); + expect(UserPreferences.getString(tokenKey(SERVER))).toBeNull(); + serverKeys(SERVER).forEach(key => expect(UserPreferences.getString(key)).toBeNull()); + }); + + it('calls the server-side logout when a client exists', async () => { + seedServer(SERVER, USER_ID); + mockSdk.setClient({ host: SERVER }); + + await logout({ server: SERVER }); + + expect(mockSdkLogout).toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + }); +}); diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index d27819472d4..013c929638c 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -66,18 +66,20 @@ export async function removeServer({ server }: { server: string }): Promise { log(e); } - try { - // RC 0.60.0 - await sdk.current.logout(); - } catch (e) { - log(e); - } - - if (sdk.current) { + if (sdk.isInitialized) { + try { + // RC 0.60.0 + await sdk.logout(); + } catch (e) { + log(e); + } disconnect(); } diff --git a/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts b/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts new file mode 100644 index 00000000000..678565f5ef0 --- /dev/null +++ b/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts @@ -0,0 +1,197 @@ +jest.unmock('@rocket.chat/sdk'); + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const sdkIntegration = jest.requireActual('../../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); + }) +); + +jest.mock('../../../encryption', () => ({ + Encryption: { decryptMessage: jest.fn(async (message: unknown) => message) } +})); + +jest.mock('../../helpers/buildMessage', () => ({ + __esModule: true, + default: jest.fn((message: unknown) => message) +})); + +jest.mock('../../helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('../../../services/twoFactor', () => ({ + twoFactor: jest.fn() +})); + +jest.mock('../../subscribeRooms', () => ({ + subscribeRooms: jest.fn(), + unsubscribeRooms: jest.fn() +})); + +jest.mock('../../../database/services/Message', () => ({ + getMessageById: jest.fn() +})); + +jest.mock('../../../database/services/Thread', () => ({ + getThreadById: jest.fn() +})); + +jest.mock('../../../database/services/ThreadMessage', () => ({ + getThreadMessageById: jest.fn() +})); + +jest.mock('../../readMessages', () => ({ + readMessages: jest.fn() +})); + +jest.mock('../../loadMissedMessages', () => ({ + loadMissedMessages: jest.fn() +})); + +jest.mock('../../helpers/markMessagesRead', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('../../../database', () => ({ + __esModule: true, + default: { + active: { + get: jest.fn(), + write: jest.fn(), + batch: jest.fn() + } + } +})); + +import RoomSubscription from '../room'; +import sdk from '../../../services/sdk'; +import { initStore } from '../../../store/auxStore'; +import { getMessageById } from '../../../database/services/Message'; +import buildMessage from '../../helpers/buildMessage'; +import { subscribeRoom, unsubscribeRoom } from '../../../../actions/room'; +import { clearUserTyping } from '../../../../actions/usersTyping'; +import { + flush, + framesOn, + makeCollection as makeBaseCollection, + makeReduxStore, + receiveFrame +} from '../../../testUtils/sdkIntegration'; +import type { IMockCollection, MockConnection } from '../../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../../testUtils/sdkIntegration'; + +const database = require('../../../database').default as { + active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; +}; + +const mockConnections: MockConnection[] = []; + +function makeCollection(name: string): IMockCollection { + const collection = makeBaseCollection(name); + collection.prepareCreate.mockImplementation((fn: (record: Record) => void) => { + const record = { _raw: { id: '' }, subscription: { id: '' } }; + fn(record); + return record; + }); + collection.schema = { columnArray: [] }; + return collection; +} + +const MESSAGE = { + _id: 'msg-1', + rid: 'room-rid', + msg: 'hello', + u: { _id: 'user-id', username: 'the-user' }, + ts: { $date: 1700000000000 } +}; + +let redux: ReturnType; +let collections: Record>; + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockConnections.length = 0; + collections = {}; + redux = makeReduxStore(); + initStore(redux.store); + database.active.get.mockReset().mockImplementation((name: string) => (collections[name] ??= makeCollection(name))); + database.active.write.mockReset().mockImplementation((fn: () => unknown) => fn()); + database.active.batch.mockReset().mockImplementation((...records: unknown[]) => Promise.resolve(records)); + (getMessageById as jest.Mock).mockResolvedValue(null); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +async function connectDriver() { + sdk.initialize('https://example.com'); + const connectPromise = sdk.connect(); + await flush(); + mockConnections[0].onopen(); + await flush(); + await connectPromise; +} + +async function subscribeToRoom(rid: string) { + const room = new RoomSubscription(rid); + const subscribing = room.subscribe(); + await flush(); + await subscribing; + await flush(); + return room; +} + +describe('RoomSubscription over the real SDK', () => { + it('subscribes to the room streams and registers the store subscription', async () => { + await connectDriver(); + + await subscribeToRoom('room-rid'); + + expect(framesOn(mockConnections[0], 'sub')).toHaveLength(5); + expect(redux.store.dispatch).toHaveBeenCalledWith(subscribeRoom('room-rid')); + }); + + it('routes a stream-room-messages frame into a written message', async () => { + await connectDriver(); + await subscribeToRoom('room-rid'); + + receiveFrame(mockConnections[0], { + msg: 'changed', + collection: 'stream-room-messages', + fields: { eventName: 'room-rid', args: [MESSAGE] } + }); + await flush(); + + expect(buildMessage).toHaveBeenCalledTimes(1); + expect(getMessageById).toHaveBeenCalledWith('msg-1'); + expect(database.active.write).toHaveBeenCalled(); + const record = database.active.batch.mock.calls[0][0]; + expect(record).toMatchObject({ _id: 'msg-1', rid: 'room-rid', msg: 'hello' }); + }); + + it('stops its listeners and unsubscribes all five subscriptions', async () => { + await connectDriver(); + const room = await subscribeToRoom('room-rid'); + + await room.unsubscribe(); + await flush(); + + expect(framesOn(mockConnections[0], 'unsub')).toHaveLength(5); + expect(redux.store.dispatch).toHaveBeenCalledWith(unsubscribeRoom('room-rid')); + expect(redux.store.dispatch).toHaveBeenCalledWith(clearUserTyping()); + + receiveFrame(mockConnections[0], { + msg: 'changed', + collection: 'stream-room-messages', + fields: { eventName: 'room-rid', args: [MESSAGE] } + }); + await flush(); + + expect(buildMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/methods/subscriptions/__tests__/rooms.hostGuard.test.ts b/app/lib/methods/subscriptions/__tests__/rooms.hostGuard.test.ts new file mode 100644 index 00000000000..2ef4e50488f --- /dev/null +++ b/app/lib/methods/subscriptions/__tests__/rooms.hostGuard.test.ts @@ -0,0 +1,92 @@ +const mockOnStreamData = jest.fn(async (_event: string, _callback: (message: IDDPMessage) => void) => ({ stop: jest.fn() })); +const mockSubscribeNotifyUser = jest.fn(async () => undefined); + +jest.mock('../../../services/sdk', () => { + const { makeSdkMock } = jest.requireActual('../../../testUtils/sdkIntegration'); + return { + __esModule: true, + default: makeSdkMock({ + onStreamData: (...args: Parameters) => mockOnStreamData(...args), + subscribeNotifyUser: () => mockSubscribeNotifyUser() + }) + }; +}); + +jest.mock('../../../database', () => ({ + __esModule: true, + default: { active: { get: jest.fn(), write: jest.fn(), batch: jest.fn() } } +})); + +jest.mock('../../../store/auxStore', () => ({ + store: { dispatch: jest.fn(), getState: jest.fn(() => ({ settings: {}, login: { user: {} } })) } +})); + +jest.mock('../../helpers/log', () => ({ __esModule: true, default: jest.fn() })); + +import subscribeRooms, { roomsSubscription } from '../rooms'; +import sdk from '../../../services/sdk'; +import database from '../../../database'; +import type { IDDPMessage } from '../../../../definitions/IDDPMessage'; +import type * as SdkIntegration from '../../../testUtils/sdkIntegration'; + +const mockedSdk = sdk as unknown as SdkIntegration.IMockSdk; +const mockedDatabase = database as unknown as { active: { get: jest.Mock } }; + +const HOST = 'https://open.rocket.chat'; + +const removedSubscriptionFrame = (): IDDPMessage => + ({ + msg: 'changed', + collection: 'stream-notify-user', + id: 'id', + fields: { + eventName: 'userId/subscriptions-changed', + args: ['removed', { rid: 'rid' }] + } + }) as unknown as IDDPMessage; + +describe('subscribeRooms host guard', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockedSdk.setClient(null); + }); + + it('does not open the stream when there is no client', () => { + subscribeRooms(); + + expect(mockOnStreamData).not.toHaveBeenCalled(); + expect(mockSubscribeNotifyUser).not.toHaveBeenCalled(); + }); + + it('drops a frame that arrives after the client is gone', async () => { + mockedSdk.setClient({ host: HOST }); + subscribeRooms(); + + const [, handleStreamMessageReceived] = mockOnStreamData.mock.calls[0]; + mockedSdk.setClient(null); + await handleStreamMessageReceived(removedSubscriptionFrame()); + + expect(mockedDatabase.active.get).not.toHaveBeenCalled(); + }); + + it('drops a frame that arrives after the subscription stopped', async () => { + mockedSdk.setClient({ host: HOST }); + subscribeRooms(); + + const [, handleStreamMessageReceived] = mockOnStreamData.mock.calls[0]; + roomsSubscription?.stop(); + await handleStreamMessageReceived(removedSubscriptionFrame()); + + expect(mockedDatabase.active.get).not.toHaveBeenCalled(); + }); + + it('processes a frame whose host matches the subscribed server', async () => { + mockedSdk.setClient({ host: HOST }); + subscribeRooms(); + + const [, handleStreamMessageReceived] = mockOnStreamData.mock.calls[0]; + await handleStreamMessageReceived(removedSubscriptionFrame()); + + expect(mockedDatabase.active.get).toHaveBeenCalledWith('subscriptions'); + }); +}); diff --git a/app/lib/methods/subscriptions/room.ts b/app/lib/methods/subscriptions/room.ts index 33dd6b46047..93f67bdeb52 100644 --- a/app/lib/methods/subscriptions/room.ts +++ b/app/lib/methods/subscriptions/room.ts @@ -2,6 +2,7 @@ import EJSON from 'ejson'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { InteractionManager } from 'react-native'; import { Q } from '@nozbe/watermelondb'; +import { type ISubscription } from '@rocket.chat/sdk/interfaces'; import log from '../helpers/log'; import protectedFunction from '../helpers/protectedFunction'; @@ -18,7 +19,6 @@ import { Encryption } from '../../encryption'; import { type IMessage, type TMessageModel, - type TSubscriptionModel, type TThreadMessageModel, type TThreadModel, type IDeleteMessageBulkParams @@ -32,7 +32,7 @@ import markMessagesRead from '../helpers/markMessagesRead'; export default class RoomSubscription { private rid: string; private isAlive: boolean; - private promises?: Promise; + private promises?: Promise<(ISubscription | undefined)[]>; private connectedListener?: Promise; private disconnectedListener?: Promise; private notifyRoomListener?: Promise; @@ -68,7 +68,7 @@ export default class RoomSubscription { if (this.promises) { try { const subscriptions = (await this.promises) || []; - subscriptions.forEach(sub => sub.unsubscribe().catch(() => console.log('unsubscribeRoom'))); + subscriptions.forEach(sub => sub?.unsubscribe().catch(() => console.log('unsubscribeRoom'))); } catch (e) { // do nothing } diff --git a/app/lib/methods/subscriptions/rooms.ts b/app/lib/methods/subscriptions/rooms.ts index 78aeb9ca674..dc4d4686cf7 100644 --- a/app/lib/methods/subscriptions/rooms.ts +++ b/app/lib/methods/subscriptions/rooms.ts @@ -39,7 +39,7 @@ import { handleVideoConfIncomingWebsocketMessages } from '../../../actions/video const removeListener = (listener: { stop: () => void }) => listener.stop(); let streamListener: Promise | false; -let subServer: string; +let subscribedHost: string | null = null; let queue: { [key: string]: ISubscription | IRoom } = {}; let subTimer: ReturnType | null | false = null; const WINDOW_TIME = 500; @@ -301,8 +301,7 @@ export default function subscribeRooms() { const handleStreamMessageReceived = protectedFunction(async (ddpMessage: IDDPMessage) => { const db = database.active; - // check if the server from variable is the same as the js sdk client - if (sdk && sdk.current.client && sdk.current.client.host !== subServer) { + if (!subscribedHost || sdk.host !== subscribedHost) { return; } if (ddpMessage.msg === 'added') { @@ -433,14 +432,20 @@ export default function subscribeRooms() { subTimer = false; } roomsSubscription = null; + subscribedHost = null; }; + const host = sdk.host; + if (!host) { + return null; + } + streamListener = sdk.onStreamData('stream-notify-user', handleStreamMessageReceived); try { // set the server that started this task - subServer = sdk.current.client.host; - sdk.current.subscribeNotifyUser().catch((e: unknown) => console.log(e)); + subscribedHost = host; + sdk.subscribeNotifyUser().catch((e: unknown) => console.log(e)); roomsSubscription = { stop: () => stop() }; return null; } catch (e) { diff --git a/app/lib/services/__tests__/connect.integration.test.ts b/app/lib/services/__tests__/connect.integration.test.ts new file mode 100644 index 00000000000..ade8f5717c5 --- /dev/null +++ b/app/lib/services/__tests__/connect.integration.test.ts @@ -0,0 +1,409 @@ +jest.unmock('@rocket.chat/sdk'); + +import { connect, login, loginWithPassword } from '../connect'; +import sdk from '../sdk'; +import { initStore } from '../../store/auxStore'; +import { connectRequest, connectSuccess, disconnect as disconnectAction } from '../../../actions/connect'; +import { loginRequest, logout, setUser } from '../../../actions/login'; +import { setActiveUsers } from '../../../actions/activeUsers'; +import { updateSettings } from '../../../actions/settings'; +import { updatePermission } from '../../../actions/permissions'; +import { _activeUsers, _setUserTimer } from '../../methods/setUser'; +import { flush, framesOn, makeCollection, makeReduxStore, receiveFrame } from '../../testUtils/sdkIntegration'; +import type { MockConnection } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; + +const mockConnections: MockConnection[] = []; + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); + }) +); + +jest.mock('../voip/MediaSessionInstance', () => ({ + mediaSessionInstance: { + reset: jest.fn(), + drainPendingHangups: jest.fn() + } +})); + +jest.mock('../twoFactor', () => ({ + twoFactor: jest.fn() +})); + +jest.mock('../../../i18n', () => ({ + __esModule: true, + default: { t: jest.fn((key: string) => key) } +})); + +jest.mock('../../methods/subscribeRooms', () => ({ + subscribeRooms: jest.fn(), + unsubscribeRooms: jest.fn() +})); + +jest.mock('../../methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('../../database', () => ({ + __esModule: true, + default: { + setActiveDB: jest.fn(), + servers: { get: jest.fn(), write: jest.fn() }, + active: { + get: jest.fn(), + write: jest.fn(), + batch: jest.fn() + } + } +})); + +const database = require('../../database').default as { + setActiveDB: jest.Mock; + active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; +}; + +const REST_LOGIN_ME = { + username: 'the-user', + name: 'The User', + language: 'en', + status: 'online', + statusText: '', + customFields: { role: 'admin' }, + statusLivechat: 'available', + emails: [{ address: 'the-user@example.com', verified: true }], + roles: ['user', 'admin'], + avatarETag: 'etag-123', + settings: { preferences: { alsoSendThreadToChannel: 'default' } }, + bio: 'hi', + nickname: 'nick', + requirePasswordChange: false +}; + +let redux: ReturnType; +let collections: Record>; + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockConnections.length = 0; + collections = {}; + redux = makeReduxStore(); + initStore(redux.store); + database.setActiveDB.mockReset(); + database.active.get.mockReset().mockImplementation((name: string) => (collections[name] ??= makeCollection(name))); + database.active.write.mockReset().mockImplementation((fn: () => unknown) => fn()); + database.active.batch.mockReset().mockImplementation((...records: unknown[]) => Promise.resolve(records)); + _activeUsers.activeUsers = {} as never; + _setUserTimer.setUserTimer = null; + REST_LOGIN_ME.settings.preferences = { alsoSendThreadToChannel: 'default' }; + global.fetch = jest.fn((url: unknown) => { + const target = String(url); + if (target.includes('/api/v1/login')) { + return Promise.resolve({ + status: 200, + json: () => + Promise.resolve({ status: 'success', data: { userId: 'user-id', authToken: 'auth-token', me: REST_LOGIN_ME } }) + }); + } + return Promise.resolve({ status: 200, json: () => Promise.resolve({ success: false }) }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +async function connectAndDriveHandshake(server = 'https://example.com') { + await connect({ server }); + await flush(); + expect(mockConnections.length).toBeGreaterThan(0); + mockConnections[0].onopen(); + await flush(); +} + +describe('connect() over the real SDK', () => { + it('dispatches connectRequest when connecting and connectSuccess once on the handshake', async () => { + await connectAndDriveHandshake(); + + expect(redux.store.dispatch).toHaveBeenCalledWith(connectRequest()); + expect(redux.store.dispatch).toHaveBeenCalledWith(connectSuccess()); + expect(redux.store.dispatch.mock.calls.filter(([action]) => action.type === connectSuccess().type)).toHaveLength(1); + }); + + it('ignores a repeated connected frame after the first', async () => { + await connectAndDriveHandshake(); + redux.state.meteor.connected = true; + + receiveFrame(mockConnections[0], { msg: 'connected', session: 'again' }); + await flush(); + + expect(redux.store.dispatch.mock.calls.filter(([action]) => action.type === connectSuccess().type)).toHaveLength(1); + }); + + it('dispatches disconnect when the socket closes', async () => { + await connectAndDriveHandshake(); + + mockConnections[0].onclose({ code: 1006 }); + await flush(); + + expect(redux.store.dispatch).toHaveBeenCalledWith(disconnectAction()); + }); + + it('resumes login with the stored token once connected', async () => { + redux.state.login.user = { token: 'stored-token' }; + + await connectAndDriveHandshake(); + + expect(redux.store.dispatch).toHaveBeenCalledWith(loginRequest({ resume: 'stored-token' }, false)); + }); + + it('tears down the prior connection and stops its listeners when connect() is re-run', async () => { + await connectAndDriveHandshake('https://a.example.com'); + const firstConnection = mockConnections[0]; + const successCount = () => redux.store.dispatch.mock.calls.filter(([action]) => action.type === connectSuccess().type).length; + const before = successCount(); + + await connect({ server: 'https://b.example.com' }); + await flush(); + + expect(firstConnection.close).toHaveBeenCalled(); + + firstConnection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'x' }) }); + await flush(); + + expect(successCount()).toBe(before); + }); +}); + +describe('login() over the real SDK', () => { + async function connectLoggedIn() { + await connectAndDriveHandshake(); + } + + it('maps the server login result to the logged user', async () => { + await connectLoggedIn(); + + const loginPromise = login({ user: 'the-user', password: 'secret' }); + await flush(); + const user = await loginPromise; + + expect(user).toEqual( + expect.objectContaining({ + id: 'user-id', + token: 'auth-token', + username: 'the-user', + name: 'The User', + language: 'en', + status: 'online', + roles: ['user', 'admin'], + avatarETag: 'etag-123', + bio: 'hi', + nickname: 'nick' + }) + ); + }); + + it('defaults the parser/main-thread preferences on servers >= 5.0.0', async () => { + await connectLoggedIn(); + + const loginPromise = login({ user: 'the-user', password: 'secret' }); + await flush(); + const user = await loginPromise; + + expect(user).toEqual( + expect.objectContaining({ + enableMessageParserEarlyAdoption: true, + showMessageInMainThread: false + }) + ); + }); + + it('reads the parser/main-thread preferences from the server below 5.0.0', async () => { + redux.state.server.version = '4.9.0'; + (REST_LOGIN_ME.settings.preferences as Record).enableMessageParserEarlyAdoption = false; + (REST_LOGIN_ME.settings.preferences as Record).showMessageInMainThread = true; + + await connectLoggedIn(); + + const loginPromise = login({ user: 'the-user', password: 'secret' }); + await flush(); + const user = await loginPromise; + + expect(user).toEqual( + expect.objectContaining({ + enableMessageParserEarlyAdoption: false, + showMessageInMainThread: true + }) + ); + }); + + it('sends LDAP params on the wire when LDAP is enabled', async () => { + redux.state.settings.LDAP_Enable = true; + await connectLoggedIn(); + + const loginPromise = loginWithPassword({ user: 'the-user', password: 'secret' }); + await flush(); + await loginPromise; + + const loginCall = (global.fetch as jest.Mock).mock.calls.find(([url]) => String(url).includes('/api/v1/login')); + const body = JSON.parse(loginCall[1].body); + expect(body).toEqual(expect.objectContaining({ username: 'the-user', ldapPass: 'secret', ldap: true })); + }); + + it('sends CROWD params on the wire when CROWD is enabled', async () => { + redux.state.settings.CROWD_Enable = true; + await connectLoggedIn(); + + const loginPromise = loginWithPassword({ user: 'the-user', password: 'secret' }); + await flush(); + await loginPromise; + + const loginCall = (global.fetch as jest.Mock).mock.calls.find(([url]) => String(url).includes('/api/v1/login')); + const body = JSON.parse(loginCall[1].body); + expect(body).toEqual(expect.objectContaining({ username: 'the-user', crowdPassword: 'secret', crowd: true })); + }); +}); + +describe('onStreamData handlers over real frames', () => { + it('public-settings-changed dispatches updateSettings', async () => { + await connectAndDriveHandshake(); + database.active.get('settings').find.mockResolvedValue({ update: jest.fn(async (fn: (u: unknown) => void) => fn({})) }); + + receiveFrame(mockConnections[0], { + msg: 'changed', + collection: 'stream-notify-all', + fields: { eventName: 'public-settings-changed', args: [null, { _id: 'Site_Name', value: 'New Name' }] } + }); + await flush(); + + expect(redux.store.dispatch).toHaveBeenCalledWith(updateSettings('Site_Name', 'New Name')); + }); + + it('stream-user-presence sets the active user and the logged user', async () => { + redux.state.login.user = { id: 'user-id' }; + await connectAndDriveHandshake(); + + receiveFrame(mockConnections[0], { + msg: 'changed', + collection: 'stream-user-presence', + fields: { uid: 'user-id', args: [['user-id', 1, '', '', undefined]] } + }); + await flush(); + + expect(redux.store.dispatch).toHaveBeenCalledWith( + setActiveUsers({ 'user-id': expect.objectContaining({ status: 'online' }) }) + ); + expect(redux.store.dispatch).toHaveBeenCalledWith(setUser(expect.objectContaining({ status: 'online' }))); + }); + + it('user-status batches into _activeUsers and sets the logged user', async () => { + redux.state.login.user = { id: 'user-id' }; + await connectAndDriveHandshake(); + + receiveFrame(mockConnections[0], { + msg: 'changed', + collection: 'stream-notify-logged', + fields: { eventName: 'user-status', args: [['user-id', 'online', 1, '', '', undefined]] } + }); + await flush(); + + expect(_activeUsers.activeUsers['user-id']).toEqual(expect.objectContaining({ status: 'online' })); + expect(redux.store.dispatch).toHaveBeenCalledWith(setUser(expect.objectContaining({ status: 'online' }))); + }); + + it('permissions-changed dispatches updatePermission', async () => { + await connectAndDriveHandshake(); + database.active.get('permissions').find.mockResolvedValue({ update: jest.fn(async (fn: (u: unknown) => void) => fn({})) }); + + receiveFrame(mockConnections[0], { + msg: 'changed', + collection: 'stream-notify-logged', + fields: { eventName: 'permissions-changed', args: [null, { _id: 'create-c', roles: ['admin'] }] } + }); + await flush(); + + expect(redux.store.dispatch).toHaveBeenCalledWith(updatePermission('create-c', ['admin'])); + }); + + it('Users:NameChanged upserts the user in the database', async () => { + await connectAndDriveHandshake(); + const collection = database.active.get('users'); + collection.find.mockResolvedValue({ update: jest.fn(async (fn: (u: unknown) => void) => fn({})) }); + + receiveFrame(mockConnections[0], { + msg: 'changed', + collection: 'stream-notify-logged', + fields: { eventName: 'Users:NameChanged', args: [{ _id: 'user-id', username: 'renamed' }] } + }); + await flush(); + + expect(collection.find).toHaveBeenCalledWith('user-id'); + expect(database.active.write).toHaveBeenCalled(); + }); + + it('stream-force_logout dispatches logout(true)', async () => { + await connectAndDriveHandshake(); + + receiveFrame(mockConnections[0], { msg: 'changed', collection: 'stream-force_logout', fields: {} }); + await flush(); + + expect(redux.store.dispatch).toHaveBeenCalledWith(logout(true)); + }); + + it('users frame feeds _setUser', async () => { + await connectAndDriveHandshake(); + + receiveFrame(mockConnections[0], { + msg: 'added', + collection: 'users', + id: 'user-id', + fields: { username: 'the-user', status: 'online' } + }); + await flush(); + + expect(_activeUsers.activeUsers['user-id']).toEqual(expect.objectContaining({ status: 'online' })); + }); +}); + +describe('sdk.subscribeRoom() over the real SDK', () => { + it('subscribes to the room streams for servers >= 4.0.0', async () => { + redux.state.server.version = '5.0.0'; + await connectAndDriveHandshake(); + + const subscribing = sdk.subscribeRoom('room-rid'); + await flush(); + await subscribing; + + const subs = framesOn(mockConnections[0], 'sub'); + expect(subs.map(sub => sub.name)).toEqual([ + 'stream-notify-room', + 'stream-room-messages', + 'stream-notify-room', + 'stream-notify-room', + 'stream-notify-room' + ]); + expect(subs.map(sub => sub.params?.[0])).toEqual([ + 'room-rid/user-activity', + 'room-rid', + 'room-rid/deleteMessage', + 'room-rid/deleteMessageBulk', + 'room-rid/messagesRead' + ]); + }); + + it('subscribes to the typing event on servers below 4.0.0', async () => { + redux.state.server.version = '3.9.0'; + await connectAndDriveHandshake(); + + const subscribing = sdk.subscribeRoom('room-rid'); + await flush(); + await subscribing; + + const subs = framesOn(mockConnections[0], 'sub'); + expect(subs[0].params?.[0]).toBe('room-rid/typing'); + }); +}); diff --git a/app/lib/services/__tests__/socketHealth.integration.test.ts b/app/lib/services/__tests__/socketHealth.integration.test.ts index edd77e267b0..aaae464b76f 100644 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ b/app/lib/services/__tests__/socketHealth.integration.test.ts @@ -1,128 +1,47 @@ import sdk from '../sdk'; import { recoverSocket } from '../socketHealth'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { DDPDriver } = require('@rocket.chat/sdk/lib/drivers/ddp') as { - DDPDriver: new (options: { host: string; logger: unknown }) => PatchedDriver; -}; - -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: () => void; -} - -interface WireFrame { - msg: string; - id?: string; - name?: string; - params?: string[]; -} - -interface PatchedDriver { - userId: string; - pingInterval: number; - reopenNow(): Promise; - waitForNotifyUserMediaSubs(timeoutMs?: number): Promise; - ddp: { - lastPing: number; - pingTimeout?: ReturnType; - openTimeout?: ReturnType; - open(): Promise; - send(message: Record): Promise; - subscriptions: Record; - }; -} +import { + addMediaSubs, + backdateLastPing, + buildConnectedDriver, + framesOn, + stopAnsweringFrames +} from '../../testUtils/sdkIntegration'; +import type { IMockSdk, MockConnection, IMockSdkDriver } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; const mockConnections: MockConnection[] = []; jest.mock('universal-websocket-client', () => jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string }; - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } else if (message.msg === 'sub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); }) ); -jest.mock('../sdk', () => ({ - __esModule: true, - default: { current: undefined } -})); +jest.mock('../sdk', () => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return { __esModule: true, default: sdkIntegration.makeSdkMock() }; +}); const USER_ID = 'user-id'; const PING_INTERVAL = 10000; +const CLOSED = 3; -const logger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }; - -/** Real patched DDPDriver over a mocked WebSocket, connected and logged in. */ -async function buildConnectedDriver() { - const driver = new DDPDriver({ host: 'localhost:3000', logger }); - driver.userId = USER_ID; - const openPromise = driver.ddp.open(); - mockConnections[0].onopen(); - await jest.advanceTimersByTimeAsync(0); - await openPromise; - return driver; -} - -function addMediaSubs(driver: PatchedDriver) { - ['media-signal', 'media-calls'].forEach((name, index) => { - const id = `sub-${index}`; - driver.ddp.subscriptions[id] = { - id, - name: 'stream-notify-user', - params: [`${USER_ID}/${name}`], - unsubscribe: jest.fn() - }; - }); -} - -function backdateLastPing(driver: PatchedDriver, ageMs: number) { - driver.ddp.lastPing = Date.now() - ageMs; -} - -/** Frames of a given `msg` sent over the wire on one connection. */ -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); -} - -describe('recoverSocket against the real patched socket', () => { - let driver: PatchedDriver; +describe('recoverSocket against the real SDK socket', () => { + let driver: IMockSdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); mockConnections.length = 0; - driver = await buildConnectedDriver(); - (sdk as unknown as { current: { ddp: PatchedDriver } }).current = { ddp: driver }; + driver = await buildConnectedDriver(mockConnections, USER_ID); + (sdk as unknown as IMockSdk).setClient({ driver }); }); afterEach(() => { - if (driver.ddp.pingTimeout) clearTimeout(driver.ddp.pingTimeout); - if (driver.ddp.openTimeout) clearTimeout(driver.ddp.openTimeout); + if (driver.socket.pingTimeout) clearTimeout(driver.socket.pingTimeout); + if (driver.socket.openTimeout) clearTimeout(driver.socket.openTimeout); jest.useRealTimers(); }); @@ -137,20 +56,17 @@ describe('recoverSocket against the real patched socket', () => { await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('confirmed-alive'); - // The round trip pinged the existing socket and the pong kept it alive. expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); expect(mockConnections).toHaveLength(1); }); it('reopens a doubtful socket when the round trip gets no pong', async () => { backdateLastPing(driver, PING_INTERVAL + 5000); - // A zombie socket: still `readyState: 1`, but the server never answers. - mockConnections[0].send.mockImplementation(() => undefined); + stopAnsweringFrames(mockConnections[0]); const recovery = recoverSocket(); await jest.advanceTimersByTimeAsync(2000); - // The round trip was actually attempted on the dead socket before reopening. expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); expect(mockConnections).toHaveLength(2); mockConnections[1].onopen(); @@ -159,15 +75,12 @@ describe('recoverSocket against the real patched socket', () => { await expect(recovery).resolves.toBe('reopened'); }); - it('reopens a frozen socket whose last ping is still young', async () => { - // A young `lastPing` proves nothing: `onOpen` refreshes it before the handshake - // reply lands, so the timestamp can sit on an unusable session. - mockConnections[0].send.mockImplementation(() => undefined); + it('reopens a frozen socket whose young lastPing sits on an unusable session', async () => { + stopAnsweringFrames(mockConnections[0]); const recovery = recoverSocket(); await jest.advanceTimersByTimeAsync(2000); - // The young ping bought a round trip, and the silent socket failed it. expect(framesOn(mockConnections[0], 'ping').length).toBeGreaterThan(0); expect(mockConnections).toHaveLength(2); mockConnections[1].onopen(); @@ -183,7 +96,6 @@ describe('recoverSocket against the real patched socket', () => { await jest.advanceTimersByTimeAsync(0); expect(mockConnections).toHaveLength(2); - // No raw round-trip ping was sent on the dead socket. expect(framesOn(mockConnections[0], 'ping')).toHaveLength(0); mockConnections[1].onopen(); @@ -195,7 +107,6 @@ describe('recoverSocket against the real patched socket', () => { it('shares one reopen with a concurrent direct reopenNow', async () => { backdateLastPing(driver, PING_INTERVAL * 3); - // The foreground path reopens the dead socket while recovery does the same. const directReopen = driver.reopenNow(); const recovery = recoverSocket(); @@ -208,20 +119,18 @@ describe('recoverSocket against the real patched socket', () => { await expect(recovery).resolves.toBe('reopened'); expect(mockConnections).toHaveLength(2); - // No queued third open fires later — the reopen really was shared. await jest.advanceTimersByTimeAsync(60000); expect(mockConnections).toHaveLength(2); }); it('rejects an in-flight DDP method call when recovery reopens the socket', async () => { let rejected = false; - const inFlight = driver.ddp.send({ msg: 'method', method: 'getRoomByTypeAndName', params: [] }).catch(() => { + const inFlight = driver.socket.send({ msg: 'method', method: 'getRoomByTypeAndName', params: [] }).catch(() => { rejected = true; }); await jest.advanceTimersByTimeAsync(0); expect(rejected).toBe(false); - // The socket dies silently after the call went out. backdateLastPing(driver, PING_INTERVAL * 3); const recovery = recoverSocket(); @@ -237,7 +146,7 @@ describe('recoverSocket against the real patched socket', () => { it('re-sends the media subscriptions on the new socket reusing their ids', async () => { backdateLastPing(driver, PING_INTERVAL * 3); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); const recovery = recoverSocket(); await jest.advanceTimersByTimeAsync(0); @@ -249,11 +158,82 @@ describe('recoverSocket against the real patched socket', () => { await jest.advanceTimersByTimeAsync(200); await expect(resubscribed).resolves.toBe(true); - // Both media subs went out on the new socket reusing their ids. expect(framesOn(mockConnections[0], 'sub')).toHaveLength(0); expect(framesOn(mockConnections[1], 'sub')).toEqual([ expect.objectContaining({ id: 'sub-0', name: 'stream-notify-user', params: [`${USER_ID}/media-signal`] }), expect.objectContaining({ id: 'sub-1', name: 'stream-notify-user', params: [`${USER_ID}/media-calls`] }) ]); }); + + it('reopens a closed transport without a round trip even when lastPing is fresh', async () => { + mockConnections[0].readyState = CLOSED; + + const recovery = recoverSocket(); + await jest.advanceTimersByTimeAsync(0); + + expect(mockConnections).toHaveLength(2); + expect(framesOn(mockConnections[0], 'ping')).toHaveLength(0); + + mockConnections[1].onopen(); + await jest.advanceTimersByTimeAsync(0); + + await expect(recovery).resolves.toBe('reopened'); + }); + + it('waits for media subs to appear after reopen, then re-acks them', async () => { + backdateLastPing(driver, PING_INTERVAL * 3); + + const recovery = recoverSocket(); + await jest.advanceTimersByTimeAsync(0); + mockConnections[1].onopen(); + await jest.advanceTimersByTimeAsync(0); + await expect(recovery).resolves.toBe('reopened'); + + const resubscribed = driver.waitForNotifyUserMediaSubs(1000); + await jest.advanceTimersByTimeAsync(100); + expect(framesOn(mockConnections[1], 'sub')).toHaveLength(0); + + addMediaSubs(driver, USER_ID); + await jest.advanceTimersByTimeAsync(200); + + await expect(resubscribed).resolves.toBe(true); + expect(framesOn(mockConnections[1], 'sub')).toEqual([ + expect.objectContaining({ id: 'sub-0', name: 'stream-notify-user', params: [`${USER_ID}/media-signal`] }), + expect.objectContaining({ id: 'sub-1', name: 'stream-notify-user', params: [`${USER_ID}/media-calls`] }) + ]); + }); + + it('resolves false when the reopened socket never acks the re-sub', async () => { + backdateLastPing(driver, PING_INTERVAL * 3); + addMediaSubs(driver, USER_ID); + + const recovery = recoverSocket(); + await jest.advanceTimersByTimeAsync(0); + mockConnections[1].onopen(); + await jest.advanceTimersByTimeAsync(0); + await expect(recovery).resolves.toBe('reopened'); + + stopAnsweringFrames(mockConnections[1]); + + const resubscribed = driver.waitForNotifyUserMediaSubs(500); + await jest.advanceTimersByTimeAsync(500); + + await expect(resubscribed).resolves.toBe(false); + }); + + it('shares one reopen between two concurrent recoverSocket calls', async () => { + backdateLastPing(driver, PING_INTERVAL * 3); + + const first = recoverSocket(); + const second = recoverSocket(); + await jest.advanceTimersByTimeAsync(0); + + expect(mockConnections).toHaveLength(2); + mockConnections[1].onopen(); + await jest.advanceTimersByTimeAsync(0); + + await expect(first).resolves.toBe('reopened'); + await expect(second).resolves.toBe('reopened'); + expect(mockConnections).toHaveLength(2); + }); }); diff --git a/app/lib/services/__tests__/socketHealth.test.ts b/app/lib/services/__tests__/socketHealth.test.ts index 031c7fe92a2..ef51619afb4 100644 --- a/app/lib/services/__tests__/socketHealth.test.ts +++ b/app/lib/services/__tests__/socketHealth.test.ts @@ -1,166 +1,144 @@ -jest.mock('../sdk', () => ({ - __esModule: true, - default: { - current: { ddp: undefined } - } -})); - -import sdk from '../sdk'; +import sdk, { type ISocketDriver } from '../sdk'; import { classifySocketHealth, recoverSocket } from '../socketHealth'; - -const now = 1_000_000; - -const sdkMock = sdk as unknown as { current: { ddp: unknown } | undefined }; - -interface MockDdp { - connected?: boolean; - lastPing: number; - pingInterval?: number; - config?: { ping?: number }; - reopenNow: jest.Mock, []>; - probe: jest.Mock, [number]>; -} - -function makeDdp(overrides: Partial = {}): MockDdp { - return { - lastPing: now, - pingInterval: 10000, - config: { ping: 10000 }, - reopenNow: jest.fn, []>(() => Promise.resolve()), - probe: jest.fn, [number]>(() => Promise.resolve(true)), - ...overrides - }; -} - -describe('classifySocketHealth', () => { - beforeEach(() => { - jest.spyOn(Date, 'now').mockReturnValue(now); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('returns reopen when age > 2 * pingInterval', () => { - const ddp = makeDdp({ lastPing: now - 21000 }); - expect(classifySocketHealth(ddp)).toBe('reopen'); - }); - - it('returns round-trip-check when age <= 2 * pingInterval', () => { - const ddp = makeDdp({ lastPing: now - 15000 }); - expect(classifySocketHealth(ddp)).toBe('round-trip-check'); - }); - - it('returns round-trip-check for a young ping rather than trusting it outright', () => { - const ddp = makeDdp({ lastPing: now - 5000 }); - expect(classifySocketHealth(ddp)).toBe('round-trip-check'); - }); - - it('falls back to config.ping when pingInterval is missing', () => { - // Only a 30s config.ping keeps a 21s-old ping below the reopen threshold. - const ddp = makeDdp({ pingInterval: undefined, config: { ping: 30000 }, lastPing: now - 21000 }); - expect(classifySocketHealth(ddp)).toBe('round-trip-check'); - }); - - it('uses 10000ms default when pingInterval and config.ping are missing', () => { - const ddp = makeDdp({ pingInterval: undefined, config: {}, lastPing: now - 21000 }); - expect(classifySocketHealth(ddp)).toBe('reopen'); - }); - - it('returns reopen for a closed socket even when lastPing is fresh', () => { - const ddp = makeDdp({ connected: false, lastPing: now }); - expect(classifySocketHealth(ddp)).toBe('reopen'); - }); +import { buildConnectedDriver } from '../../testUtils/sdkIntegration'; +import type { IMockSdk, IMockSdkDriver, MockConnection } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; + +const mockConnections: MockConnection[] = []; + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); + }) +); + +jest.mock('../sdk', () => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return { __esModule: true, default: sdkIntegration.makeSdkMock() }; }); -describe('recoverSocket', () => { - let ddp: MockDdp; - - beforeEach(() => { - ddp = makeDdp({ lastPing: Date.now() }); - sdkMock.current = { ddp }; - }); - - it('keeps a socket whose round trip answers', async () => { - await expect(recoverSocket()).resolves.toBe('confirmed-alive'); - expect(ddp.reopenNow).not.toHaveBeenCalled(); - }); - - it('runs the round trip with a 2s budget', async () => { - await recoverSocket(); - expect(ddp.probe).toHaveBeenCalledWith(2000); - }); - - it('reopens when the round trip goes unanswered', async () => { - ddp.probe.mockResolvedValue(false); - await expect(recoverSocket()).resolves.toBe('reopened'); - expect(ddp.reopenNow).toHaveBeenCalledTimes(1); - }); +const sdkMock = sdk as unknown as IMockSdk; - it('reopens a known-dead socket without a round trip', async () => { - ddp.connected = false; - await expect(recoverSocket()).resolves.toBe('reopened'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).toHaveBeenCalledTimes(1); - }); +const USER_ID = 'user-id'; +const CLOSED = 3; - it('reports no-socket when the ddp handle is missing', async () => { - sdkMock.current = { ddp: undefined }; - await expect(recoverSocket()).resolves.toBe('no-socket'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).not.toHaveBeenCalled(); - }); +describe('socket health against a driver from the shared harness', () => { + let driver: IMockSdkDriver; + let probe: jest.SpyInstance, [number?]>; + let reopenNow: jest.SpyInstance, []>; - it('reports no-socket when there is no sdk instance', async () => { - sdkMock.current = undefined; - await expect(recoverSocket()).resolves.toBe('no-socket'); + beforeEach(async () => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockConnections.length = 0; + driver = await buildConnectedDriver(mockConnections, USER_ID); + probe = jest.spyOn(driver, 'probe').mockResolvedValue(true); + reopenNow = jest.spyOn(driver, 'reopenNow').mockResolvedValue(); + sdkMock.setClient({ driver }); }); - it('rejects when the round trip throws', async () => { - ddp.probe.mockRejectedValue(new Error('round trip failed')); - await expect(recoverSocket()).rejects.toThrow('round trip failed'); - }); - - it('rejects when reopening throws', async () => { - ddp.connected = false; - ddp.reopenNow.mockRejectedValue(new Error('reopen failed')); - await expect(recoverSocket()).rejects.toThrow('reopen failed'); - }); - - it('shares one in-flight recovery between overlapping callers', async () => { - const outcomes = await Promise.all([recoverSocket(), recoverSocket()]); - expect(outcomes).toEqual(['confirmed-alive', 'confirmed-alive']); - expect(ddp.probe).toHaveBeenCalledTimes(1); - }); - - it('starts a fresh recovery after the shared one settles', async () => { - await recoverSocket(); - await recoverSocket(); - expect(ddp.probe).toHaveBeenCalledTimes(2); - }); - - it('abandons the aborted caller while the shared recovery runs on', async () => { - let answerRoundTrip: (alive: boolean) => void = () => {}; - ddp.probe.mockImplementation(() => new Promise(resolve => (answerRoundTrip = resolve))); - - const controller = new AbortController(); - const aborted = recoverSocket({ abortSignal: controller.signal }); - const other = recoverSocket(); - - controller.abort(); - await expect(aborted).resolves.toBe('abandoned'); - - answerRoundTrip(true); - await expect(other).resolves.toBe('confirmed-alive'); - expect(ddp.probe).toHaveBeenCalledTimes(1); - }); - - it('abandons a pre-aborted caller without touching the socket', async () => { - const controller = new AbortController(); - controller.abort(); - - await expect(recoverSocket({ abortSignal: controller.signal })).resolves.toBe('abandoned'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + afterEach(() => { + if (driver.socket.pingTimeout) clearTimeout(driver.socket.pingTimeout); + if (driver.socket.openTimeout) clearTimeout(driver.socket.openTimeout); + jest.useRealTimers(); + }); + + describe('classifySocketHealth', () => { + it('returns round-trip-check for a connected socket rather than trusting it outright', () => { + expect(classifySocketHealth(driver as unknown as ISocketDriver)).toBe('round-trip-check'); + }); + + it('returns reopen for a closed socket even when lastPing is fresh', () => { + mockConnections[0].readyState = CLOSED; + expect(classifySocketHealth(driver as unknown as ISocketDriver)).toBe('reopen'); + }); + }); + + describe('recoverSocket', () => { + it('keeps a socket whose round trip answers', async () => { + await expect(recoverSocket()).resolves.toBe('confirmed-alive'); + expect(reopenNow).not.toHaveBeenCalled(); + }); + + it('runs the round trip with a 2s budget', async () => { + await recoverSocket(); + expect(probe).toHaveBeenCalledWith(2000); + }); + + it('reopens when the round trip goes unanswered', async () => { + probe.mockResolvedValue(false); + await expect(recoverSocket()).resolves.toBe('reopened'); + expect(reopenNow).toHaveBeenCalledTimes(1); + }); + + it('reopens a known-dead socket without a round trip', async () => { + mockConnections[0].readyState = CLOSED; + await expect(recoverSocket()).resolves.toBe('reopened'); + expect(probe).not.toHaveBeenCalled(); + expect(reopenNow).toHaveBeenCalledTimes(1); + }); + + it('reports no-socket when the driver handle is missing', async () => { + sdkMock.setClient({}); + await expect(recoverSocket()).resolves.toBe('no-socket'); + expect(probe).not.toHaveBeenCalled(); + expect(reopenNow).not.toHaveBeenCalled(); + }); + + it('reports no-socket when there is no client at all', async () => { + sdkMock.setClient(null); + await expect(recoverSocket()).resolves.toBe('no-socket'); + expect(probe).not.toHaveBeenCalled(); + expect(reopenNow).not.toHaveBeenCalled(); + }); + + it('rejects when the round trip throws', async () => { + probe.mockRejectedValue(new Error('round trip failed')); + await expect(recoverSocket()).rejects.toThrow('round trip failed'); + }); + + it('rejects when reopening throws', async () => { + mockConnections[0].readyState = CLOSED; + reopenNow.mockRejectedValue(new Error('reopen failed')); + await expect(recoverSocket()).rejects.toThrow('reopen failed'); + }); + + it('shares one in-flight recovery between overlapping callers', async () => { + const outcomes = await Promise.all([recoverSocket(), recoverSocket()]); + expect(outcomes).toEqual(['confirmed-alive', 'confirmed-alive']); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('starts a fresh recovery after the shared one settles', async () => { + await recoverSocket(); + await recoverSocket(); + expect(probe).toHaveBeenCalledTimes(2); + }); + + it('abandons the aborted caller while the shared recovery runs on', async () => { + let answerRoundTrip: (alive: boolean) => void = () => {}; + probe.mockImplementation(() => new Promise(resolve => (answerRoundTrip = resolve))); + + const controller = new AbortController(); + const aborted = recoverSocket({ abortSignal: controller.signal }); + const other = recoverSocket(); + + controller.abort(); + await expect(aborted).resolves.toBe('abandoned'); + + answerRoundTrip(true); + await expect(other).resolves.toBe('confirmed-alive'); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('abandons a pre-aborted caller without touching the socket', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(recoverSocket({ abortSignal: controller.signal })).resolves.toBe('abandoned'); + expect(probe).not.toHaveBeenCalled(); + expect(reopenNow).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/lib/services/connect.test.ts b/app/lib/services/connect.test.ts index 7f1ad5edd3b..d8abdc8d767 100644 --- a/app/lib/services/connect.test.ts +++ b/app/lib/services/connect.test.ts @@ -1,4 +1,4 @@ -import { connect, determineAuthType, disconnect } from './connect'; +import { connect, determineAuthType, disconnect, login, loginTOTP } from './connect'; import { mediaSessionInstance } from './voip/MediaSessionInstance'; import { pendingHangups } from './voip/pendingHangups'; import { setUser } from '../../actions/login'; @@ -23,19 +23,26 @@ const mockOnStreamData = jest.fn, [string, (...args const mockSdkConnect = jest.fn, []>(() => Promise.resolve()); const mockSdkAbort = jest.fn(); const mockSdkDisconnect = jest.fn(); -const mockSdkInitialize = jest.fn(); -const mockSdkCurrent = { - onStreamData: (event: string, cb: (...args: any[]) => void) => mockOnStreamData(event, cb), - connect: () => mockSdkConnect(), - abort: () => mockSdkAbort() +const mockSdkLogin = jest.fn, [unknown]>(() => Promise.resolve()); +const mockSdkCurrent: Record = { + currentLogin: undefined }; +const mockSdkInitialize = jest.fn(); jest.mock('./sdk', () => ({ __esModule: true, default: { initialize: (server: string) => mockSdkInitialize(server), + connect: () => mockSdkConnect(), disconnect: () => mockSdkDisconnect(), - get current() { - return mockSdkCurrent; + onStreamData: (event: string, cb: (...args: any[]) => void) => mockOnStreamData(event, cb), + isInitialized: true, + login: async (credentials: unknown) => { + await mockSdkLogin(credentials); + return mockSdkCurrent.currentLogin ?? null; + }, + abort: () => mockSdkAbort(), + get currentLogin() { + return mockSdkCurrent.currentLogin; } } })); @@ -44,11 +51,13 @@ type MockStoreState = { meteor: { connected: boolean }; login: { user: unknown; isAuthenticated: boolean }; settings: Record; + server?: { version: string }; }; const mockStoreGetState = jest.fn(() => ({ meteor: { connected: false }, login: { user: null, isAuthenticated: false }, - settings: {} + settings: {}, + server: { version: '6.0.0' } })); const mockStoreDispatch = jest.fn(); const noopUnsubscribe = () => () => {}; @@ -629,3 +638,51 @@ describe('connect — stream-notify-logged updateAvatar', () => { }); // Note: Apple authentication when isIOS is true is tested in connect.ios.test.ts + +describe('login', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSdkCurrent.currentLogin = undefined; + mockStoreGetState.mockReturnValue({ + meteor: { connected: true }, + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + }); + + it('rejects when the SDK resolves login without a login result', async () => { + await expect(login({ user: 'user', password: 'password' })).rejects.toThrow('Login failed: missing login result'); + }, 2000); + + it('returns the logged user when the SDK provides a login result', async () => { + mockSdkCurrent.currentLogin = { + result: { + userId: 'userId', + authToken: 'authToken', + me: { username: 'username', name: 'name' } + } + }; + + await expect(login({ user: 'user', password: 'password' })).resolves.toEqual( + expect.objectContaining({ id: 'userId', token: 'authToken', username: 'username' }) + ); + }, 2000); +}); + +describe('loginTOTP', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSdkCurrent.currentLogin = undefined; + mockStoreGetState.mockReturnValue({ + meteor: { connected: true }, + login: { user: null, isAuthenticated: false }, + settings: {}, + server: { version: '6.0.0' } + }); + }); + + it('rejects instead of hanging when the SDK resolves login without a login result', async () => { + await expect(loginTOTP({ user: 'user', password: 'password' })).rejects.toThrow('Login failed: missing login result'); + }, 2000); +}); diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts index 1f9043efd1f..c56edc7a6e6 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -11,11 +11,17 @@ import { twoFactor } from './twoFactor'; import { store } from '../store/auxStore'; import { loginRequest, logout, setLoginServices, setUser } from '../../actions/login'; import { waitForLoginReady } from './waitForLoginReady'; -import sdk from './sdk'; +import sdk, { type IStreamDataListener } from './sdk'; import { mediaSessionInstance } from './voip/MediaSessionInstance'; import { pendingHangups } from './voip/pendingHangups'; import I18n from '../../i18n'; -import { type ICredentials, type ILoggedUser, STATUSES } from '../../definitions'; +import { + type ILoginCredentials, + type ICredentialsPasswordAPI, + type ILoggedUser, + STATUSES, + type TUserStatus +} from '../../definitions'; import { connectRequest, connectSuccess, disconnect as disconnectAction } from '../../actions/connect'; import { updatePermission } from '../../actions/permissions'; import EventEmitter from '../methods/helpers/events'; @@ -48,6 +54,7 @@ let pendingHangupsConnectedListener: any; let usersListener: any; let notifyAllListener: any; let rolesListener: any; +let userPresenceListener: Promise | undefined; let notifyLoggedListener: any; let logoutListener: any; @@ -61,50 +68,27 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr store.dispatch(connectRequest()); - if (connectingListener) { - connectingListener.then(stopListener); - } - - if (connectedListener) { - connectedListener.then(stopListener); - } - - if (closeListener) { - closeListener.then(stopListener); - } - - if (pendingHangupsConnectedListener) { - pendingHangupsConnectedListener.then(stopListener); - } - - if (usersListener) { - usersListener.then(stopListener); - } - - if (notifyAllListener) { - notifyAllListener.then(stopListener); - } - - if (rolesListener) { - rolesListener.then(stopListener); - } - - if (notifyLoggedListener) { - notifyLoggedListener.then(stopListener); - } - - if (logoutListener) { - logoutListener.then(stopListener); - } + [ + connectingListener, + connectedListener, + closeListener, + pendingHangupsConnectedListener, + usersListener, + notifyAllListener, + rolesListener, + userPresenceListener, + notifyLoggedListener, + logoutListener + ].forEach(listener => listener?.then(stopListener)); unsubscribeRooms(); EventEmitter.emit('INQUIRY_UNSUBSCRIBE'); sdk.initialize(server); - getSettings(); + getSettings(server); - sdk.current + sdk .connect() .then(() => { console.log('connected'); @@ -113,11 +97,11 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr console.log('connect error', err); }); - connectingListener = sdk.current.onStreamData('connecting', () => { + connectingListener = sdk.onStreamData('connecting', () => { store.dispatch(connectRequest()); }); - connectedListener = sdk.current.onStreamData('connected', () => { + connectedListener = sdk.onStreamData('connected', () => { const { connected } = store.getState().meteor; if (connected) { return; @@ -133,12 +117,12 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr // the WebSocket was unhealthy. Local to the closure so it resets per `connect()` call. let pendingHangupsDrainArmed = false; - closeListener = sdk.current.onStreamData('close', () => { + closeListener = sdk.onStreamData('close', () => { pendingHangupsDrainArmed = true; store.dispatch(disconnectAction()); }); - pendingHangupsConnectedListener = sdk.current.onStreamData('connected', async () => { + pendingHangupsConnectedListener = sdk.onStreamData('connected', async () => { if (!pendingHangupsDrainArmed) return; pendingHangupsDrainArmed = false; if (pendingHangups.size === 0) return; @@ -150,12 +134,12 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr } }); - usersListener = sdk.current.onStreamData( + usersListener = sdk.onStreamData( 'users', protectedFunction((ddpMessage: any) => _setUser(ddpMessage)) ); - notifyAllListener = sdk.current.onStreamData( + notifyAllListener = sdk.onStreamData( 'stream-notify-all', protectedFunction(async (ddpMessage: { fields: { args?: any; eventName: string } }) => { const { eventName } = ddpMessage.fields; @@ -193,13 +177,13 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr }) ); - rolesListener = sdk.current.onStreamData( + rolesListener = sdk.onStreamData( 'stream-roles', protectedFunction((ddpMessage: any) => onRolesChanged(ddpMessage)) ); // RC 4.1 - sdk.current.onStreamData('stream-user-presence', (ddpMessage: { fields: { args?: any; uid?: any } }) => { + userPresenceListener = sdk.onStreamData('stream-user-presence', (ddpMessage: { fields: { args?: any; uid?: any } }) => { const userStatus = ddpMessage.fields.args[0]; const { uid } = ddpMessage.fields; const [, status, statusText, statusSource, statusExpiresAtRaw] = userStatus; @@ -215,7 +199,7 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr } }); - notifyLoggedListener = sdk.current.onStreamData( + notifyLoggedListener = sdk.onStreamData( 'stream-notify-logged', protectedFunction(async (ddpMessage: { fields: { args?: any; eventName?: any } }) => { const { eventName } = ddpMessage.fields; @@ -306,21 +290,27 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr }) ); - logoutListener = sdk.current.onStreamData('stream-force_logout', () => store.dispatch(logout(true))); + logoutListener = sdk.onStreamData('stream-force_logout', () => store.dispatch(logout(true))); resolve(); }); } -function stopListener(listener: any): boolean { - return listener && listener.stop(); +function stopListener(listener: any): void { + listener?.stop(); } -async function login(credentials: ICredentials): Promise { +async function login(credentials: ILoginCredentials): Promise { + if (!sdk.isInitialized) { + throw new Error('Cannot login before a server is selected'); + } // RC 0.64.0 - await sdk.current.login(credentials); + const currentLogin = await sdk.login(credentials); const serverVersion = store.getState().server.version; - const result = sdk.current.currentLogin?.result; + const result = currentLogin?.result; + if (!result) { + throw new Error('Login failed: missing login result'); + } let enableMessageParserEarlyAdoption = true; let showMessageInMainThread = false; @@ -329,85 +319,73 @@ async function login(credentials: ICredentials): Promise { - return new Promise(async (resolve, reject) => { - try { - const result = await login(params); - if (result) { - return resolve(result); - } - } catch (e: any) { - if (e.data?.error && (e.data.error === 'totp-required' || e.data.error === 'totp-invalid')) { - const { details, error } = e.data; - try { - const code = await twoFactor({ - params, - method: details?.method || 'totp', - invalid: (details.error || error) === 'totp-invalid' - }); - - if (loginEmailPassword) { - store.dispatch(setUser({ username: params.user || params.username })); - - // Force normalized params for 2FA starting RC 3.9.0. - const serverVersion = store.getState().server.version; - if (compareServerVersion(serverVersion as string, 'greaterThanOrEqualTo', '3.9.0')) { - const user = params.user ?? params.username; - const password = params.password ?? params.ldapPass ?? params.crowdPassword; - params = { user, password }; - } +async function loginTOTP(params: ILoginCredentials, loginEmailPassword?: boolean): Promise { + try { + return await login(params); + } catch (e: any) { + if (e.data?.error && (e.data.error === 'totp-required' || e.data.error === 'totp-invalid')) { + const { details, error } = e.data; + const code = await twoFactor({ + params, + method: details?.method || 'totp', + invalid: (details.error || error) === 'totp-invalid' + }); - return resolve(loginTOTP({ ...params, code: code?.twoFactorCode }, loginEmailPassword)); - } + const passwordParams = loginEmailPassword ? toPasswordLogin(params) : undefined; + if (passwordParams) { + store.dispatch(setUser({ username: passwordParams.user || passwordParams.username })); - return resolve( - loginTOTP({ - totp: { - login: { - ...params - }, - code: code?.twoFactorCode - } - }) - ); - } catch { - // twoFactor was canceled - return reject(); - } - } else { - reject(e); + return loginTOTP({ ...passwordParams, code: code?.twoFactorCode }, loginEmailPassword); } + + return loginTOTP({ + totp: { + login: params, + code: code?.twoFactorCode + } + }); } - }); + throw e; + } } function loginWithPassword({ user, password }: { user: string; password: string }): Promise { - let params: ICredentials = { user, password }; + let params: ILoginCredentials = { user, password }; const state = store.getState(); if (state.settings.LDAP_Enable) { @@ -428,21 +406,20 @@ function loginWithPassword({ user, password }: { user: string; password: string return loginTOTP(params, true); } -async function loginOAuthOrSso(params: ICredentials) { +async function loginOAuthOrSso(params: ILoginCredentials) { const result = await loginTOTP(params, false); store.dispatch(loginRequest({ resume: result.token }, false)); } -function abort() { - if (sdk.current) { - return sdk.current.abort(); +function abort(): void { + if (sdk.isInitialized) { + sdk.abort(); } } -function disconnect() { - const result = sdk.disconnect(); +function disconnect(): void { + sdk.disconnect(); mediaSessionInstance.reset(); - return result; } async function getWebsocketInfo({ diff --git a/app/lib/services/ddpSocket.test.ts b/app/lib/services/ddpSocket.test.ts deleted file mode 100644 index 44acd6e7320..00000000000 --- a/app/lib/services/ddpSocket.test.ts +++ /dev/null @@ -1,365 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { Socket, DDPDriver } = require('@rocket.chat/sdk/lib/drivers/ddp'); - -const mockConnections: any[] = []; -const trackedSockets: any[] = []; - -jest.mock('universal-websocket-client', () => { - return jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data); - if (message.msg === 'connect') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); - } else if (message.msg === 'ping') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); - } - }), - close: jest.fn(), - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - mockConnections.push(connection); - return connection; - }); -}); - -const buildSocket = () => { - const socket = new Socket({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - timeout: 10000 - }); - trackedSockets.push(socket); - const send = jest.fn(); - const close = jest.fn(); - socket.connection = { - send, - close, - readyState: 1, - onopen: jest.fn(), - onmessage: jest.fn(), - onerror: jest.fn(), - onclose: jest.fn() - }; - return { socket, send, close }; -}; - -const trackSocket = (socket: any) => { - trackedSockets.push(socket); - return socket; -}; - -beforeEach(() => { - mockConnections.length = 0; - trackedSockets.length = 0; -}); - -afterEach(() => { - trackedSockets.forEach(socket => { - if (socket.openTimeout) clearTimeout(socket.openTimeout as any); - if (socket.pingTimeout) clearTimeout(socket.pingTimeout as any); - }); -}); - -describe('Socket.probe', () => { - afterEach(() => { - jest.useRealTimers(); - }); - - it('resolves true when pong arrives within deadline', async () => { - const { socket } = buildSocket(); - const probePromise = socket.probe(); - socket.lastPing += 1; - socket.emit('pong'); - await expect(probePromise).resolves.toBe(true); - }); - - it('resolves false when no pong arrives within 2s deadline', async () => { - jest.useFakeTimers(); - const { socket } = buildSocket(); - const probePromise = socket.probe(); - await jest.advanceTimersByTimeAsync(2000); - await expect(probePromise).resolves.toBe(false); - }); - - it('resolves false when raw connection.send throws', async () => { - const { socket, send } = buildSocket(); - send.mockImplementation(() => { - throw new Error('boom'); - }); - await expect(socket.probe()).resolves.toBe(false); - }); - - it('resolves false when readyState is not open', async () => { - const { socket } = buildSocket(); - socket.connection.readyState = 2; - await expect(socket.probe()).resolves.toBe(false); - }); - - it('ignores a stale pong that does not advance lastPing', async () => { - jest.useFakeTimers(); - const { socket } = buildSocket(); - const initialLastPing = Date.now() - 1000; - socket.lastPing = initialLastPing; - - const probePromise = socket.probe(); - socket.emit('pong'); - - await jest.advanceTimersByTimeAsync(2000); - await expect(probePromise).resolves.toBe(false); - }); -}); - -describe('Socket.reopenNow', () => { - afterEach(() => { - jest.useRealTimers(); - }); - - it('preserves subscriptions and subscribeAll re-sends them', async () => { - const { socket } = buildSocket(); - const subscription = { - id: 'sub-1', - name: 'stream-room-messages', - params: ['rid'], - unsubscribe: jest.fn() - }; - socket.subscriptions['sub-1'] = subscription; - - const sendSpy = jest.spyOn(socket, 'send').mockResolvedValue({ subs: ['sub-1'] }); - - const reopenPromise = socket.reopenNow(); - mockConnections[0].onopen(); - await reopenPromise; - - expect(socket.subscriptions['sub-1']).toBe(subscription); - - await socket.subscribeAll(); - - expect(sendSpy).toHaveBeenCalledWith( - expect.objectContaining({ - msg: 'sub', - id: 'sub-1', - name: 'stream-room-messages', - params: ['rid'] - }) - ); - }); - - it("emits 'disconnected' and rejects in-flight send()", async () => { - const { socket } = buildSocket(); - const disconnectedListener = jest.fn(); - socket.on('disconnected', disconnectedListener); - const sendPromise = socket.send({ msg: 'ping' }); - - const reopenPromise = socket.reopenNow(); - - expect(disconnectedListener).toHaveBeenCalledTimes(1); - await expect(sendPromise).rejects.toBeUndefined(); - - mockConnections[0].onopen(); - await reopenPromise; - }); - - it('concurrent calls create exactly one new WebSocket', async () => { - const socket = trackSocket( - new Socket({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - timeout: 10000 - }) - ); - - const a = socket.reopenNow(); - const b = socket.reopenNow(); - - expect(mockConnections).toHaveLength(1); - - mockConnections[0].onopen(); - - await Promise.all([a, b]); - }); - - it('times out and clears in-flight state so a later reopenNow retries', async () => { - jest.useFakeTimers(); - const socket = trackSocket( - new Socket({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - timeout: 10000 - }) - ); - - const promise = socket.reopenNow(); - expect(socket.reopenPromise).toBeTruthy(); - - await jest.advanceTimersByTimeAsync(10000); - await promise; - - expect(socket.reopenPromise).toBeUndefined(); - - const secondPromise = socket.reopenNow(); - expect(mockConnections).toHaveLength(2); - - mockConnections[1].onopen(); - await jest.runOnlyPendingTimersAsync(); - await secondPromise; - }); - - it('forces a reconnect on an already healthy socket', async () => { - const { socket } = buildSocket(); - const initialConnection = socket.connection; - - const promise = socket.reopenNow(); - - expect(mockConnections).toHaveLength(1); - expect(initialConnection.close).toHaveBeenCalled(); - - mockConnections[0].onopen(); - await promise; - - expect(socket.connection).toBe(mockConnections[0]); - }); - - it('serializes against concurrent open(): no second socket, no closing in-flight one', async () => { - const socket = trackSocket( - new Socket({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }, - timeout: 10000 - }) - ); - - const reopenPromise = socket.reopenNow(); - const inFlightConnection = mockConnections[0]; - - const openPromise = socket.open(); - expect(mockConnections).toHaveLength(1); - expect(inFlightConnection.close).not.toHaveBeenCalled(); - - mockConnections[0].onopen(); - await reopenPromise; - await openPromise; - }); -}); - -describe('Socket.send disconnected listener', () => { - it('cleans up the disconnected listener after send resolves', async () => { - const { socket, send } = buildSocket(); - const baseline = socket._listeners.disconnected?.length || 0; - send.mockImplementation(() => { - setImmediate(() => socket.emit('pong', { msg: 'pong' })); - }); - - await socket.send({ msg: 'ping' }); - - expect(socket._listeners.disconnected?.length || 0).toBe(baseline); - }); -}); - -describe('DDPDriver.waitForNotifyUserMediaSubs', () => { - afterEach(() => { - jest.useRealTimers(); - }); - - const makeDriver = () => - new DDPDriver({ - logger: { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() } - }); - - it('resolves true when media subs are present and server acks', async () => { - const driver = makeDriver(); - driver.userId = 'uid'; - driver.ddp.subscriptions['sub-ms'] = { - id: 'sub-ms', - name: 'stream-notify-user', - params: ['uid/media-signal'], - unsubscribe: jest.fn() - }; - driver.ddp.subscriptions['sub-mc'] = { - id: 'sub-mc', - name: 'stream-notify-user', - params: ['uid/media-calls'], - unsubscribe: jest.fn() - }; - jest.spyOn(driver.ddp, 'subscribe').mockResolvedValue({}); - - await expect(driver.waitForNotifyUserMediaSubs(1000)).resolves.toBe(true); - expect(driver.ddp.subscribe).toHaveBeenCalledWith('stream-notify-user', ['uid/media-signal'], undefined, 'sub-ms'); - expect(driver.ddp.subscribe).toHaveBeenCalledWith('stream-notify-user', ['uid/media-calls'], undefined, 'sub-mc'); - }); - - it('waits for media subs to appear before re-subscribing', async () => { - jest.useFakeTimers(); - const driver = makeDriver(); - driver.userId = 'uid'; - jest.spyOn(driver.ddp, 'subscribe').mockResolvedValue({}); - - const promise = driver.waitForNotifyUserMediaSubs(1000); - driver.ddp.subscriptions['sub-ms'] = { - id: 'sub-ms', - name: 'stream-notify-user', - params: ['uid/media-signal'], - unsubscribe: jest.fn() - }; - driver.ddp.subscriptions['sub-mc'] = { - id: 'sub-mc', - name: 'stream-notify-user', - params: ['uid/media-calls'], - unsubscribe: jest.fn() - }; - - await jest.advanceTimersByTimeAsync(100); - await expect(promise).resolves.toBe(true); - expect(driver.ddp.subscribe).toHaveBeenCalledTimes(2); - }); - - it('stays pending while only one of the media subs is present', async () => { - jest.useFakeTimers(); - const driver = makeDriver(); - driver.userId = 'uid'; - jest.spyOn(driver.ddp, 'subscribe').mockResolvedValue({}); - - let resolved: boolean | undefined; - const promise = driver.waitForNotifyUserMediaSubs(1000).then((value: boolean) => { - resolved = value; - return value; - }); - - driver.ddp.subscriptions['sub-ms'] = { - id: 'sub-ms', - name: 'stream-notify-user', - params: ['uid/media-signal'], - unsubscribe: jest.fn() - }; - - await jest.advanceTimersByTimeAsync(100); - expect(resolved).toBeUndefined(); - expect(driver.ddp.subscribe).not.toHaveBeenCalled(); - - driver.ddp.subscriptions['sub-mc'] = { - id: 'sub-mc', - name: 'stream-notify-user', - params: ['uid/media-calls'], - unsubscribe: jest.fn() - }; - - await jest.advanceTimersByTimeAsync(100); - await expect(promise).resolves.toBe(true); - }); - - it('resolves false if media subs never appear before the timeout', async () => { - jest.useFakeTimers(); - const driver = makeDriver(); - driver.userId = 'uid'; - - const promise = driver.waitForNotifyUserMediaSubs(500); - await jest.advanceTimersByTimeAsync(500); - - await expect(promise).resolves.toBe(false); - }); - - it('resolves false when userId is missing', async () => { - const driver = makeDriver(); - await expect(driver.waitForNotifyUserMediaSubs(1000)).resolves.toBe(false); - }); -}); diff --git a/app/lib/services/restApi.test.ts b/app/lib/services/restApi.test.ts index 8dc0dcf8fba..7405c6d6330 100644 --- a/app/lib/services/restApi.test.ts +++ b/app/lib/services/restApi.test.ts @@ -1,22 +1,27 @@ import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; import { Platform } from 'react-native'; +import type * as SdkIntegration from '../testUtils/sdkIntegration'; import { mediaCallsStateSignals } from './restApi'; const mockSdkGet = jest.fn(); const mockSdkPost = jest.fn(); -let mockSdkCurrent: unknown = {}; +const mockSdkDel = jest.fn(); +let mockSdk!: SdkIntegration.IMockSdk; + +jest.mock('./sdk', () => { + const { makeSdkMock } = jest.requireActual('../testUtils/sdkIntegration'); + mockSdk = + mockSdk ?? + makeSdkMock({ + get: (...args: unknown[]) => mockSdkGet(...args), + post: (...args: unknown[]) => mockSdkPost(...args), + del: (...args: unknown[]) => mockSdkDel(...args) + }); + return { __esModule: true, default: mockSdk }; +}); -jest.mock('./sdk', () => ({ - __esModule: true, - default: { - get: (...args: unknown[]) => mockSdkGet(...args), - post: (...args: unknown[]) => mockSdkPost(...args), - get current() { - return mockSdkCurrent; - } - } -})); +const SDK_HOST = 'https://open.rocket.chat'; jest.mock('../notifications', () => ({ getDeviceToken: jest.fn() @@ -47,7 +52,7 @@ jest.mock('react-native-device-info', () => { }; }); -function loadRegisterPushToken(platform: 'ios' | 'android' = 'android', mockServerVersion = '8.0.0') { +function loadPushTokenApi(platform: 'ios' | 'android' = 'android', mockServerVersion = '8.0.0') { jest.resetModules(); Object.defineProperty(Platform, 'OS', { configurable: true, writable: true, value: platform }); @@ -64,10 +69,12 @@ function loadRegisterPushToken(platform: 'ios' | 'android' = 'android', mockServ // eslint-disable-next-line @typescript-eslint/no-require-imports const voipNative = require('../native/NativeVoip').default; // eslint-disable-next-line @typescript-eslint/no-require-imports - const { registerPushToken } = require('./restApi'); + const { registerPushToken, removePushToken } = require('./restApi'); return { // eslint-disable-next-line @typescript-eslint/consistent-type-imports registerPushToken: registerPushToken as typeof import('./restApi').registerPushToken, + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + removePushToken: removePushToken as typeof import('./restApi').removePushToken, getDeviceToken: jest.mocked(notifications.getDeviceToken), getLastVoipToken: jest.mocked(voipNative.getLastVoipToken) }; @@ -129,25 +136,25 @@ describe('registerPushToken', () => { beforeEach(() => { jest.clearAllMocks(); mockSdkPost.mockResolvedValue(undefined); - mockSdkCurrent = {}; + mockSdk.setClient({ host: SDK_HOST }); }); it('does not post when SDK is not initialized, and a later call after init posts', async () => { - const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios'); + const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('ios'); getToken.mockReturnValue('apns-token'); getVoip.mockReturnValue('voip-token'); - mockSdkCurrent = undefined; + mockSdk.setClient(null); await registerPushToken(); expect(mockSdkPost).not.toHaveBeenCalled(); - mockSdkCurrent = {}; + mockSdk.setClient({ host: SDK_HOST }); await registerPushToken(); expect(mockSdkPost).toHaveBeenCalledTimes(1); }); it('returns early when there is no device push token', async () => { - const { registerPushToken, getDeviceToken: getToken } = loadRegisterPushToken(); + const { registerPushToken, getDeviceToken: getToken } = loadPushTokenApi(); getToken.mockReturnValue(''); await registerPushToken(); @@ -156,7 +163,7 @@ describe('registerPushToken', () => { }); it('on iOS registers apn payload without voipToken when VoIP token is missing', async () => { - const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios'); + const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('ios'); getToken.mockReturnValue('apns-token'); getVoip.mockReturnValue(''); @@ -177,7 +184,7 @@ describe('registerPushToken', () => { }); it('on Android still registers when VoIP token is missing', async () => { - const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('android'); + const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('android'); getToken.mockReturnValue('fcm-token'); getVoip.mockReturnValue(''); @@ -198,7 +205,7 @@ describe('registerPushToken', () => { }); it('dedupes when the same push and VoIP tokens are registered again', async () => { - const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios'); + const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('ios'); getToken.mockReturnValue('apns-token'); getVoip.mockReturnValue('voip-token'); @@ -209,7 +216,7 @@ describe('registerPushToken', () => { }); it('on iOS posts apn payload with voipToken when both tokens are present', async () => { - const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios', '8.4.0'); + const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('ios', '8.4.0'); getToken.mockReturnValue('apns-token'); getVoip.mockReturnValue('voip-token'); @@ -228,7 +235,7 @@ describe('registerPushToken', () => { }); it('on RC < 8.0 does not send id field', async () => { - const { registerPushToken, getDeviceToken: getToken } = loadRegisterPushToken('ios', '7.5.0'); + const { registerPushToken, getDeviceToken: getToken } = loadPushTokenApi('ios', '7.5.0'); getToken.mockReturnValue('apns-token'); await registerPushToken(); @@ -238,7 +245,7 @@ describe('registerPushToken', () => { }); it('on RC < 8.0 does not send voipToken field even when present', async () => { - const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios', '7.5.0'); + const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('ios', '7.5.0'); getToken.mockReturnValue('apns-token'); getVoip.mockReturnValue('voip-token'); @@ -249,7 +256,7 @@ describe('registerPushToken', () => { }); it('on RC 8.0-8.3 sends id but not voipToken', async () => { - const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios', '8.2.0'); + const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('ios', '8.2.0'); getToken.mockReturnValue('apns-token'); getVoip.mockReturnValue('voip-token'); @@ -265,3 +272,61 @@ describe('registerPushToken', () => { expect(Object.prototype.hasOwnProperty.call(payload, 'voipToken')).toBe(false); }); }); + +describe('removePushToken', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSdkPost.mockResolvedValue(undefined); + mockSdkDel.mockResolvedValue({ success: true }); + mockSdk.setClient({ host: SDK_HOST }); + }); + + it('deletes the token on the server and forgets the registered tokens', async () => { + const { registerPushToken, removePushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('ios'); + getToken.mockReturnValue('apns-token'); + getVoip.mockReturnValue('voip-token'); + await registerPushToken(); + expect(mockSdkPost).toHaveBeenCalledTimes(1); + + await removePushToken(); + expect(mockSdkDel).toHaveBeenCalledWith('push.token', { token: 'apns-token' }); + + await registerPushToken(); + + expect(mockSdkPost).toHaveBeenCalledTimes(2); + }); + + it('keeps the registered tokens when the device token is already gone', async () => { + const { registerPushToken, removePushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('ios'); + getToken.mockReturnValue('apns-token'); + getVoip.mockReturnValue('voip-token'); + await registerPushToken(); + expect(mockSdkPost).toHaveBeenCalledTimes(1); + + getToken.mockReturnValue(''); + await removePushToken(); + expect(mockSdkDel).not.toHaveBeenCalled(); + + getToken.mockReturnValue('apns-token'); + await registerPushToken(); + + expect(mockSdkPost).toHaveBeenCalledTimes(1); + }); + + it('forgets the registered tokens even when there is no client to delete them from', async () => { + const { registerPushToken, removePushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadPushTokenApi('ios'); + getToken.mockReturnValue('apns-token'); + getVoip.mockReturnValue('voip-token'); + await registerPushToken(); + expect(mockSdkPost).toHaveBeenCalledTimes(1); + + mockSdk.setClient(null); + await removePushToken(); + expect(mockSdkDel).not.toHaveBeenCalled(); + + mockSdk.setClient({ host: SDK_HOST }); + await registerPushToken(); + + expect(mockSdkPost).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index c6f087b8209..e44f27f0ff2 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -562,11 +562,14 @@ export const deleteRoom = (roomId: string, t: RoomTypes) => // RC 0.49.0 sdk.post(`${roomTypeToApiType(t)}.delete`, { roomId }); -export const toggleMuteUserInRoom = (rid: string, username: string, userId: string, mute: boolean) => { +export const toggleMuteUserInRoom = (rid: string, username: string | undefined, userId: string, mute: boolean) => { const serverVersion = reduxStore.getState().server.version; if (compareServerVersion(serverVersion, 'greaterThanOrEqualTo', '6.8.0')) { return sdk.post(mute ? 'rooms.muteUser' : 'rooms.unmuteUser', { roomId: rid, userId }); } + if (!username) { + throw new Error('muteUserInRoom requires a username on servers older than 6.8.0'); + } // RC 0.51.0 return sdk.methodCallWrapper(mute ? 'muteUserInRoom' : 'unmuteUserInRoom', { rid, username }); }; @@ -1038,7 +1041,6 @@ export const emitTyping = (room: IRoom, typing = true, args: { tmid?: string } = }; export function e2eResetOwnKey(): Promise<{ success?: boolean }> { - // {} when TOTP is enabled unsubscribeRooms(); // RC 3.6.0 @@ -1137,7 +1139,7 @@ export const registerPushToken = async (): Promise => { // On a fresh-install cold-start, FCM/APNS and iOS PushKit can deliver tokens before that // happens; bail without recording lastToken/lastVoipToken so registerPushTokenFork retries // after login (and a later VoipPushTokenRegistered emission can still re-fire this path). - if (!sdk.current) { + if (!sdk.isInitialized) { return; } @@ -1173,15 +1175,18 @@ export const registerPushToken = async (): Promise => { }; // TODO: add voip token removal -export const removePushToken = (): Promise => { +export const removePushToken = async (): Promise => { const token = getDeviceToken(); - if (token) { - lastToken = ''; - lastVoipToken = ''; - // RC 0.60.0 - return sdk.current.del('push.token', { token }); + if (!token) { + return; } - return Promise.resolve(); + lastToken = ''; + lastVoipToken = ''; + if (!sdk.isInitialized) { + return; + } + // RC 0.60.0 + await sdk.del('push.token', { token }); }; // RC 6.6.0 diff --git a/app/lib/services/sdk.test.ts b/app/lib/services/sdk.test.ts index f82d0df02dc..aeba40547e9 100644 --- a/app/lib/services/sdk.test.ts +++ b/app/lib/services/sdk.test.ts @@ -1,16 +1,22 @@ import sdk from './sdk'; +import { TwoFactorCancelledError, isTwoFactorCancelled } from './twoFactor'; const mockInnerMethodCall = jest.fn(); +const mockInnerPost = jest.fn(); const mockTwoFactor = jest.fn(); jest.mock('@rocket.chat/sdk', () => ({ Rocketchat: jest.fn().mockImplementation(() => ({ - methodCall: (...args: unknown[]) => mockInnerMethodCall(...args) + methodCall: (...args: unknown[]) => mockInnerMethodCall(...args), + post: (...args: unknown[]) => mockInnerPost(...args) })), settings: { customHeaders: {} } })); +jest.mock('../../containers/TwoFactor', () => ({ TWO_FACTOR: 'TWO_FACTOR' })); + jest.mock('./twoFactor', () => ({ + ...jest.requireActual('./twoFactor'), twoFactor: (...args: unknown[]) => mockTwoFactor(...args) })); @@ -73,16 +79,53 @@ describe('sdk.methodCall', () => { expect(mockInnerMethodCall.mock.calls[2]).toHaveLength(2); }); - it('twoFactor canceled → resolves to {}', async () => { + it('twoFactor cancelled → rejects with TwoFactorCancelledError', async () => { mockInnerMethodCall.mockRejectedValue({ error: 'totp-required', details: { method: 'totp' } }); - mockTwoFactor.mockRejectedValue(new Error('Canceled')); + mockTwoFactor.mockRejectedValue(new TwoFactorCancelledError()); - const result = await sdk.methodCall('m'); + const error = await sdk.methodCall('m').catch(e => e); - expect(result).toEqual({}); + expect(isTwoFactorCancelled(error)).toBe(true); expect(mockInnerMethodCall).toHaveBeenCalledTimes(1); }); + + it('non-2FA error → rejects with the original error', async () => { + const error = { error: 'error-not-allowed' }; + mockInnerMethodCall.mockRejectedValue(error); + + await expect(sdk.methodCall('m')).rejects.toBe(error); + expect(mockTwoFactor).not.toHaveBeenCalled(); + }); +}); + +describe('sdk.post', () => { + it('twoFactor cancelled → rejects with TwoFactorCancelledError', async () => { + mockInnerPost.mockRejectedValue({ data: { errorType: 'totp-required', details: { method: 'totp' } } }); + mockTwoFactor.mockRejectedValue(new TwoFactorCancelledError()); + + const error = await sdk.post('chat.delete' as never, {} as never).catch(e => e); + + expect(isTwoFactorCancelled(error)).toBe(true); + expect(mockInnerPost).toHaveBeenCalledTimes(1); + }); + + it('twoFactor submitted → retries and resolves', async () => { + mockInnerPost.mockRejectedValueOnce({ data: { errorType: 'totp-required', details: { method: 'totp' } } }); + mockInnerPost.mockResolvedValueOnce({ success: true }); + mockTwoFactor.mockResolvedValue({ twoFactorCode: 'CODE', twoFactorMethod: 'totp' }); + + await expect(sdk.post('chat.delete' as never, {} as never)).resolves.toEqual({ success: true }); + expect(mockInnerPost).toHaveBeenCalledTimes(2); + }); + + it('non-2FA error → rejects with the original error', async () => { + const error = { data: { errorType: 'error-not-allowed' } }; + mockInnerPost.mockRejectedValue(error); + + await expect(sdk.post('chat.delete' as never, {} as never)).rejects.toBe(error); + expect(mockTwoFactor).not.toHaveBeenCalled(); + }); }); diff --git a/app/lib/services/sdk.ts b/app/lib/services/sdk.ts index d8be776f45a..3da561e9dcf 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -1,4 +1,5 @@ import { Rocketchat } from '@rocket.chat/sdk'; +import { type ICallback, type ICurrentLogin, type ILoginCredentials, type ISubscription } from '@rocket.chat/sdk/interfaces'; import EJSON from 'ejson'; import isEmpty from 'lodash/isEmpty'; @@ -14,36 +15,79 @@ import { } from '../../definitions/rest/helpers'; import { compareServerVersion, random } from '../methods/helpers'; +export interface ISocketDriver { + readonly connected: boolean; + reopenNow(): Promise; + probe(timeoutMs?: number): Promise; + waitForNotifyUserMediaSubs(timeoutMs?: number): Promise; +} + +export type TStreamDataCallback = (ddpMessage: any) => void; + +export interface IStreamDataListener { + stop: () => void; +} + class Sdk { - private sdk: typeof Rocketchat; + private sdk: Rocketchat | null = null; private code: any; - private initializeSdk(server: string): typeof Rocketchat { + private get activeSdk(): Rocketchat { + if (!this.sdk) { + throw new Error('Sdk is not initialized'); + } + return this.sdk; + } + + private initializeSdk(server: string): Rocketchat { // The app can't reconnect if reopen interval is 5s while in development return new Rocketchat({ host: server, protocol: 'ddp', useSsl: isSsl(server), reopen: __DEV__ ? 20000 : 5000 }); } - // TODO: We need to stop returning the SDK after all methods are dehydrated - initialize(server: string) { + initialize(server: string): void { this.code = null; this.sdk = this.initializeSdk(server); - return this.sdk; } - get current() { - return this.sdk; + connect(): Promise { + return this.activeSdk.connect(); + } + + get host(): string | null { + return this.sdk?.client.host ?? null; + } + + get currentLogin(): ICurrentLogin | null { + return this.sdk?.currentLogin ?? null; + } + + get driver(): ISocketDriver | null { + return this.sdk?.driver ?? null; } - /** - * TODO: evaluate the need for assigning "null" to this.sdk - * I'm returning "null" because we need to remove both instances of this.sdk here and on rocketchat.js - */ - disconnect() { + get isInitialized(): boolean { + return this.sdk !== null; + } + + async login(credentials: ILoginCredentials): Promise { + const client = this.activeSdk; + await client.login(credentials); + return client.currentLogin ?? null; + } + + abort(): void { + this.activeSdk.abort(); + } + + subscribeNotifyUser() { + return this.activeSdk.subscribeNotifyUser(); + } + + disconnect(): void { if (this.sdk) { this.sdk.disconnect(); this.sdk = null; } - return null; } get>( @@ -57,7 +101,7 @@ class Sdk { ? void : Serialized>> ): Promise>>> { - return this.current.get(endpoint, params); + return this.activeSdk.get(endpoint, params); } post>( @@ -74,7 +118,7 @@ class Sdk { return new Promise(async (resolve, reject) => { const isMethodCall = endpoint?.startsWith('method.call/'); try { - const result = await this.current.post(endpoint, params); + const result = await this.activeSdk.post(endpoint, params); /** * if API_Use_REST_For_DDP_Calls is enabled and it's a method call, @@ -97,9 +141,8 @@ class Sdk { try { await twoFactor({ method: details?.method, invalid: errorType === totpInvalid }); return resolve(this.post(endpoint, params)); - } catch { - // twoFactor was canceled - return resolve({} as any); + } catch (twoFactorError) { + return reject(twoFactorError); } } else { reject(e); @@ -108,23 +151,40 @@ class Sdk { }); } - methodCall(...args: any[]): Promise { + del>( + endpoint: TPath, + params: void extends OperationParams<'DELETE', MatchPathPattern> + ? void + : Serialized>> = undefined as void extends OperationParams< + 'DELETE', + MatchPathPattern + > + ? void + : Serialized>> + ): Promise>>> { + return this.activeSdk.del(endpoint, params); + } + + logout() { + return this.activeSdk.logout(); + } + + methodCall(method: string, ...args: any[]): Promise { return new Promise(async (resolve, reject) => { try { // Clear the 2FA code after use — a stale trailing arg breaks typed method signatures const { code } = this; this.code = null; - const result = await this.current.methodCall(...args, ...(code ? [code] : [])); + const result = await this.activeSdk.methodCall(method, ...args, ...(code ? [code] : [])); return resolve(result); } catch (e: any) { if (e.error && (e.error === 'totp-required' || e.error === 'totp-invalid')) { const { details } = e; try { this.code = await twoFactor({ method: details?.method, invalid: e.error === 'totp-invalid' }); - return resolve(this.methodCall(...args)); - } catch { - // twoFactor was canceled - return resolve({}); + return resolve(this.methodCall(method, ...args)); + } catch (twoFactorError) { + return reject(twoFactorError); } } else { reject(e); @@ -152,12 +212,12 @@ class Sdk { return this.methodCall(method, ...parsedParams); } - subscribe(...args: any[]) { - return this.current.subscribe(...args); + subscribe(topic: string, eventName?: string, ...args: any[]): Promise { + return this.activeSdk.subscribe(topic, eventName as string, ...args); } - subscribeRaw(...args: any[]) { - return this.current.subscribeRaw(...args); + subscribeRaw(name: string, params: any[]): Promise { + return this.activeSdk.subscribeRaw(name, params); } subscribeRoom(...args: any[]) { @@ -181,12 +241,12 @@ class Sdk { ]); } - unsubscribe(subscription: any[]) { - return this.current.unsubscribe(subscription); + unsubscribe(subscription: ISubscription) { + return this.activeSdk.unsubscribe(subscription); } - onStreamData(...args: any[]) { - return this.current.onStreamData(...args); + onStreamData(event: string, callback: TStreamDataCallback): Promise { + return this.activeSdk.onStreamData(event, callback as ICallback); } } diff --git a/app/lib/services/socketHealth.ts b/app/lib/services/socketHealth.ts index aa7d4ceb5f1..771fb1cb017 100644 --- a/app/lib/services/socketHealth.ts +++ b/app/lib/services/socketHealth.ts @@ -1,19 +1,5 @@ import { onAbort } from '../methods/helpers/onAbort'; -import sdk from './sdk'; - -/** - * The slice of the patched DDP driver this module reads. - * The only guard is `sdk.current?.ddp` being undefined — the patch is guaranteed - * at runtime, so there are no per-method typeof checks. - */ -interface SocketHealthDdp { - connected?: boolean; - lastPing: number; - pingInterval?: number; - config?: { ping?: number }; - reopenNow(): Promise; - probe(timeoutMs: number): Promise; -} +import sdk, { type ISocketDriver } from './sdk'; /** * The recovery plan — what classification decides. @@ -26,29 +12,14 @@ interface SocketHealthDdp { */ export type SocketRecoveryPlan = 'reopen' | 'round-trip-check'; -export function classifySocketHealth(ddp: SocketHealthDdp): SocketRecoveryPlan { - // Ping age can't vouch for a socket the OS already closed. - if (ddp.connected === false) { - return 'reopen'; - } - const pingInterval = (ddp.pingInterval ?? ddp.config?.ping) || 10000; - const age = Date.now() - ddp.lastPing; - if (age > pingInterval * 2) { +export function classifySocketHealth(driver: ISocketDriver): SocketRecoveryPlan { + if (!driver.connected) { return 'reopen'; } - // Anything younger is verified by a round trip, never trusted outright: onOpen - // refreshes lastPing before the handshake reply lands. return 'round-trip-check'; } /** - * What a recovery attempt reports. - * - `'confirmed-alive'` — round trip succeeded; nothing was done. - * - `'reopened'` — socket reopened (stale ping, or round trip failed). - * - `'no-socket'` — `sdk.current?.ddp` undefined; nothing to recover. - * - `'abandoned'` — caller's abort signal fired while waiting; the - * underlying recovery (shared — see below) runs on. - * * Errors from `reopenNow()`/`probe()` REJECT the promise rather than becoming * an outcome: both current callers already sit in catch paths (`state.js` * logs, accept gate fails the call), and a thrown error is not a decision the @@ -62,20 +33,20 @@ function shareRecovery(): Promise { if (inFlightRecovery) { return inFlightRecovery; } - const ddp = sdk.current?.ddp as SocketHealthDdp | undefined; - if (!ddp) { + const driver = sdk.driver; + if (!driver) { return Promise.resolve('no-socket'); } const recovery = (async (): Promise => { - if (classifySocketHealth(ddp) === 'reopen') { - await ddp.reopenNow(); + if (classifySocketHealth(driver) === 'reopen') { + await driver.reopenNow(); return 'reopened'; } - const alive = await ddp.probe(2000); + const alive = await driver.probe(2000); if (alive) { return 'confirmed-alive'; } - await ddp.reopenNow(); + await driver.reopenNow(); return 'reopened'; })(); inFlightRecovery = recovery; @@ -124,12 +95,3 @@ export function recoverSocket(options?: { abortSignal?: AbortSignal }): Promise< }); return Promise.race([recovery, abandoned]); } - -/** - * Vocabulary: - * - socket health — the classification concern (`classifySocketHealth`). - * - recovery plan — `SocketRecoveryPlan`, the decision. - * - round trip — the liveness check (`ddp.probe` stays as the SDK - * method name; our terms say round trip). - * - recovery outcome — `SocketRecoveryOutcome`, what callers see. - */ diff --git a/app/lib/services/twoFactor.ts b/app/lib/services/twoFactor.ts index f8b47609b46..4d2c91dd1dd 100644 --- a/app/lib/services/twoFactor.ts +++ b/app/lib/services/twoFactor.ts @@ -2,12 +2,15 @@ import { settings } from '@rocket.chat/sdk'; import { TWO_FACTOR } from '../../containers/TwoFactor'; import EventEmitter from '../methods/helpers/events'; -import { type ICredentials } from '../../definitions'; +import { type ILoginCredentials } from '../../definitions'; +import { TwoFactorCancelledError } from './twoFactorCancelled'; + +export { TwoFactorCancelledError, isTwoFactorCancelled } from './twoFactorCancelled'; interface ITwoFactor { method: string; invalid: boolean; - params?: ICredentials; + params?: ILoginCredentials; } export const twoFactor = ({ method, invalid, params }: ITwoFactor): Promise<{ twoFactorCode: string; twoFactorMethod: string }> => @@ -16,7 +19,7 @@ export const twoFactor = ({ method, invalid, params }: ITwoFactor): Promise<{ tw method, invalid, params, - cancel: () => reject(), + cancel: () => reject(new TwoFactorCancelledError()), submit: (code: string) => { settings.customHeaders = { ...settings.customHeaders, diff --git a/app/lib/services/twoFactorCancelled.ts b/app/lib/services/twoFactorCancelled.ts new file mode 100644 index 00000000000..c21244d4cd3 --- /dev/null +++ b/app/lib/services/twoFactorCancelled.ts @@ -0,0 +1,9 @@ +export class TwoFactorCancelledError extends Error { + constructor() { + super('Two-factor authentication was cancelled'); + this.name = 'TwoFactorCancelledError'; + } +} + +export const isTwoFactorCancelled = (e: unknown): e is TwoFactorCancelledError => + e instanceof TwoFactorCancelledError || (e instanceof Error && e.name === 'TwoFactorCancelledError'); diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 564bddb47d3..1a35d57edcb 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -3,6 +3,8 @@ import RNCallKeep from 'react-native-callkeep'; import { waitFor } from '@testing-library/react-native'; import type { IDDPMessage } from '../../../definitions/IDDPMessage'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; +import sdk from '../sdk'; import Navigation from '../../navigation/appNavigation'; import { getDMSubscriptionByUsername } from '../../database/services/Subscription'; import { getUidDirectMessage } from '../../methods/helpers/helpers'; @@ -56,31 +58,27 @@ jest.mock('./useCallStore', () => ({ } })); +const mockSdk = sdk as unknown as SdkIntegration.IMockSdk; +const SDK_HOST = 'https://open.rocket.chat'; + const mockOnStreamDataStop = jest.fn(); -const mockOnStreamData = jest.fn(() => ({ stop: mockOnStreamDataStop })); +const mockOnStreamData = jest.fn((_event: string, _callback: (message: IDDPMessage) => void) => + Promise.resolve({ stop: mockOnStreamDataStop }) +); const mockMethodCall = jest.fn(); - -jest.mock('../sdk', () => ({ - __esModule: true, - default: { - onStreamData: (...args: Parameters) => mockOnStreamData(...args), - methodCall: (...args: unknown[]) => { - mockMethodCall(...args); - return Promise.resolve(); - }, - get current() { - return { - ddp: { - reopenNow: jest.fn(() => Promise.resolve()), - probe: jest.fn(() => Promise.resolve(true)), - lastPing: Date.now(), - pingInterval: 10000, - waitForNotifyUserMediaSubs: jest.fn(() => Promise.resolve(true)) - } - }; - } - } -})); +jest.mock('../sdk', () => { + const { makeSdkMock } = jest.requireActual('../../testUtils/sdkIntegration'); + return { + __esModule: true, + default: makeSdkMock({ + onStreamData: (...args: Parameters) => mockOnStreamData(...args), + methodCall: (...args: unknown[]) => { + mockMethodCall(...args); + return Promise.resolve(); + } + }) + }; +}); const mockMediaCallsStateSignals = jest.fn().mockResolvedValue({ signals: [], success: true }); @@ -262,6 +260,7 @@ describe('MediaSessionInstance', () => { beforeEach(() => { jest.clearAllMocks(); + mockSdk.setClient({ host: SDK_HOST }); mockStartVoipCallService.mockResolvedValue(undefined); mockMediaCallsStateSignals.mockResolvedValue({ signals: [], success: true }); mockRequestVoipCallPermissions.mockResolvedValue(true); @@ -321,6 +320,22 @@ describe('MediaSessionInstance', () => { ); spy.mockRestore(); }); + + it('should drop sendSignal after the client is gone', async () => { + const spy = jest.spyOn(mediaSessionStore, 'setSendSignalFn'); + await mediaSessionInstance.init('user-xyz'); + const sendFn = spy.mock.calls[spy.mock.calls.length - 1][0] as (signal: { type: string }) => void; + mockSdk.setClient(null); + mockMethodCall.mockClear(); + mockLog.mockClear(); + + sendFn({ type: 'register' }); + await Promise.resolve(); + + expect(mockMethodCall).not.toHaveBeenCalled(); + expect(mockLog).not.toHaveBeenCalled(); + spy.mockRestore(); + }); }); describe('teardown and user switch', () => { diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 4c7df62da10..cefa71ba93f 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -22,7 +22,7 @@ import { useCallStore } from './useCallStore'; import { MediaCallLogger } from './MediaCallLogger'; import { isSelfUserId } from './isSelfUserId'; import { store } from '../../store/auxStore'; -import sdk from '../sdk'; +import sdk, { type IStreamDataListener } from '../sdk'; import { mediaCallsStateSignals } from '../restApi'; import Navigation, { waitForNavigationReady } from '../../navigation/appNavigation'; import { parseStringToIceServers } from './parseStringToIceServers'; @@ -43,7 +43,7 @@ const mediaCallLogger = new MediaCallLogger(); class MediaSessionInstance { private iceServers: IceServer[] = []; private iceGatheringTimeout: number = 5000; - private mediaSignalListener: { stop: () => void } | null = null; + private mediaSignalListener: IStreamDataListener | null = null; private instance: MediaSignalingSession | null = null; private mediaSessionStoreChangeUnsubscribe: (() => void) | null = null; private storeTimeoutUnsubscribe: (() => void) | null = null; @@ -111,6 +111,9 @@ class MediaSessionInstance { }) ); mediaSessionStore.setSendSignalFn((signal: ClientMediaSignal) => { + if (!sdk.isInitialized) { + return; + } sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal)).catch(error => { log(error); }); @@ -134,7 +137,7 @@ class MediaSessionInstance { this.instance = mediaSessionStore.getInstance(userId); }); - this.mediaSignalListener = sdk.onStreamData('stream-notify-user', async (ddpMessage: IDDPMessage) => { + this.mediaSignalListener = await sdk.onStreamData('stream-notify-user', async (ddpMessage: IDDPMessage) => { if (!this.instance) { return; } diff --git a/app/lib/services/voip/acceptNativeCall.integration.test.ts b/app/lib/services/voip/acceptNativeCall.integration.test.ts index ef988bc3825..02f2753d22f 100644 --- a/app/lib/services/voip/acceptNativeCall.integration.test.ts +++ b/app/lib/services/voip/acceptNativeCall.integration.test.ts @@ -6,6 +6,9 @@ import { useCallStore } from './useCallStore'; import { initStore } from '../../store/auxStore'; import { recoverSocket } from '../socketHealth'; import sdk from '../sdk'; +import { addMediaSubs, buildConnectedDriver } from '../../testUtils/sdkIntegration'; +import type { IMockSdk, IMockSdkDriver, MockConnection } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; import type { IApplicationState } from '../../../definitions'; jest.mock('./terminateNativeCall', () => ({ @@ -22,10 +25,19 @@ jest.mock('../socketHealth', () => ({ recoverSocket: jest.fn() })); -jest.mock('../sdk', () => ({ - __esModule: true, - default: { current: undefined } -})); +jest.mock('../sdk', () => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return { __esModule: true, default: sdkIntegration.makeSdkMock() }; +}); + +const mockConnections: MockConnection[] = []; + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); + }) +); jest.mock('../../methods/helpers/log', () => ({ __esModule: true, @@ -33,6 +45,7 @@ jest.mock('../../methods/helpers/log', () => ({ })); const CALL_ID = 'call-uuid'; +const USER_ID = 'user-id'; const READINESS_TIMEOUT = 8000; const mockTerminateNativeCall = terminateNativeCall as jest.Mock; @@ -55,22 +68,6 @@ function makeMediaSession(): IMediaSession { }; } -/** Media Signal subs that ack `delayMs` after the gate starts waiting. */ -function mediaSubsAckAfter(delayMs: number) { - return { - waitForNotifyUserMediaSubs: jest.fn(() => new Promise(resolve => setTimeout(() => resolve(true), delayMs))) - }; -} - -/** Media Signal subs that never ack: the wait ends on its own timeout. */ -function mediaSubsNeverAck() { - return { - waitForNotifyUserMediaSubs: jest.fn( - (timeoutMs: number) => new Promise(resolve => setTimeout(() => resolve(false), timeoutMs)) - ) - }; -} - /** * Minimal redux surface so `waitForLoginReady` runs for real: it reads * `login.isAuthenticated` / `meteor.connected` and subscribes for changes. @@ -97,18 +94,24 @@ function makeReduxStore() { describe('acceptNativeCallWithReadiness against real login readiness', () => { let redux: ReturnType; + let driver: IMockSdkDriver; - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); + mockConnections.length = 0; redux = makeReduxStore(); initStore(redux.store); mockGetCallState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); mockRecoverSocket.mockResolvedValue('reopened'); - (sdk as any).current = { ddp: mediaSubsAckAfter(100) }; + driver = await buildConnectedDriver(mockConnections, USER_ID); + addMediaSubs(driver, USER_ID); + (sdk as unknown as IMockSdk).setClient({ driver }); }); afterEach(() => { + if (driver.socket.pingTimeout) clearTimeout(driver.socket.pingTimeout); + if (driver.socket.openTimeout) clearTimeout(driver.socket.openTimeout); jest.useRealTimers(); }); @@ -148,7 +151,7 @@ describe('acceptNativeCallWithReadiness against real login readiness', () => { }); it('runs the failure ladder once and leaves nothing behind when readiness never lands', async () => { - (sdk as any).current = { ddp: mediaSubsNeverAck() }; + driver.socket.subscriptions = {}; const resetNativeCallId = jest.fn(); mockGetCallState.mockReturnValue({ call: null, resetNativeCallId }); const mediaSession = makeMediaSession(); diff --git a/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts new file mode 100644 index 00000000000..b51086e41ee --- /dev/null +++ b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts @@ -0,0 +1,149 @@ +import sdk from '../sdk'; +import { acceptNativeCallWithReadiness } from './acceptNativeCall'; +import { useCallStore } from './useCallStore'; +import { terminateNativeCall } from './terminateNativeCall'; +import { waitForLoginReady } from '../waitForLoginReady'; +import { addMediaSubs, backdateLastPing, buildConnectedDriver, stopAnsweringFrames } from '../../testUtils/sdkIntegration'; +import type { IMockSdk, MockConnection, IMockSdkDriver } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; + +jest.mock('../sdk', () => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return { __esModule: true, default: sdkIntegration.makeSdkMock() }; +}); + +jest.mock('./useCallStore', () => ({ + useCallStore: { getState: jest.fn() } +})); + +jest.mock('./terminateNativeCall', () => ({ + terminateNativeCall: jest.fn() +})); + +jest.mock('../waitForLoginReady', () => ({ + waitForLoginReady: jest.fn() +})); + +jest.mock('../../methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn() +})); + +const mockConnections: MockConnection[] = []; + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); + }) +); + +const mockWaitForLoginReady = waitForLoginReady as jest.MockedFunction; +const mockGetState = useCallStore.getState as jest.Mock; +const mockTerminateNativeCall = terminateNativeCall as jest.Mock; + +const CALL_ID = 'call-uuid'; +const USER_ID = 'user-id'; +const PING_INTERVAL = 10000; + +interface IMediaSession { + applyRestStateSignals: jest.Mock>; + answerCall: jest.Mock, [string]>; + endCall: jest.Mock; + isInitialized: jest.Mock; +} + +function makeMediaSession(overrides: Partial = {}): IMediaSession { + return { + applyRestStateSignals: jest.fn, []>(() => Promise.resolve()), + answerCall: jest.fn, [string]>(() => Promise.resolve()), + endCall: jest.fn(), + isInitialized: jest.fn(() => true), + ...overrides + }; +} + +let driver: IMockSdkDriver; + +beforeEach(async () => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockConnections.length = 0; + driver = await buildConnectedDriver(mockConnections, USER_ID); + (sdk as unknown as IMockSdk).setClient({ driver }); + mockWaitForLoginReady.mockResolvedValue(true); + mockGetState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); +}); + +afterEach(() => { + if (driver.socket.pingTimeout) clearTimeout(driver.socket.pingTimeout); + if (driver.socket.openTimeout) clearTimeout(driver.socket.openTimeout); + jest.useRealTimers(); +}); + +describe('acceptNativeCallWithReadiness against the real SDK socket', () => { + it('answers the call once media subs re-ack on the reopened socket', async () => { + const mediaSession = makeMediaSession(); + + backdateLastPing(driver, PING_INTERVAL * 3); + addMediaSubs(driver, USER_ID); + + const accept = acceptNativeCallWithReadiness(CALL_ID, mediaSession); + await jest.advanceTimersByTimeAsync(0); + mockConnections[1].onopen(); + await jest.advanceTimersByTimeAsync(0); + await jest.advanceTimersByTimeAsync(200); + await accept; + + expect(mockWaitForLoginReady).toHaveBeenCalledTimes(1); + expect(mediaSession.applyRestStateSignals).toHaveBeenCalledTimes(1); + expect(mediaSession.answerCall).toHaveBeenCalledWith(CALL_ID); + expect(mockTerminateNativeCall).not.toHaveBeenCalled(); + expect(mediaSession.endCall).not.toHaveBeenCalled(); + }); + + it('fails the call without answering when the reopened socket never acks the re-sub', async () => { + const mediaSession = makeMediaSession(); + const resetNativeCallId = jest.fn(); + mockGetState.mockReturnValue({ call: null, resetNativeCallId }); + + backdateLastPing(driver, PING_INTERVAL * 3); + addMediaSubs(driver, USER_ID); + + const accept = acceptNativeCallWithReadiness(CALL_ID, mediaSession); + await jest.advanceTimersByTimeAsync(0); + mockConnections[1].onopen(); + + stopAnsweringFrames(mockConnections[1]); + await jest.advanceTimersByTimeAsync(0); + await jest.advanceTimersByTimeAsync(8000); + await accept; + + expect(mockTerminateNativeCall).toHaveBeenCalledWith(CALL_ID); + expect(resetNativeCallId).toHaveBeenCalled(); + expect(mediaSession.endCall).toHaveBeenCalledWith(CALL_ID); + expect(mediaSession.answerCall).not.toHaveBeenCalled(); + expect(mediaSession.applyRestStateSignals).not.toHaveBeenCalled(); + }); + + it('answers when the media subs only appear after the reopen', async () => { + const mediaSession = makeMediaSession(); + + backdateLastPing(driver, PING_INTERVAL * 3); + + const accept = acceptNativeCallWithReadiness(CALL_ID, mediaSession); + await jest.advanceTimersByTimeAsync(0); + mockConnections[1].onopen(); + await jest.advanceTimersByTimeAsync(0); + + await jest.advanceTimersByTimeAsync(100); + + addMediaSubs(driver, USER_ID); + await jest.advanceTimersByTimeAsync(200); + await accept; + + expect(mediaSession.applyRestStateSignals).toHaveBeenCalledTimes(1); + expect(mediaSession.answerCall).toHaveBeenCalledWith(CALL_ID); + expect(mockTerminateNativeCall).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/services/voip/acceptNativeCall.test.ts b/app/lib/services/voip/acceptNativeCall.test.ts index 026f4e9dc0b..13eb3834977 100644 --- a/app/lib/services/voip/acceptNativeCall.test.ts +++ b/app/lib/services/voip/acceptNativeCall.test.ts @@ -4,12 +4,14 @@ import { terminateNativeCall } from './terminateNativeCall'; import { waitForLoginReady } from '../waitForLoginReady'; import { recoverSocket } from '../socketHealth'; import sdk from '../sdk'; +import { buildConnectedDriver } from '../../testUtils/sdkIntegration'; +import type { IMockSdk, IMockSdkDriver, MockConnection } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; const mockWaitForLoginReady = waitForLoginReady as jest.MockedFunction; const mockRecoverSocket = recoverSocket as jest.MockedFunction; const mockGetState = useCallStore.getState as jest.Mock; const mockTerminateNativeCall = terminateNativeCall as jest.Mock; -const mockDdp = () => sdk.current?.ddp as any; jest.mock('./useCallStore', () => ({ useCallStore: { @@ -21,12 +23,19 @@ jest.mock('./terminateNativeCall', () => ({ terminateNativeCall: jest.fn() })); -jest.mock('../sdk', () => ({ - __esModule: true, - default: { - current: { ddp: {} } - } -})); +jest.mock('../sdk', () => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return { __esModule: true, default: sdkIntegration.makeSdkMock() }; +}); + +const mockConnections: MockConnection[] = []; + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); + }) +); jest.mock('../socketHealth', () => ({ recoverSocket: jest.fn() @@ -59,13 +68,6 @@ function makeMediaSession(overrides: Partial = {}): IMediaSession }; } -function makeDdp(overrides: Record = {}) { - return { - waitForNotifyUserMediaSubs: jest.fn(() => Promise.resolve(true)), - ...overrides - }; -} - function makeStoreState(overrides: Record = {}) { return { call: null, @@ -76,17 +78,26 @@ function makeStoreState(overrides: Record = {}) { describe('acceptNativeCallWithReadiness', () => { const CALL_ID = 'call-uuid'; + const USER_ID = 'user-id'; + + let driver: IMockSdkDriver; + let waitForMediaSubs: jest.SpyInstance, [number?]>; - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); - (sdk as any).current = { ddp: makeDdp() }; + mockConnections.length = 0; + driver = await buildConnectedDriver(mockConnections, USER_ID); + waitForMediaSubs = jest.spyOn(driver, 'waitForNotifyUserMediaSubs').mockResolvedValue(true); + (sdk as unknown as IMockSdk).setClient({ driver }); mockRecoverSocket.mockResolvedValue('confirmed-alive'); mockWaitForLoginReady.mockResolvedValue(true); mockGetState.mockReturnValue(makeStoreState()); }); afterEach(() => { + if (driver.socket.pingTimeout) clearTimeout(driver.socket.pingTimeout); + if (driver.socket.openTimeout) clearTimeout(driver.socket.openTimeout); jest.useRealTimers(); }); @@ -163,7 +174,7 @@ describe('acceptNativeCallWithReadiness', () => { }); it('terminates and ends the call when media-subscription ack times out', async () => { - mockDdp().waitForNotifyUserMediaSubs = jest.fn(() => Promise.resolve(false)); + waitForMediaSubs.mockResolvedValue(false); const mediaSession = makeMediaSession(); const resetNativeCallId = jest.fn(); mockGetState.mockReturnValue(makeStoreState({ resetNativeCallId })); @@ -190,7 +201,7 @@ describe('acceptNativeCallWithReadiness', () => { }); it('terminates and ends the call when the SDK socket is unavailable for media subscriptions', async () => { - (sdk as any).current = {}; + (sdk as unknown as IMockSdk).setClient({}); const mediaSession = makeMediaSession(); const resetNativeCallId = jest.fn(); mockGetState.mockReturnValue(makeStoreState({ resetNativeCallId })); diff --git a/app/lib/services/voip/acceptNativeCall.ts b/app/lib/services/voip/acceptNativeCall.ts index 7aab96d7d67..85adb619e0b 100644 --- a/app/lib/services/voip/acceptNativeCall.ts +++ b/app/lib/services/voip/acceptNativeCall.ts @@ -1,6 +1,6 @@ import log from '../../methods/helpers/log'; import { onAbort } from '../../methods/helpers/onAbort'; -import sdk from '../sdk'; +import sdk, { type ISocketDriver } from '../sdk'; import { waitForLoginReady } from '../waitForLoginReady'; import { recoverSocket } from '../socketHealth'; import { terminateNativeCall } from './terminateNativeCall'; @@ -13,28 +13,19 @@ export interface NativeCallMediaSession { isInitialized(): boolean; } -/** The slice of the patched DDP driver the accept path reads: Media Signal subscription readiness. */ -interface MediaSignalDdp { - waitForNotifyUserMediaSubs(timeoutMs: number): Promise; -} - const activeGates = new Map(); -async function waitForMediaSignalSubs(ddp: MediaSignalDdp, timeoutMs: number, abortSignal?: AbortSignal): Promise { - if (typeof ddp.waitForNotifyUserMediaSubs !== 'function') { - return false; - } +async function waitForMediaSignalSubs(driver: ISocketDriver, timeoutMs: number, abortSignal?: AbortSignal): Promise { if (abortSignal?.aborted) { return false; } - const ready = ddp.waitForNotifyUserMediaSubs(timeoutMs); const aborted = new Promise(resolve => { onAbort(abortSignal, () => resolve(false)); }); try { - return await Promise.race([ready, aborted]); + return await Promise.race([driver.waitForNotifyUserMediaSubs(timeoutMs), aborted]); } catch (error) { log(error); return false; @@ -74,14 +65,14 @@ export async function acceptNativeCallWithReadiness(callId: string, mediaSession return; } - const ddp = sdk.current?.ddp as MediaSignalDdp | undefined; - if (!ddp) { + const driver = sdk.driver; + if (!driver) { return handleFailure(callId, mediaSession); } const [loginReady, mediaSubsReady] = await Promise.all([ waitForLoginReady(8000, controller.signal), - waitForMediaSignalSubs(ddp, 8000, controller.signal) + waitForMediaSignalSubs(driver, 8000, controller.signal) ]); if (controller.signal.aborted) { diff --git a/app/lib/services/waitForLoginReady.ts b/app/lib/services/waitForLoginReady.ts index 06e6ce494f7..77d844ddb88 100644 --- a/app/lib/services/waitForLoginReady.ts +++ b/app/lib/services/waitForLoginReady.ts @@ -1,8 +1,6 @@ import { onAbort } from '../methods/helpers/onAbort'; import { store } from '../store/auxStore'; -// Reads redux rather than `ddp.loggedIn`: `close` clears `meteor.connected`, while `ddp.loggedIn` survives it. -// Neither survives a silent background death, so callers must bound their wait. export function isLoginReady(): boolean { const state = store.getState(); return state.login.isAuthenticated && state.meteor.connected; diff --git a/app/lib/store/__tests__/appStateMiddleware.test.ts b/app/lib/store/__tests__/appStateMiddleware.test.ts new file mode 100644 index 00000000000..a6f7c633bee --- /dev/null +++ b/app/lib/store/__tests__/appStateMiddleware.test.ts @@ -0,0 +1,93 @@ +jest.mock('react-native', () => ({ + AppState: { + currentState: 'unknown', + addEventListener: jest.fn() + } +})); + +jest.mock('../../notifications', () => ({ + removeNotificationsAndBadge: jest.fn(() => Promise.resolve()) +})); + +import { AppState } from 'react-native'; + +import applyAppStateMiddleware from '../appStateMiddleware'; +import { APP_STATE } from '../../../actions/actionsTypes'; + +function bootMiddleware(): { dispatch: jest.Mock; notifyAppState: (state: string) => void } { + const dispatch = jest.fn(); + const createStore = jest.fn(() => ({ dispatch })); + applyAppStateMiddleware()(createStore)(); + const [, notifyAppState] = (AppState.addEventListener as jest.Mock).mock.calls[0]; + jest.runOnlyPendingTimers(); + return { dispatch, notifyAppState }; +} + +function dispatchedTypes(dispatch: jest.Mock): string[] { + return dispatch.mock.calls.map(([action]) => action.type); +} + +describe('appStateMiddleware', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + AppState.currentState = 'unknown'; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('reports the state the app booted into', () => { + AppState.currentState = 'active'; + + const { dispatch } = bootMiddleware(); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); + }); + + it('stays quiet when the app boots into an unknown state', () => { + AppState.currentState = 'unknown'; + + const { dispatch } = bootMiddleware(); + + expect(dispatchedTypes(dispatch)).toEqual([]); + }); + + it('tells the app it came to the foreground', () => { + const { dispatch, notifyAppState } = bootMiddleware(); + + notifyAppState('active'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); + }); + + it('tells the app it went to the background', () => { + const { dispatch, notifyAppState } = bootMiddleware(); + + notifyAppState('background'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.BACKGROUND]); + }); + + it('keeps the foreground state through a temporary interruption', () => { + const { dispatch, notifyAppState } = bootMiddleware(); + + notifyAppState('active'); + notifyAppState('inactive'); + notifyAppState('active'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.FOREGROUND]); + }); + + it('does not repeat the state already in effect', () => { + const { dispatch, notifyAppState } = bootMiddleware(); + + notifyAppState('background'); + notifyAppState('background'); + notifyAppState('active'); + notifyAppState('active'); + + expect(dispatchedTypes(dispatch)).toEqual([APP_STATE.BACKGROUND, APP_STATE.FOREGROUND]); + }); +}); diff --git a/app/lib/testUtils/sagaStore.ts b/app/lib/testUtils/sagaStore.ts new file mode 100644 index 00000000000..50b5bc11bf8 --- /dev/null +++ b/app/lib/testUtils/sagaStore.ts @@ -0,0 +1,43 @@ +import { applyMiddleware, createStore } from 'redux'; +import type { AnyAction, Store } from 'redux'; +import createSagaMiddleware from 'redux-saga'; +import type { Saga, Task } from 'redux-saga'; + +import reducers from '../../reducers'; + +const MICROTASK_DRAIN_PASSES = 20; + +export async function flushSagaMicrotasks(): Promise { + for (let i = 0; i < MICROTASK_DRAIN_PASSES; i += 1) { + await Promise.resolve(); + } +} + +const runningTasks: Task[] = []; + +export function cancelSagaTasks(): void { + runningTasks.splice(0).forEach(task => task.cancel()); +} + +export interface RecordingStore { + store: Store; + dispatchedActions: AnyAction[]; +} + +export function createRecordingStore(rootSaga: Saga): RecordingStore { + const dispatchedActions: AnyAction[] = []; + const sagaMiddleware = createSagaMiddleware(); + const store = createStore( + reducers, + applyMiddleware( + () => next => action => { + dispatchedActions.push(action); + return next(action); + }, + sagaMiddleware + ) + ); + const task: Task = sagaMiddleware.run(rootSaga); + runningTasks.push(task); + return { store, dispatchedActions }; +} diff --git a/app/lib/testUtils/sdkIntegration.ts b/app/lib/testUtils/sdkIntegration.ts new file mode 100644 index 00000000000..f56f47d8736 --- /dev/null +++ b/app/lib/testUtils/sdkIntegration.ts @@ -0,0 +1,206 @@ +import type * as RocketChatSdk from '@rocket.chat/sdk'; +import type { Store } from 'redux'; + +import type { IApplicationState } from '../../definitions'; +import type sdk from '../services/sdk'; +import type { ISocketDriver } from '../services/sdk'; + +export interface IDdpMessage { + msg: string; + id?: string; + name?: string; + method?: string; + params?: unknown[]; +} + +export class MockConnection { + send = jest.fn((frame: string) => { + const message = JSON.parse(frame) as IDdpMessage; + if (message.msg === 'connect') { + setImmediate(() => this.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'session-id' }) })); + } else if (message.msg === 'ping') { + setImmediate(() => this.onmessage({ data: JSON.stringify({ msg: 'pong' }) })); + } else if (message.msg === 'sub') { + setImmediate(() => this.onmessage({ data: JSON.stringify({ msg: 'ready', subs: [message.id] }) })); + } else if (message.msg === 'unsub') { + setImmediate(() => this.onmessage({ data: JSON.stringify({ msg: 'nosub', id: message.id }) })); + } else if (message.msg === 'method' && message.method === 'login') { + setImmediate(() => + this.onmessage({ + data: JSON.stringify({ msg: 'result', id: message.id, result: { id: 'user-id', token: 'auth-token' } }) + }) + ); + } + }); + + close = jest.fn(); + readyState = 1; + onopen = () => {}; + onmessage = (_event: { data: string }) => {}; + onerror = () => {}; + onclose = (_event?: { code?: number }) => {}; + + constructor(registry: MockConnection[]) { + registry.push(this); + } +} + +export interface IMockSdkDriver extends ISocketDriver { + userId: string; + pingInterval: number; + socket: { + lastPing: number; + pingTimeout?: ReturnType; + openTimeout?: ReturnType; + open(): Promise; + send(message: Record): Promise; + subscriptions: Record; + }; +} + +export interface IMockSdkClient { + host?: string; + driver?: ISocketDriver; +} + +export type IMockSdk = Pick & { + setClient(client: IMockSdkClient | null): void; +}; + +export function makeSdkMock = Record>( + members?: TMembers +): IMockSdk & TMembers { + let client: IMockSdkClient | null = null; + const mock: IMockSdk = { + setClient(next: IMockSdkClient | null) { + client = next; + }, + get host() { + return client?.host ?? null; + }, + get driver() { + return client?.driver ?? null; + }, + get isInitialized() { + return client !== null; + } + }; + return Object.assign(mock, members ?? ({} as TMembers)); +} + +export function latestConnection(connections: MockConnection[]): MockConnection { + return connections[connections.length - 1]; +} + +export function framesOn(connection: MockConnection, msg: string): IDdpMessage[] { + return connection.send.mock.calls + .map(([frame]: [string]) => JSON.parse(frame) as IDdpMessage) + .filter(message => message.msg === msg); +} + +export function receiveFrame(connection: MockConnection, frame: Record): void { + connection.onmessage({ data: JSON.stringify(frame) }); +} + +const { Rocketchat } = jest.requireActual('@rocket.chat/sdk'); + +const driverLogger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }; + +export async function buildConnectedDriver(connections: MockConnection[], userId: string): Promise { + const driver = new Rocketchat({ host: 'localhost:3000', logger: driverLogger }).driver as unknown as IMockSdkDriver; + driver.userId = userId; + const openPromise = driver.socket.open(); + connections[0].onopen(); + await jest.advanceTimersByTimeAsync(0); + await openPromise; + return driver; +} + +export function addMediaSubs(driver: IMockSdkDriver, userId: string): void { + ['media-signal', 'media-calls'].forEach((name, index) => { + const id = `sub-${index}`; + driver.socket.subscriptions[id] = { + id, + name: 'stream-notify-user', + params: [`${userId}/${name}`], + unsubscribe: jest.fn() + }; + }); +} + +export function backdateLastPing(driver: IMockSdkDriver, ageMs: number): void { + driver.socket.lastPing = Date.now() - ageMs; +} + +export function stopAnsweringFrames(connection: MockConnection): void { + connection.send.mockImplementation(() => undefined); +} + +export interface IMockCollection { + name: string; + find: jest.Mock; + query: jest.Mock; + create: jest.Mock; + prepareCreate: jest.Mock; + schema: Record; +} + +export function makeCollection(name: string): IMockCollection { + return { + name, + find: jest.fn(), + query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), + create: jest.fn(), + prepareCreate: jest.fn(), + schema: {} + }; +} + +export async function flush(turns = 10): Promise { + for (let i = 0; i < turns; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(0); + } +} + +export async function settleUntil(isSettled: () => boolean, maxRounds = 20): Promise { + for (let round = 0; round < maxRounds && !isSettled(); round++) { + await jest.runOnlyPendingTimersAsync(); + await flush(); + } +} + +export interface IMockReduxState { + meteor: { connected: boolean }; + login: { user: Record | null; isAuthenticated: boolean }; + server: { version: string }; + settings: Record; + room: { subscribedRoom: string | null }; +} + +export interface IMockReduxStore { + state: IMockReduxState; + store: Store & { dispatch: jest.Mock }; +} + +export function makeReduxStore(): IMockReduxStore { + const listeners = new Set<() => void>(); + const state: IMockReduxState = { + meteor: { connected: false }, + login: { user: null, isAuthenticated: false }, + server: { version: '5.0.0' }, + settings: {}, + room: { subscribedRoom: null } + }; + return { + state, + store: { + getState: () => state, + dispatch: jest.fn(), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + } + } as unknown as Store & { dispatch: jest.Mock } + }; +} diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 1e0dd9c424b..c7639a5349b 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -35,11 +35,7 @@ jest.mock('../../lib/services/connect', () => ({ jest.mock('../../lib/services/sdk', () => ({ __esModule: true, default: { - current: { - client: { - host: '' - } - } + host: null } })); @@ -92,18 +88,16 @@ jest.mock('../../lib/methods/helpers', () => ({ // ─── Real imports (after mocks) ─────────────────────────────────────────────── -import { applyMiddleware, createStore } from 'redux'; -import createSagaMiddleware from 'redux-saga'; - 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, LOGOUT, SERVER } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; -import reducers from '../../reducers'; 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'; @@ -112,23 +106,12 @@ import { loginOAuthOrSso } from '../../lib/services/connect'; import sdk from '../../lib/services/sdk'; import database from '../../lib/database'; import EventEmitter from '../../lib/methods/helpers/events'; +import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; +import type { RecordingStore } from '../../lib/testUtils/sagaStore'; -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/** Drains pending saga microtasks so all synchronous saga steps complete. */ -async function flushSagaMicrotasks(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} +const setupStore = (): RecordingStore => createRecordingStore(deepLinkingRoot); -type PreloadedState = Parameters[1]; - -function setupStore(preloadedState?: PreloadedState) { - const sagaMiddleware = createSagaMiddleware(); - const store = createStore(reducers, preloadedState, applyMiddleware(sagaMiddleware)); - sagaMiddleware.run(deepLinkingRoot); - return store; -} +afterEach(cancelSagaTasks); // ─── Factories ──────────────────────────────────────────────────────────────── @@ -201,7 +184,7 @@ describe('deepLinking saga — Regression race (new server + token + room path)' * once, sequenced after the APP.START dispatch. */ it('calls goRoom exactly once after APP.START(ROOT_INSIDE) completes the chain', async () => { - const store = setupStore(); + const { store } = setupStore(); const params = makeParamsWithToken(); store.dispatch(deepLinkingOpen(params)); @@ -223,15 +206,10 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); - // Saga has dispatched appReady and selected state.app.root. - // Root is NOT yet ROOT_INSIDE (reducer hasn't seen ROOT_INSIDE yet), - // so saga is waiting for APP.START(ROOT_INSIDE). expect(jest.mocked(goRoom)).not.toHaveBeenCalled(); - // Now dispatch APP.START(ROOT_INSIDE) — this satisfies the take. store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); @@ -242,7 +220,7 @@ describe('deepLinking saga — Regression race (new server + token + room path)' * Then dispatch APP.START(ROOT_INSIDE). Flush. Assert goRoom called once. */ it('goRoom is NOT called between LOGIN.SUCCESS and APP.START(ROOT_INSIDE)', async () => { - const store = setupStore(); + const { store } = setupStore(); const params = makeParamsWithToken(); store.dispatch(deepLinkingOpen(params)); @@ -262,7 +240,6 @@ describe('deepLinking saga — Regression race (new server + token + room path)' // Now release the saga by dispatching APP.START(ROOT_INSIDE) store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); @@ -274,7 +251,7 @@ describe('deepLinking saga — Regression race (new server + token + room path)' * before flushing, so the reducer updates the root before the saga's select runs. */ it('skips the APP.START take when state.app.root is already ROOT_INSIDE at select time', async () => { - const store = setupStore(); + const { store } = setupStore(); const params = makeParamsWithToken(); store.dispatch(deepLinkingOpen(params)); @@ -291,7 +268,6 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); // goRoom should fire immediately — the take was skipped by the select short-circuit expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); @@ -303,7 +279,7 @@ describe('deepLinking saga — Regression race (new server + token + room path)' * called once. */ it('APP.START(ROOT_OUTSIDE) does not satisfy the take; APP.START(ROOT_INSIDE) does', async () => { - const store = setupStore(); + const { store } = setupStore(); const params = makeParamsWithToken(); store.dispatch(deepLinkingOpen(params)); @@ -327,7 +303,6 @@ describe('deepLinking saga — Regression race (new server + token + room path)' // Now dispatch correct root — satisfies the take store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); @@ -338,7 +313,7 @@ describe('deepLinking saga — Regression race (new server + token + room path)' * the take, takeLatest has not been retriggered). */ it('a second APP.START(ROOT_INSIDE) after navigation does not re-trigger goRoom', async () => { - const store = setupStore(); + const { store } = setupStore(); const params = makeParamsWithToken(); store.dispatch(deepLinkingOpen(params)); @@ -355,14 +330,12 @@ describe('deepLinking saga — Regression race (new server + token + room path)' // First APP.START(ROOT_INSIDE) — fires the take store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); // Second APP.START(ROOT_INSIDE) — saga is done, no re-trigger store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); // Still exactly once expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); @@ -399,13 +372,12 @@ describe('deepLinking saga — server already connected, should skip changing se jest.mocked(goRoom).mockResolvedValue(undefined); // Key setup: SDK websocket is already open to HOST - (sdk.current as any).client.host = HOST; + (sdk as any).host = HOST; }); afterEach(() => { jest.useRealTimers(); - // Reset so other describe blocks see the default empty host - (sdk.current as any).client.host = ''; + (sdk as any).host = null; }); /** @@ -414,14 +386,12 @@ describe('deepLinking saga — server already connected, should skip changing se * (not SELECT_SUCCESS) when the server is already connected. */ it('calls goRoom after LOGIN.SUCCESS + APP.START(ROOT_INSIDE) without needing SERVER.SELECT_SUCCESS', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen(makeParamsWithToken())); - // Two flushes drain the getServerById and getServerInfo promise microtasks. // No jest.advanceTimersByTimeAsync needed — delay(1000) is skipped when // hostAlreadyConnected is true. await flushSagaMicrotasks(); - await flushSagaMicrotasks(); // Saga must be parked at take(LOGIN.SUCCESS), not take(SERVER.SELECT_SUCCESS) expect(jest.mocked(goRoom)).not.toHaveBeenCalled(); @@ -434,7 +404,6 @@ describe('deepLinking saga — server already connected, should skip changing se store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); }); @@ -447,10 +416,9 @@ describe('deepLinking saga — server already connected, should skip changing se it('does not emit NewServer when the SDK is already connected to the deeplink host', async () => { const emitSpy = jest.spyOn(EventEmitter, 'emit'); - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen(makeParamsWithToken())); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(emitSpy).not.toHaveBeenCalledWith('NewServer', expect.anything()); @@ -458,7 +426,6 @@ describe('deepLinking saga — server already connected, should skip changing se store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(goRoom)).toHaveBeenCalledTimes(1); emitSpy.mockRestore(); @@ -499,7 +466,7 @@ describe('deepLinking saga — handleClickCallPush (new server + token + call ro }); it('navigates to the call room once after SELECT_SUCCESS and LOGIN.SUCCESS', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingClickCallPush(makeCallParams())); await flushSagaMicrotasks(); @@ -513,7 +480,6 @@ describe('deepLinking saga — handleClickCallPush (new server + token + call ro store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(navigateToRoom)).toHaveBeenCalledTimes(1); }); @@ -530,11 +496,10 @@ describe('deepLinking saga — handleOAuth dedup guard', () => { }); it('calls loginOAuthOrSso with the oauth credentials on a fresh token', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-fresh-A', credentialSecret: 'secret-A' } as any)); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledTimes(1); expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledWith({ @@ -543,41 +508,36 @@ describe('deepLinking saga — handleOAuth dedup guard', () => { }); it('does not call loginOAuthOrSso when the credentialSecret is missing', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-no-secret-D' } as any)); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(loginOAuthOrSso)).not.toHaveBeenCalled(); }); it('does not call loginOAuthOrSso a second time for the same credentialToken', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-dup-B', credentialSecret: 'secret-B' } as any)); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); // Second dispatch with the identical token — guard must suppress it. store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-dup-B', credentialSecret: 'secret-B' } as any)); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledTimes(1); }); it('calls loginOAuthOrSso again for a different credentialToken after a previous one was consumed', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-first-C', credentialSecret: 'secret-C' } as any)); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); // A distinct token must not be blocked by the guard. store.dispatch(deepLinkingOpen({ type: 'oauth', credentialToken: 'token-second-C', credentialSecret: 'secret-C2' } as any)); await flushSagaMicrotasks(); - await flushSagaMicrotasks(); expect(jest.mocked(loginOAuthOrSso)).toHaveBeenCalledTimes(2); expect(jest.mocked(loginOAuthOrSso)).toHaveBeenNthCalledWith(2, { @@ -586,6 +546,145 @@ describe('deepLinking saga — handleOAuth dedup guard', () => { }); }); +describe('deepLinking saga — unknown host hands off to the add-server flow', () => { + const PREVIOUS_SERVER = 'https://previous.rocket.chat'; + + beforeEach(() => { + jest.useFakeTimers(); + jest.mocked(UserPreferences.getString).mockReset(); + jest.mocked(getServerById).mockReset(); + jest.mocked(getServerInfo).mockReset(); + + jest.mocked(UserPreferences.getString).mockImplementation((key: string) => { + if (key === 'currentServer') return PREVIOUS_SERVER; + return null; + }); + jest.mocked(getServerById).mockResolvedValue(undefined as any); + jest.mocked(getServerInfo).mockResolvedValue({ success: true } as any); + (sdk as any).host = PREVIOUS_SERVER; + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('starts the outside stack, seeds the previous server, then emits NewServer for the host', async () => { + const emit = jest.spyOn(EventEmitter, 'emit').mockImplementation(() => {}); + const { store, dispatchedActions } = setupStore(); + + store.dispatch(deepLinkingOpen(makeParams() as any)); + await flushSagaMicrotasks(); + + const outsideIndex = dispatchedActions.findIndex( + action => action.type === APP.START && action.root === RootEnum.ROOT_OUTSIDE + ); + const initAddIndex = dispatchedActions.findIndex(action => action.type === SERVER.INIT_ADD); + + expect(outsideIndex).toBeGreaterThanOrEqual(0); + expect(initAddIndex).toBeGreaterThan(outsideIndex); + expect(dispatchedActions[initAddIndex].previousServer).toBe(PREVIOUS_SERVER); + expect(emit).not.toHaveBeenCalledWith('NewServer', { server: HOST }); + + jest.advanceTimersByTime(1000); + await flushSagaMicrotasks(); + + expect(emit).toHaveBeenCalledWith('NewServer', { server: HOST }); + 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(); + }); + (sdk as any).host = null; + }); + + afterEach(() => { + cancelSagaTasks(); + (sdk as any).host = null; + }); + + 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); + }); +}); + describe('deepLinking saga — handleSaml', () => { beforeEach(() => { jest.mocked(loginOAuthOrSso).mockReset(); @@ -593,7 +692,7 @@ describe('deepLinking saga — handleSaml', () => { }); it('redeems the SAML credential token through the regular saml login', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST, credentialToken: 'saml-fresh-A' } as any)); await flushSagaMicrotasks(); @@ -604,7 +703,7 @@ describe('deepLinking saga — handleSaml', () => { }); it('does not call loginOAuthOrSso when the credentialToken is missing', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST } as any)); await flushSagaMicrotasks(); @@ -614,7 +713,7 @@ describe('deepLinking saga — handleSaml', () => { }); it('does not redeem the same SAML credentialToken twice', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST, credentialToken: 'saml-dup-B' } as any)); await flushSagaMicrotasks(); @@ -629,7 +728,7 @@ describe('deepLinking saga — handleSaml', () => { }); it('redeems a different SAML credentialToken after a previous one was consumed', async () => { - const store = setupStore(); + const { store } = setupStore(); store.dispatch(deepLinkingOpen({ type: 'saml', host: HOST, credentialToken: 'saml-first-C' } as any)); await flushSagaMicrotasks(); diff --git a/app/sagas/__tests__/foregroundResume.integration.test.ts b/app/sagas/__tests__/foregroundResume.integration.test.ts new file mode 100644 index 00000000000..0e2f054e049 --- /dev/null +++ b/app/sagas/__tests__/foregroundResume.integration.test.ts @@ -0,0 +1,399 @@ +jest.unmock('@rocket.chat/sdk'); + +import { applyMiddleware, createStore, type AnyAction, type Store } from 'redux'; +import createSagaMiddleware from 'redux-saga'; + +import type * as SdkIntegration from '../../lib/testUtils/sdkIntegration'; +import type { MockConnection } from '../../lib/testUtils/sdkIntegration'; + +const USER_ID = 'user-id'; +const RESUME_TOKEN = 'auth-token'; +const CLOSED = 3; +const mockConnections: MockConnection[] = []; + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const sdkIntegration = jest.requireActual('../../lib/testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); + }) +); + +jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ + localAuthenticate: jest.fn(), + saveLastLocalAuthenticationSession: jest.fn() +})); + +jest.mock('../../lib/services/restApi', () => ({ + setUserPresenceOnline: jest.fn(), + setUserPresenceAway: jest.fn() +})); + +jest.mock('../../lib/notifications', () => ({ + checkPendingNotification: jest.fn(() => Promise.resolve()) +})); + +jest.mock('../../lib/services/voip/MediaSessionInstance', () => ({ + mediaSessionInstance: { + reset: jest.fn(), + drainPendingHangups: jest.fn() + } +})); + +jest.mock('../../lib/services/voip/MediaSessionStore', () => ({ + mediaSessionStore: { getCurrentInstance: jest.fn(() => null) } +})); + +jest.mock('../../lib/services/twoFactor', () => ({ + twoFactor: jest.fn() +})); + +jest.mock('../../lib/methods/subscribeRooms', () => ({ + subscribeRooms: jest.fn(), + unsubscribeRooms: jest.fn() +})); + +jest.mock('../../lib/methods/loadMissedMessages', () => ({ + loadMissedMessages: jest.fn(() => Promise.resolve()) +})); + +jest.mock('../../lib/methods/readMessages', () => ({ + readMessages: jest.fn(() => Promise.resolve()) +})); + +jest.mock('../../lib/methods/helpers/markMessagesRead', () => ({ + __esModule: true, + default: jest.fn() +})); + +jest.mock('../../lib/methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn(), + events: {}, + logEvent: jest.fn() +})); + +jest.mock('../../lib/encryption', () => ({ + Encryption: { decryptMessage: jest.fn(async (message: unknown) => message) } +})); + +jest.mock('../../lib/database/services/Message', () => ({ + getMessageById: jest.fn(() => Promise.resolve(null)) +})); + +jest.mock('../../lib/database', () => ({ + __esModule: true, + default: { + setActiveDB: jest.fn(), + servers: { get: jest.fn(), write: jest.fn() }, + active: { + get: jest.fn(), + write: jest.fn(), + batch: jest.fn() + } + } +})); + +import RoomSubscription from '../../lib/methods/subscriptions/room'; +import databaseModule from '../../lib/database'; +import { connect } from '../../lib/services/connect'; +import sdk from '../../lib/services/sdk'; +import { loadMissedMessages } from '../../lib/methods/loadMissedMessages'; +import { initStore } from '../../lib/store/auxStore'; +import { APP_STATE } from '../../actions/actionsTypes'; +import { appStart } from '../../actions/app'; +import { loginRequest, loginSuccess } from '../../actions/login'; +import { connectSuccess, disconnect } from '../../actions/connect'; +import { selectServerSuccess } from '../../actions/server'; +import { RootEnum } from '../../definitions'; +import reducers from '../../reducers'; +import loginRoot from '../login'; +import stateRoot from '../state'; +import { + flush, + framesOn, + latestConnection, + makeCollection, + settleUntil, + stopAnsweringFrames +} from '../../lib/testUtils/sdkIntegration'; +import { saveLastLocalAuthenticationSession } from '../../lib/methods/helpers/localAuthentication'; +import { setUserPresenceAway } from '../../lib/services/restApi'; + +const SERVER = 'https://open.rocket.chat'; +const ROOM_ID = 'room-rid'; +const RECOVERY_WINDOW = 5000; + +const database = databaseModule as unknown as { + active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; +}; + +const ROOM_TOPICS = [ + `stream-room-messages:${ROOM_ID}`, + `stream-notify-room:${ROOM_ID}/user-activity`, + `stream-notify-room:${ROOM_ID}/deleteMessage`, + `stream-notify-room:${ROOM_ID}/deleteMessageBulk`, + `stream-notify-room:${ROOM_ID}/messagesRead` +]; + +function typeOf(action: AnyAction): string { + return action.type; +} + +function topicsOn(connection: MockConnection): string[] { + return framesOn(connection, 'sub').map(frame => `${frame.name}:${frame.params?.[0]}`); +} + +function roomTopicsOn(connection: MockConnection): string[] { + return topicsOn(connection).filter(topic => topic.includes(ROOM_ID)); +} + +let dispatched: AnyAction[]; +let store: Store; +let collections: Record>; + +function recordDispatched() { + return () => (next: (action: AnyAction) => AnyAction) => (action: AnyAction) => { + dispatched.push(action); + return next(action); + }; +} + +function bootApp(): void { + dispatched = []; + const sagaMiddleware = createSagaMiddleware(); + store = createStore(reducers, applyMiddleware(recordDispatched(), sagaMiddleware)); + sagaMiddleware.run(stateRoot); + sagaMiddleware.run(loginRoot); + initStore(store); + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + store.dispatch(selectServerSuccess({ server: SERVER, name: 'open.rocket.chat', version: '6.0.0' })); +} + +async function openSocket(): Promise { + await connect({ server: SERVER }); + await flush(); + mockConnections[0].onopen(); + await flush(); + store.dispatch(connectSuccess()); + await flush(); +} + +async function openSignedInSocket(): Promise { + await openSocket(); + store.dispatch(loginSuccess({ id: USER_ID, token: RESUME_TOKEN } as never)); + await flush(); +} + +function resumedUser(): unknown { + const resumed = dispatched.find(action => typeOf(action) === typeOf(loginSuccess({} as never))); + return resumed?.user; +} + +async function subscribeToRoom(rid: string): Promise { + const room = new RoomSubscription(rid); + const subscribing = room.subscribe(); + await flush(); + await subscribing; + await flush(); + return room; +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockConnections.length = 0; + collections = {}; + database.active.get.mockReset().mockImplementation((name: string) => (collections[name] ??= makeCollection(name))); + database.active.write.mockReset().mockImplementation((fn: () => unknown) => fn()); + database.active.batch.mockReset().mockImplementation((...records: unknown[]) => Promise.resolve(records)); + global.fetch = jest.fn(() => + Promise.resolve({ + status: 200, + json: () => + Promise.resolve({ + status: 'success', + data: { userId: USER_ID, authToken: RESUME_TOKEN, me: { username: 'the-user', roles: ['user'], settings: {} } } + }) + }) + ) as unknown as typeof fetch; +}); + +afterEach(async () => { + sdk.disconnect(); + await flush(); + jest.useRealTimers(); +}); + +describe('foreground resume over the real SDK socket', () => { + it('gets messages flowing again when the socket died silently while away', async () => { + bootApp(); + await openSignedInSocket(); + await subscribeToRoom(ROOM_ID); + const frozen = mockConnections[0]; + expect(roomTopicsOn(frozen)).toEqual(expect.arrayContaining(ROOM_TOPICS)); + + stopAnsweringFrames(frozen); + const pingsBefore = framesOn(frozen, 'ping').length; + dispatched.length = 0; + jest.mocked(loadMissedMessages).mockClear(); + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(framesOn(frozen, 'ping').length).toBeGreaterThan(pingsBefore); + expect(mockConnections).toHaveLength(2); + const reopened = latestConnection(mockConnections); + + expect(loadMissedMessages).not.toHaveBeenCalled(); + + reopened.onopen(); + await settleUntil(() => resumedUser() !== undefined); + + expect(dispatched).toContainEqual(connectSuccess()); + expect(dispatched).toContainEqual(loginRequest({ resume: RESUME_TOKEN }, false)); + expect(loadMissedMessages).toHaveBeenCalledWith({ rid: ROOM_ID }); + expect(roomTopicsOn(reopened)).toEqual(expect.arrayContaining(ROOM_TOPICS)); + expect(resumedUser()).toEqual(expect.objectContaining({ id: USER_ID, token: RESUME_TOKEN, username: 'the-user' })); + }); + + it('lands on a reconnected, still-signed-in app instead of forcing a relaunch after the network dropped while away', async () => { + bootApp(); + await openSignedInSocket(); + const dropped = mockConnections[0]; + + dropped.readyState = CLOSED; + dropped.onclose({ code: 1006 }); + await flush(); + expect(dispatched).toContainEqual(disconnect()); + dispatched.length = 0; + + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + expect(mockConnections).toHaveLength(1); + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(mockConnections.length).toBeGreaterThan(1); + const reopened = latestConnection(mockConnections); + + reopened.onopen(); + await settleUntil(() => resumedUser() !== undefined); + + const connectSuccessAt = dispatched.findIndex(action => typeOf(action) === typeOf(connectSuccess())); + const loginRequestAt = dispatched.findIndex( + action => typeOf(action) === typeOf(loginRequest({ resume: RESUME_TOKEN }, false)) + ); + expect(connectSuccessAt).toBeGreaterThanOrEqual(0); + expect(loginRequestAt).toBeGreaterThan(connectSuccessAt); + expect(dispatched[loginRequestAt]).toEqual(loginRequest({ resume: RESUME_TOKEN }, false)); + expect(resumedUser()).toEqual(expect.objectContaining({ id: USER_ID, token: RESUME_TOKEN, username: 'the-user' })); + + expect(framesOn(reopened, 'connect').length).toBeGreaterThan(0); + }); + + it('keeps the live connection instead of paying for an avoidable reconnect when switching straight back', async () => { + bootApp(); + await openSignedInSocket(); + await subscribeToRoom(ROOM_ID); + const alive = mockConnections[0]; + const pingsBefore = framesOn(alive, 'ping').length; + const connectFramesBefore = framesOn(alive, 'connect').length; + const connectionsBefore = mockConnections.length; + dispatched.length = 0; + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(framesOn(alive, 'ping').length).toBeGreaterThan(pingsBefore); + expect(mockConnections).toHaveLength(connectionsBefore); + expect(framesOn(alive, 'connect')).toHaveLength(connectFramesBefore); + expect(dispatched.map(typeOf)).not.toContain(typeOf(connectSuccess())); + expect(dispatched.map(typeOf)).not.toContain(typeOf(loginRequest({ resume: RESUME_TOKEN }, false))); + }); + + it('leaves the socket alone when the app returns to the foreground before anyone is signed in', async () => { + bootApp(); + await openSocket(); + const frozen = mockConnections[0]; + stopAnsweringFrames(frozen); + dispatched.length = 0; + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(framesOn(frozen, 'ping')).toHaveLength(0); + expect(mockConnections).toHaveLength(1); + expect(dispatched.map(typeOf)).not.toContain(typeOf(loginRequest({ resume: RESUME_TOKEN }, false))); + }); + + it('leaves the socket alone when the app returns to the foreground on the Outside Stack', async () => { + bootApp(); + await openSignedInSocket(); + const frozen = mockConnections[0]; + stopAnsweringFrames(frozen); + store.dispatch(appStart({ root: RootEnum.ROOT_OUTSIDE })); + await flush(); + const pingsBefore = framesOn(frozen, 'ping').length; + dispatched.length = 0; + + store.dispatch({ type: APP_STATE.FOREGROUND }); + await flush(); + await jest.advanceTimersByTimeAsync(RECOVERY_WINDOW); + + expect(framesOn(frozen, 'ping')).toHaveLength(pingsBefore); + expect(mockConnections).toHaveLength(1); + expect(dispatched.map(typeOf)).not.toContain(typeOf(loginRequest({ resume: RESUME_TOKEN }, false))); + }); + + it('saves the local authentication session and goes away when the app leaves for the background', async () => { + bootApp(); + await openSignedInSocket(); + + store.dispatch({ type: APP_STATE.BACKGROUND }); + await flush(); + + expect(saveLastLocalAuthenticationSession).toHaveBeenCalledWith(SERVER); + expect(setUserPresenceAway).toHaveBeenCalled(); + }); + + it('stays quiet on the background transition when nobody is signed in', async () => { + bootApp(); + await openSocket(); + + store.dispatch({ type: APP_STATE.BACKGROUND }); + await flush(); + + expect(saveLastLocalAuthenticationSession).not.toHaveBeenCalled(); + expect(setUserPresenceAway).not.toHaveBeenCalled(); + }); + + it('stays quiet on the background transition while the socket is down', async () => { + bootApp(); + await openSignedInSocket(); + store.dispatch(disconnect()); + await flush(); + + store.dispatch({ type: APP_STATE.BACKGROUND }); + await flush(); + + expect(saveLastLocalAuthenticationSession).not.toHaveBeenCalled(); + expect(setUserPresenceAway).not.toHaveBeenCalled(); + }); + + it('stays quiet on the background transition while on the Outside Stack', async () => { + bootApp(); + await openSignedInSocket(); + store.dispatch(appStart({ root: RootEnum.ROOT_OUTSIDE })); + await flush(); + + store.dispatch({ type: APP_STATE.BACKGROUND }); + await flush(); + + expect(saveLastLocalAuthenticationSession).not.toHaveBeenCalled(); + expect(setUserPresenceAway).not.toHaveBeenCalled(); + }); +}); diff --git a/app/sagas/__tests__/init.fallbackServer.test.ts b/app/sagas/__tests__/init.fallbackServer.test.ts new file mode 100644 index 00000000000..07f689eee03 --- /dev/null +++ b/app/sagas/__tests__/init.fallbackServer.test.ts @@ -0,0 +1,50 @@ +const FALLBACK_SERVER = 'https://fallback.rocket.chat'; +const FALLBACK_VERSION = '7.0.0'; +const LOGGED_OUT_SERVER = 'https://loggedout.rocket.chat'; + +jest.mock('../../lib/database', () => ({ + __esModule: true, + default: { + servers: { + get: () => ({ + query: () => ({ fetch: () => Promise.resolve([{ id: FALLBACK_SERVER, version: FALLBACK_VERSION }]) }) + }) + } + } +})); + +jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ + localAuthenticate: jest.fn() +})); + +import { appInit } from '../../actions/app'; +import { SERVER } from '../../actions/actionsTypes'; +import { CURRENT_SERVER, TOKEN_KEY } from '../../lib/constants/keys'; +import UserPreferences from '../../lib/methods/userPreferences'; +import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; +import initRoot from '../init'; + +describe('init saga — fallback workspace', () => { + beforeEach(() => { + UserPreferences.setString(CURRENT_SERVER, LOGGED_OUT_SERVER); + UserPreferences.removeItem(`${TOKEN_KEY}-${LOGGED_OUT_SERVER}`); + UserPreferences.setString(`${TOKEN_KEY}-${FALLBACK_SERVER}`, 'userId'); + }); + + afterEach(() => { + cancelSagaTasks(); + UserPreferences.removeItem(CURRENT_SERVER); + UserPreferences.removeItem(`${TOKEN_KEY}-${FALLBACK_SERVER}`); + }); + + it('requests the fallback workspace with the version from its own record', async () => { + const { store, dispatchedActions } = createRecordingStore(initRoot); + + store.dispatch(appInit()); + await flushSagaMicrotasks(); + + expect(dispatchedActions.find(action => action.type === SERVER.SELECT_REQUEST)).toEqual( + expect.objectContaining({ server: FALLBACK_SERVER, version: FALLBACK_VERSION }) + ); + }); +}); diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts new file mode 100644 index 00000000000..b23547588be --- /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__/login.forcedLogout.test.ts b/app/sagas/__tests__/login.forcedLogout.test.ts new file mode 100644 index 00000000000..a6fd412b29d --- /dev/null +++ b/app/sagas/__tests__/login.forcedLogout.test.ts @@ -0,0 +1,142 @@ +jest.mock('../../lib/methods/getPermissions', () => ({ + getPermissions: jest.fn() +})); + +jest.mock('../../lib/methods/enterpriseModules', () => ({ + getEnterpriseModules: jest.fn(), + isOmnichannelModuleAvailable: jest.fn(() => false), + isOmnichannelStatusAvailable: jest.fn(() => false), + isVoipModuleAvailable: jest.fn(() => false) +})); + +jest.mock('../../lib/methods/getCustomEmojis', () => ({ + getCustomEmojis: jest.fn() +})); + +jest.mock('../../lib/methods/getRoles', () => ({ + getRoles: jest.fn() +})); + +jest.mock('../../lib/methods/getSlashCommands', () => ({ + getSlashCommands: jest.fn() +})); + +jest.mock('../../lib/methods/getSettings', () => ({ + subscribeSettings: jest.fn() +})); + +jest.mock('../../lib/methods/getUsersPresence', () => ({ + getUserPresence: jest.fn(), + refreshDmUsersPresence: jest.fn(), + subscribeUsersPresence: jest.fn() +})); + +jest.mock('../../lib/services/restApi', () => ({ + getUsersRoles: jest.fn(() => []), + registerPushToken: jest.fn(), + saveUserProfile: jest.fn(), + setUserPresenceAway: jest.fn() +})); + +jest.mock('../../lib/services/connect', () => ({ + disconnect: jest.fn(), + login: jest.fn(), + loginWithPassword: jest.fn() +})); + +jest.mock('../../lib/methods/logout', () => ({ + logout: jest.fn(), + removeServerData: jest.fn(), + removeServerDatabase: jest.fn() +})); + +jest.mock('../../lib/services/voip/MediaSessionInstance', () => ({ + mediaSessionInstance: { init: jest.fn(), reset: jest.fn() } +})); + +jest.mock('../../lib/services/voip/MediaSessionStore', () => ({ + mediaSessionStore: { getCurrentInstance: jest.fn(() => null) } +})); + +jest.mock('../../lib/services/voip/isInActiveVoipCall', () => ({ + isInActiveVoipCall: jest.fn(() => false) +})); + +jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ + localAuthenticate: jest.fn() +})); + +jest.mock('../../lib/methods/helpers/info', () => ({ + showErrorAlert: jest.fn() +})); + +jest.mock('../../lib/services/sdk', () => ({ + __esModule: true, + default: { + current: { client: { host: '' } }, + subscribe: jest.fn() + } +})); + +jest.mock('../../lib/methods/helpers/log', () => ({ + ...jest.requireActual('../../lib/methods/helpers/log'), + __esModule: true, + default: jest.fn() +})); + +const mockServersQuery = { query: jest.fn(() => ({ fetch: jest.fn() })) }; + +jest.mock('../../lib/database', () => ({ + __esModule: true, + default: { + active: { get: jest.fn() }, + servers: { + get: jest.fn(() => mockServersQuery), + write: jest.fn(async (block: () => Promise) => block()) + } + } +})); + +import loginRoot from '../login'; +import { logout } from '../../actions/login'; +import { selectServerSuccess } from '../../actions/server'; +import UserPreferences from '../../lib/methods/userPreferences'; +import { TOKEN_KEY } from '../../lib/constants/keys'; +import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; + +afterEach(cancelSagaTasks); + +const LOGGED_OUT_SERVER = 'https://logged-out.rocket.chat'; +const OTHER_SERVER = 'https://other.rocket.chat'; + +const setRemainingServers = (servers: { id: string }[]): void => { + mockServersQuery.query.mockReturnValue({ fetch: jest.fn(() => Promise.resolve(servers)) }); +}; + +const runForcedLogout = async (): Promise => { + const { store } = createRecordingStore(loginRoot); + store.dispatch(selectServerSuccess({ server: LOGGED_OUT_SERVER, version: '7.0.0', name: 'Logged out' })); + store.dispatch(logout(true, 'Logged_out_by_server')); + await flushSagaMicrotasks(); + return store.getState().server.previousServer; +}; + +describe('login saga — a logout forced by the server', () => { + beforeEach(() => { + UserPreferences.removeItem(`${TOKEN_KEY}-${OTHER_SERVER}`); + jest.clearAllMocks(); + }); + + it('points previousServer at another logged in workspace, so the user can leave NewServerView', async () => { + setRemainingServers([{ id: OTHER_SERVER }]); + UserPreferences.setString(`${TOKEN_KEY}-${OTHER_SERVER}`, 'user-id'); + + expect(await runForcedLogout()).toBe(OTHER_SERVER); + }); + + it('leaves previousServer unset when no other workspace is logged in', async () => { + setRemainingServers([{ id: OTHER_SERVER }]); + + expect(await runForcedLogout()).toBeNull(); + }); +}); diff --git a/app/sagas/__tests__/login.switchCancel.test.ts b/app/sagas/__tests__/login.switchCancel.test.ts new file mode 100644 index 00000000000..85c94e4301f --- /dev/null +++ b/app/sagas/__tests__/login.switchCancel.test.ts @@ -0,0 +1,164 @@ +jest.mock('../../lib/methods/getPermissions', () => ({ + getPermissions: jest.fn() +})); + +jest.mock('../../lib/methods/enterpriseModules', () => ({ + getEnterpriseModules: jest.fn(), + isOmnichannelModuleAvailable: jest.fn(() => false), + isOmnichannelStatusAvailable: jest.fn(() => false), + isVoipModuleAvailable: jest.fn(() => false) +})); + +jest.mock('../../lib/methods/getCustomEmojis', () => ({ + getCustomEmojis: jest.fn() +})); + +jest.mock('../../lib/methods/getRoles', () => ({ + getRoles: jest.fn() +})); + +jest.mock('../../lib/methods/getSlashCommands', () => ({ + getSlashCommands: jest.fn() +})); + +jest.mock('../../lib/methods/getSettings', () => ({ + subscribeSettings: jest.fn() +})); + +jest.mock('../../lib/methods/getUsersPresence', () => ({ + getUserPresence: jest.fn(), + refreshDmUsersPresence: jest.fn(), + subscribeUsersPresence: jest.fn() +})); + +jest.mock('../../lib/services/restApi', () => ({ + getUsersRoles: jest.fn(() => []), + registerPushToken: jest.fn(), + saveUserProfile: jest.fn(), + setUserPresenceAway: jest.fn() +})); + +jest.mock('../../lib/services/connect', () => ({ + disconnect: jest.fn(), + login: jest.fn(), + loginWithPassword: jest.fn() +})); + +jest.mock('../../lib/methods/logout', () => ({ + logout: jest.fn(), + removeServerData: jest.fn(), + removeServerDatabase: jest.fn() +})); + +jest.mock('../../lib/services/voip/MediaSessionInstance', () => ({ + mediaSessionInstance: { init: jest.fn(), reset: jest.fn() } +})); + +jest.mock('../../lib/services/voip/MediaSessionStore', () => ({ + mediaSessionStore: { getCurrentInstance: jest.fn(() => null) } +})); + +jest.mock('../../lib/services/voip/isInActiveVoipCall', () => ({ + isInActiveVoipCall: jest.fn(() => false) +})); + +jest.mock('../../lib/methods/helpers/localAuthentication', () => ({ + localAuthenticate: jest.fn() +})); + +jest.mock('../../lib/services/sdk', () => ({ + __esModule: true, + default: { + current: { client: { host: '' } }, + subscribe: jest.fn() + } +})); + +jest.mock('../../lib/methods/helpers/log', () => ({ + ...jest.requireActual('../../lib/methods/helpers/log'), + __esModule: true, + default: jest.fn() +})); + +jest.mock('../../lib/database', () => ({ + __esModule: true, + default: { + active: { get: jest.fn() }, + servers: { + get: jest.fn(() => ({ + find: jest.fn(() => Promise.reject(new Error('not found'))), + create: jest.fn(), + schema: {} + })), + write: jest.fn(async (block: () => Promise) => block()) + } + } +})); + +import loginRoot from '../login'; +import { loginSuccess } from '../../actions/login'; +import { selectServerRequest, selectServerSuccess } from '../../actions/server'; +import UserPreferences from '../../lib/methods/userPreferences'; +import { CURRENT_SERVER, TOKEN_KEY } from '../../lib/constants/keys'; +import { getPermissions } from '../../lib/methods/getPermissions'; +import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; +import type { RecordingStore } from '../../lib/testUtils/sagaStore'; + +const setupStore = (): RecordingStore => createRecordingStore(loginRoot); + +afterEach(cancelSagaTasks); + +const SERVER_A = 'https://a.rocket.chat'; +const SERVER_B = 'https://b.rocket.chat'; +const USER_B = { id: 'user-b', token: 'token-b', username: 'userb', name: 'User B' }; + +describe('login saga — a workspace switch cancels the login bootstrap', () => { + beforeEach(() => { + UserPreferences.removeItem(`${TOKEN_KEY}-${SERVER_A}`); + UserPreferences.removeItem(`${TOKEN_KEY}-${USER_B.id}`); + UserPreferences.removeItem(CURRENT_SERVER); + jest.clearAllMocks(); + }); + + it('does not persist the credentials when SELECT_REQUEST arrives before the token write', async () => { + let releasePermissions = () => {}; + jest.mocked(getPermissions).mockImplementation( + () => + new Promise(resolve => { + releasePermissions = resolve; + }) as any + ); + + const { store } = setupStore(); + store.dispatch(selectServerSuccess({ server: SERVER_A, version: '7.0.0', name: 'A' })); + + store.dispatch(loginSuccess(USER_B)); + await flushSagaMicrotasks(); + + expect(getPermissions).toHaveBeenCalled(); + + store.dispatch(selectServerRequest(SERVER_B, '7.0.0')); + await flushSagaMicrotasks(); + + releasePermissions(); + await flushSagaMicrotasks(); + + expect(UserPreferences.getString(`${TOKEN_KEY}-${SERVER_A}`)).toBeNull(); + expect(UserPreferences.getString(`${TOKEN_KEY}-${USER_B.id}`)).toBeNull(); + expect(UserPreferences.getString(CURRENT_SERVER)).toBeNull(); + }); + + it('persists the credentials when no switch interrupts the bootstrap', async () => { + jest.mocked(getPermissions).mockResolvedValue(undefined as any); + + const { store } = setupStore(); + store.dispatch(selectServerSuccess({ server: SERVER_A, version: '7.0.0', name: 'A' })); + + store.dispatch(loginSuccess(USER_B)); + await flushSagaMicrotasks(); + + expect(UserPreferences.getString(`${TOKEN_KEY}-${SERVER_A}`)).toBe(USER_B.id); + expect(UserPreferences.getString(`${TOKEN_KEY}-${USER_B.id}`)).toBe(USER_B.token); + expect(UserPreferences.getString(CURRENT_SERVER)).toBe(SERVER_A); + }); +}); diff --git a/app/sagas/__tests__/selectServer.sdkHost.test.ts b/app/sagas/__tests__/selectServer.sdkHost.test.ts new file mode 100644 index 00000000000..6d58e4938ef --- /dev/null +++ b/app/sagas/__tests__/selectServer.sdkHost.test.ts @@ -0,0 +1,73 @@ +jest.unmock('@rocket.chat/sdk'); + +const mockConnections: MockConnection[] = []; + +jest.mock('universal-websocket-client', () => + jest.fn().mockImplementation(() => { + const sdkIntegration = jest.requireActual('../../lib/testUtils/sdkIntegration'); + return new sdkIntegration.MockConnection(mockConnections); + }) +); + +jest.mock('../../lib/methods/helpers/sslPinning', () => ({ + __esModule: true, + default: undefined +})); + +jest.mock('../../lib/services/connect', () => ({ + connect: jest.fn(() => Promise.resolve()), + disconnect: jest.fn(), + getLoginServices: jest.fn(), + getWebsocketInfo: jest.fn(() => Promise.resolve({ success: true })) +})); + +jest.mock('../../lib/methods/helpers/log', () => ({ + ...jest.requireActual('../../lib/methods/helpers/log'), + __esModule: true, + default: jest.fn(), + logServerVersion: jest.fn() +})); + +jest.mock('../../lib/services/twoFactor', () => ({ + twoFactor: jest.fn() +})); + +import selectServerRoot from '../selectServer'; +import { selectServerRequest } from '../../actions/server'; +import { APP, SERVER } from '../../actions/actionsTypes'; +import { RootEnum } from '../../definitions'; +import sdk from '../../lib/services/sdk'; +import { connect } from '../../lib/services/connect'; +import type { MockConnection } from '../../lib/testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../lib/testUtils/sdkIntegration'; +import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; + +const HOST = 'https://open.rocket.chat'; + +describe('selectServer saga — redundant select for the live SDK host', () => { + beforeEach(() => { + mockConnections.length = 0; + }); + + afterEach(() => { + cancelSagaTasks(); + sdk.disconnect(); + }); + + it('reads the live host off the real SDK client and cancels the select without reconnecting', async () => { + sdk.initialize(HOST); + expect(sdk.host).toBe(HOST); + + const { store, dispatchedActions } = createRecordingStore(selectServerRoot); + + store.dispatch(selectServerRequest(HOST, '7.0.0', false)); + await flushSagaMicrotasks(); + + const insideIndex = dispatchedActions.findIndex(action => action.type === APP.START && action.root === RootEnum.ROOT_INSIDE); + const cancelIndex = dispatchedActions.findIndex(action => action.type === SERVER.SELECT_CANCEL); + + expect(insideIndex).toBeGreaterThanOrEqual(0); + expect(cancelIndex).toBeGreaterThan(insideIndex); + expect(connect).not.toHaveBeenCalled(); + }); +}); diff --git a/app/sagas/__tests__/selectServer.test.ts b/app/sagas/__tests__/selectServer.test.ts new file mode 100644 index 00000000000..7302338e5a0 --- /dev/null +++ b/app/sagas/__tests__/selectServer.test.ts @@ -0,0 +1,258 @@ +jest.mock('../../lib/methods/helpers/sslPinning', () => ({ + __esModule: true, + default: undefined +})); + +jest.mock('../../lib/database/services/LoggedUser', () => ({ + getLoggedUserById: jest.fn() +})); + +jest.mock('../../lib/database/services/Server', () => ({ + getServerById: jest.fn() +})); + +jest.mock('../../lib/methods/getServerInfo', () => ({ + getServerInfo: jest.fn() +})); + +jest.mock('../../lib/methods/getSettings', () => ({ + getLoginSettings: jest.fn(), + setSettings: jest.fn() +})); + +jest.mock('../../lib/methods/getCustomEmojis', () => ({ + setCustomEmojis: jest.fn() +})); + +jest.mock('../../lib/methods/getPermissions', () => ({ + setPermissions: jest.fn() +})); + +jest.mock('../../lib/methods/getRoles', () => ({ + setRoles: jest.fn() +})); + +jest.mock('../../lib/methods/enterpriseModules', () => ({ + setEnterpriseModules: jest.fn() +})); + +jest.mock('../../lib/methods/checkSupportedVersions', () => ({ + checkSupportedVersions: jest.fn(() => Promise.resolve({ status: 'supported' })) +})); + +jest.mock('../../lib/services/connect', () => ({ + connect: jest.fn(() => Promise.resolve()), + disconnect: jest.fn(), + getLoginServices: jest.fn(), + getWebsocketInfo: jest.fn(() => Promise.resolve({ success: true })) +})); + +jest.mock('../../lib/services/sdk', () => ({ + __esModule: true, + default: { + current: { client: { host: '' } } + } +})); + +jest.mock('../../lib/methods/helpers/log', () => ({ + ...jest.requireActual('../../lib/methods/helpers/log'), + __esModule: true, + default: jest.fn(), + logServerVersion: jest.fn() +})); + +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'; +import { CURRENT_SERVER, TOKEN_KEY } from '../../lib/constants/keys'; +import { getLoggedUserById } from '../../lib/database/services/LoggedUser'; +import { getServerInfo } from '../../lib/methods/getServerInfo'; +import { connect } from '../../lib/services/connect'; +import { getServerById } from '../../lib/database/services/Server'; +import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../lib/testUtils/sagaStore'; +import type { RecordingStore } from '../../lib/testUtils/sagaStore'; + +const OLD_SERVER = 'https://old.rocket.chat'; +const SERVER_URL = 'https://new.rocket.chat'; +const USER_ID = 'user-new'; +const TOKEN = 'token-new'; + +const keysToClear = [`${TOKEN_KEY}-${SERVER_URL}`, `${TOKEN_KEY}-${USER_ID}`, `${BASIC_AUTH_KEY}-${SERVER_URL}`, CURRENT_SERVER]; + +const setupStore = (): RecordingStore => createRecordingStore(selectServerRoot); + +afterEach(cancelSagaTasks); + +beforeEach(() => { + jest.clearAllMocks(); + keysToClear.forEach(key => UserPreferences.removeItem(key)); + UserPreferences.setString(CURRENT_SERVER, OLD_SERVER); + setBasicAuth(null); +}); + +describe('selectServer saga — resolving the target workspace user', () => { + it('sets the full user from the logged-user record and stamps CURRENT_SERVER', async () => { + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + jest.mocked(getLoggedUserById).mockResolvedValue({ id: USER_ID, token: TOKEN, username: 'new' } as any); + + const { store, dispatchedActions } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().login.user).toMatchObject({ id: USER_ID, token: TOKEN }); + expect(dispatchedActions.map(action => action.type)).not.toContain(SERVER.SELECT_FAILURE); + expect(UserPreferences.getString(CURRENT_SERVER)).toBe(SERVER_URL); + }); + + it('falls back to the token stored under the userId key when there is no record', async () => { + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + UserPreferences.setString(`${TOKEN_KEY}-${USER_ID}`, TOKEN); + jest.mocked(getLoggedUserById).mockResolvedValue(null as any); + + const { store } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().login.user).toEqual({ token: TOKEN }); + expect(UserPreferences.getString(CURRENT_SERVER)).toBe(SERVER_URL); + }); + + it('does not stamp CURRENT_SERVER when the target workspace has no credentials', async () => { + const { store } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(getLoggedUserById).not.toHaveBeenCalled(); + expect(store.getState().login.user).toEqual({}); + expect(UserPreferences.getString(CURRENT_SERVER)).toBe(OLD_SERVER); + }); + + it('leaves CURRENT_SERVER on the previous workspace when the switch fails', async () => { + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + jest.mocked(getLoggedUserById).mockRejectedValue(new Error('database unavailable')); + + const { store, dispatchedActions } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(dispatchedActions.map(action => action.type)).toContain(SERVER.SELECT_FAILURE); + expect(UserPreferences.getString(CURRENT_SERVER)).toBe(OLD_SERVER); + expect(connect).not.toHaveBeenCalled(); + }); + + it('drops the previous workspace basic-auth header when the target has none', async () => { + setBasicAuth('old-workspace-credentials'); + expect(RocketChatSettings.customHeaders).toHaveProperty('Authorization'); + + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + jest.mocked(getLoggedUserById).mockResolvedValue({ id: USER_ID, token: TOKEN } as any); + + const { store } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(RocketChatSettings.customHeaders).not.toHaveProperty('Authorization'); + }); +}); + +describe('selectServer saga — version and name fallback', () => { + beforeEach(() => { + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + jest.mocked(getLoggedUserById).mockResolvedValue({ id: USER_ID, token: TOKEN } as any); + }); + + it('reports the caller-supplied version and the default name', async () => { + const { store, dispatchedActions } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.4.0', false)); + await flushSagaMicrotasks(); + + const success = dispatchedActions.find(action => action.type === SERVER.SELECT_SUCCESS); + expect(success).toMatchObject({ server: SERVER_URL, version: '7.4.0', name: 'Rocket.Chat' }); + expect(getServerInfo).not.toHaveBeenCalled(); + }); + + it('reports a server failure and the caller-supplied version when the server info fetch throws', async () => { + jest.mocked(getServerInfo).mockRejectedValue(new Error('offline')); + + const { store, dispatchedActions } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.4.0', true)); + await flushSagaMicrotasks(); + + const types = dispatchedActions.map(action => action.type); + expect(types).toContain(SERVER.FAILURE); + expect(types).not.toContain(SERVER.SELECT_FAILURE); + + const success = dispatchedActions.find(action => action.type === SERVER.SELECT_SUCCESS); + expect(success).toMatchObject({ server: SERVER_URL, version: '7.4.0', name: 'Rocket.Chat' }); + }); + + it('reports the stored record version when the server info fetch is unsuccessful', async () => { + jest.mocked(getServerInfo).mockResolvedValue({ success: false } as any); + jest.mocked(getServerById).mockResolvedValue({ version: '6.9.0', name: 'Stored A' } as any); + + const { store, dispatchedActions } = setupStore(); + store.dispatch(selectServerRequest(SERVER_URL, '7.4.0', true)); + await flushSagaMicrotasks(); + + const success = dispatchedActions.find(action => action.type === SERVER.SELECT_SUCCESS); + 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 6da1e0bdcb0..636dc297ab2 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'; @@ -168,17 +168,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.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 }) { @@ -252,7 +267,7 @@ const handleOpen = function* handleOpen({ params }) { return; } // if the host is different from the current one, we need to connect to it before navigating - const hostAlreadyConnected = sdk.current?.client?.host === host; + const hostAlreadyConnected = sdk.host === host; if (!hostAlreadyConnected) { yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); yield put(serverInitAdd(server)); diff --git a/app/sagas/init.js b/app/sagas/init.js index d9d6024abe8..b4ee3f72197 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -2,13 +2,13 @@ import { call, put, select, takeLatest } from 'redux-saga/effects'; import RNBootSplash from 'react-native-bootsplash'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { CURRENT_SERVER, TOKEN_KEY } from '../lib/constants/keys'; +import { CURRENT_SERVER } from '../lib/constants/keys'; import UserPreferences from '../lib/methods/userPreferences'; +import { findLoggedInServer, hasStoredLoginToken } from '../lib/methods/loggedInServer'; import { selectServerRequest } from '../actions/server'; import { setAllPreferences } from '../actions/sortPreferences'; import { APP } from '../actions/actionsTypes'; import log from '../lib/methods/helpers/log'; -import database from '../lib/database'; import { localAuthenticate } from '../lib/methods/helpers/localAuthentication'; import { appReady, appStart } from '../actions/app'; import { RootEnum } from '../definitions'; @@ -21,43 +21,41 @@ export const initLocalSettings = function* initLocalSettings() { yield put(setAllPreferences(sortPreferences)); }; +const serverToRestore = function* serverToRestore(server) { + if (!server) { + return null; + } + + if (!hasStoredLoginToken(server)) { + return (yield* findLoggedInServer()) || 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}`); - - 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(); + const restoredServer = yield* serverToRestore(server); - // 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 newServer = servers[i].id; - userId = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); - if (userId) { - return yield put(selectServerRequest(newServer, 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/login.js b/app/sagas/login.js index 6d5f1f8e7d9..4138d3960b9 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -5,12 +5,13 @@ import { Q } from '@nozbe/watermelondb'; import dayjs from '../lib/dayjs'; import * as types from '../actions/actionsTypes'; import { appStart } from '../actions/app'; -import { selectServerRequest, serverFinishAdd } from '../actions/server'; +import { selectServerRequest, serverFinishAdd, serverInitAdd } from '../actions/server'; import { loginFailure, loginSuccess, logout as logoutAction, setUser } from '../actions/login'; import { roomsRequest } from '../actions/rooms'; import log, { events, logEvent } from '../lib/methods/helpers/log'; import I18n, { setLanguage } from '../i18n'; import database from '../lib/database'; +import { findLoggedInServer } from '../lib/methods/loggedInServer'; import EventEmitter from '../lib/methods/helpers/events'; import { inviteLinksRequest } from '../actions/inviteLinks'; import { showErrorAlert } from '../lib/methods/helpers/info'; @@ -28,6 +29,7 @@ import { getIsMasterDetail } from '../lib/hooks/useMasterDetail'; import { getEnterpriseModules, isOmnichannelModuleAvailable, isVoipModuleAvailable } from '../lib/methods/enterpriseModules'; import { getPermissions } from '../lib/methods/getPermissions'; import { getRoles } from '../lib/methods/getRoles'; +import { isTwoFactorCancelled } from '../lib/services/twoFactor'; import { getSlashCommands } from '../lib/methods/getSlashCommands'; import { getUserPresence, refreshDmUsersPresence, subscribeUsersPresence } from '../lib/methods/getUsersPresence'; import { logout, removeServerData, removeServerDatabase } from '../lib/methods/logout'; @@ -123,8 +125,14 @@ const handleLoginRequest = function* handleLoginRequest({ credentials, logoutOnE }); yield put(loginSuccess(result)); if (registerCustomFields) { - const updatedUser = yield call(saveUserProfile, {}, { ...registerCustomFields }); - yield put(setUser({ ...result, ...updatedUser.user })); + try { + const updatedUser = yield call(saveUserProfile, {}, { ...registerCustomFields }); + yield put(setUser({ ...result, ...updatedUser.user })); + } catch (e) { + if (!isTwoFactorCancelled(e)) { + throw e; + } + } } } } catch (e) { @@ -370,8 +378,13 @@ const handleLogout = function* handleLogout({ forcedByServer, message }) { try { yield call(logoutCall, { server }); + const loggedInServer = yield call(findLoggedInServer); + // if the user was logged out by the server if (forcedByServer) { + if (loggedInServer) { + yield put(serverInitAdd(loggedInServer.id)); + } yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); if (message) { showErrorAlert(I18n.t(message), I18n.t('Oops')); @@ -379,23 +392,10 @@ const handleLogout = function* handleLogout({ forcedByServer, message }) { yield delay(300); EventEmitter.emit('NewServer', { server }); } else { - const serversDB = database.servers; - // all servers - const serversCollection = serversDB.get('servers'); - const servers = yield serversCollection.query().fetch(); - - // see if there're other logged in servers and selects first one - if (servers.length > 0) { - for (let i = 0; i < servers.length; i += 1) { - const newServer = servers[i].id; - const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); - if (token) { - yield put(selectServerRequest(newServer, newServer.version)); - return; - } - } + if (loggedInServer) { + yield put(selectServerRequest(loggedInServer.id, loggedInServer.version)); + return; } - // if there's no servers, go outside yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } } catch (e) { @@ -446,23 +446,11 @@ const handleDeleteAccount = function* handleDeleteAccount() { try { yield call(removeServerData, { server }); yield call(removeServerDatabase, { server }); - const serversDB = database.servers; - // all servers - const serversCollection = serversDB.get('servers'); - const servers = yield serversCollection.query().fetch(); - - // see if there're other logged in servers and selects first one - if (servers.length > 0) { - for (let i = 0; i < servers.length; i += 1) { - const newServer = servers[i].id; - const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); - if (token) { - yield put(selectServerRequest(newServer, newServer.version)); - return; - } - } + const loggedInServer = yield call(findLoggedInServer); + if (loggedInServer) { + yield put(selectServerRequest(loggedInServer.id, loggedInServer.version)); + return; } - // if there's no servers, go outside disconnect(); yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } catch (e) { diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fcf..24a8498f366 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -137,7 +137,7 @@ const getServerInfoSaga = function* getServerInfoSaga({ server, raiseError = tru const handleSelectServer = function* handleSelectServer({ server, version, fetchVersion }: ISelectServerAction) { try { - if (sdk.current?.client?.host === server) { + if (sdk.host === server) { yield put(appStart({ root: RootEnum.ROOT_INSIDE })); yield put(selectServerCancel()); return; @@ -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); } }; diff --git a/app/views/AuthenticationWebView.tsx b/app/views/AuthenticationWebView.tsx index 0353eb158fd..e90ca4220d6 100644 --- a/app/views/AuthenticationWebView.tsx +++ b/app/views/AuthenticationWebView.tsx @@ -7,7 +7,7 @@ import parse from 'url-parse'; import ActivityIndicator from '../containers/ActivityIndicator'; import * as HeaderButton from '../containers/Header/components/HeaderButton'; -import { type ICredentials } from '../definitions'; +import { type ILoginCredentials } from '../definitions'; import { userAgent } from '../lib/constants/userAgent'; import { useAppSelector } from '../lib/hooks/useAppSelector'; import { useDebounce } from '../lib/methods/helpers'; @@ -70,9 +70,9 @@ const AuthenticationWebView = ({ route }: AuthenticationWebViewProps) => { const iframeRedirectRegex = new RegExp(`(?=.*(${server}))(?=.*(event|loginToken|token))`, 'g'); // Force 3s delay so the server has time to evaluate the token - const debouncedLogin = useDebounce((params: ICredentials) => login(params), 3000); + const debouncedLogin = useDebounce((params: ILoginCredentials) => login(params), 3000); - const login = async (params: ICredentials) => { + const login = async (params: ILoginCredentials) => { if (loggingRef.current) { return; } diff --git a/app/views/ChangeAvatarView/index.tsx b/app/views/ChangeAvatarView/index.tsx index aa9c2d63348..c09251f891b 100644 --- a/app/views/ChangeAvatarView/index.tsx +++ b/app/views/ChangeAvatarView/index.tsx @@ -29,6 +29,7 @@ import ImagePicker, { type Image } from '../../lib/methods/helpers/ImagePicker/I import { compareServerVersion, isImageURL, useDebounce } from '../../lib/methods/helpers'; import { ControlledFormTextInput } from '../../containers/TextInput'; import { HeaderBackButton } from '../../containers/Header/components/HeaderBackButton'; +import { isTwoFactorCancelled } from '../../lib/services/twoFactor'; enum AvatarStateActions { CHANGE_AVATAR = 'CHANGE_AVATAR', @@ -172,6 +173,9 @@ const ChangeAvatarView = () => { } isDirty.current = false; } catch (e: any) { + if (isTwoFactorCancelled(e)) { + return; + } log(e); return showErrorAlert(e.message, I18n.t('Oops')); } finally { diff --git a/app/views/ChangeAvatarView/submitHelpers.ts b/app/views/ChangeAvatarView/submitHelpers.ts index 1bc13919816..ae850649b6f 100644 --- a/app/views/ChangeAvatarView/submitHelpers.ts +++ b/app/views/ChangeAvatarView/submitHelpers.ts @@ -1,6 +1,10 @@ import I18n from '../../i18n'; +import { isTwoFactorCancelled } from '../../lib/services/twoFactorCancelled'; export const handleError = (e: any, action: string) => { + if (isTwoFactorCancelled(e)) { + throw e; + } if (e.data && e.data.error.includes('[error-too-many-requests]')) { throw new Error(e.data.error); } diff --git a/app/views/ChangePasswordView/index.tsx b/app/views/ChangePasswordView/index.tsx index 7e2ff69b63c..6ddf4b66885 100644 --- a/app/views/ChangePasswordView/index.tsx +++ b/app/views/ChangePasswordView/index.tsx @@ -7,7 +7,7 @@ import { type NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useDispatch } from 'react-redux'; import { sha256 } from 'js-sha256'; -import { twoFactor } from '../../lib/services/twoFactor'; +import { twoFactor, isTwoFactorCancelled } from '../../lib/services/twoFactor'; import { type ProfileStackParamList } from '../../stacks/types'; import { ControlledFormTextInput } from '../../containers/TextInput'; import { useAppSelector } from '../../lib/hooks/useAppSelector'; @@ -146,8 +146,10 @@ const ChangePasswordView = ({ navigation }: IChangePasswordViewProps) => { const code = await twoFactor({ method: e.details.method, invalid: e?.error === 'totp-invalid' && !!twoFactorCode }); setTwoFactorCode(code as any); return handleSetNewPassword(); - } catch { - // cancelled twoFactor modal + } catch (twoFactorError) { + if (isTwoFactorCancelled(twoFactorError)) { + return; + } } } diff --git a/app/views/E2EEToggleRoomView/resetRoomKey.ts b/app/views/E2EEToggleRoomView/resetRoomKey.ts index aac1ed914dc..e31308890f4 100644 --- a/app/views/E2EEToggleRoomView/resetRoomKey.ts +++ b/app/views/E2EEToggleRoomView/resetRoomKey.ts @@ -5,6 +5,7 @@ import { Encryption } from '../../lib/encryption'; import log from '../../lib/methods/helpers/log'; import { showToast } from '../../lib/methods/helpers/showToast'; import { e2eResetRoomKey } from '../../lib/services/restApi'; +import { isTwoFactorCancelled } from '../../lib/services/twoFactor'; export const resetRoomKey = (rid: string) => { Alert.alert( @@ -35,6 +36,9 @@ export const resetRoomKey = (rid: string) => { await e2eResetRoomKey(rid, e2eKey, e2eKeyId); showToast(I18n.t('Encryption_keys_reset')); } catch (e) { + if (isTwoFactorCancelled(e)) { + return; + } log(e); showToast(I18n.t('Encryption_keys_failed')); } diff --git a/app/views/E2EEncryptionSecurityView/ChangePassword.tsx b/app/views/E2EEncryptionSecurityView/ChangePassword.tsx index be82638cf7d..3a705fca81f 100644 --- a/app/views/E2EEncryptionSecurityView/ChangePassword.tsx +++ b/app/views/E2EEncryptionSecurityView/ChangePassword.tsx @@ -8,6 +8,7 @@ import log, { events, logEvent } from '../../lib/methods/helpers/log'; import { FormTextInput } from '../../containers/TextInput'; import Button from '../../containers/Button'; import { Encryption } from '../../lib/encryption'; +import { isTwoFactorCancelled } from '../../lib/services/twoFactor'; import { showConfirmationAlert, showErrorAlert } from '../../lib/methods/helpers/info'; import EventEmitter from '../../lib/methods/helpers/events'; import { LISTENER } from '../../containers/Toast'; @@ -48,6 +49,9 @@ const ChangePassword = () => { newPasswordInputRef?.current?.clear(); newPasswordInputRef?.current?.blur(); } catch (e) { + if (isTwoFactorCancelled(e)) { + return; + } log(e); showErrorAlert(I18n.t('E2E_encryption_change_password_error')); } diff --git a/app/views/E2EEncryptionSecurityView/index.tsx b/app/views/E2EEncryptionSecurityView/index.tsx index b4eed27d9a1..1cb4dcca8e8 100644 --- a/app/views/E2EEncryptionSecurityView/index.tsx +++ b/app/views/E2EEncryptionSecurityView/index.tsx @@ -13,6 +13,7 @@ import Button from '../../containers/Button'; import { logout } from '../../actions/login'; import { showConfirmationAlert, showErrorAlert } from '../../lib/methods/helpers/info'; import { e2eResetOwnKey } from '../../lib/services/restApi'; +import { isTwoFactorCancelled } from '../../lib/services/twoFactor'; import { type SettingsStackParamList } from '../../stacks/types'; import ChangePassword from './ChangePassword'; import { styles } from './styles'; @@ -42,6 +43,9 @@ const E2EEncryptionSecurityView = () => { dispatch(logout()); } } catch (e) { + if (isTwoFactorCancelled(e)) { + return; + } log(e); showErrorAlert(I18n.t('E2E_encryption_reset_error')); } diff --git a/app/views/ForwardLivechatView.tsx b/app/views/ForwardLivechatView.tsx index 23befc85275..57c28a3de48 100644 --- a/app/views/ForwardLivechatView.tsx +++ b/app/views/ForwardLivechatView.tsx @@ -65,7 +65,7 @@ const ForwardLivechatView = (): ReactElement => { term }); if (result.success) { - const parsedUsers = result.items.map(user => ({ label: user.username, value: user._id })); + const parsedUsers = result.items.flatMap(user => (user.username ? [{ label: user.username, value: user._id }] : [])); if (!term) { setUsers(parsedUsers); } diff --git a/app/views/ProfileView/components/DeleteAccountActionSheetContent/ConfirmDeleteAccountContent.tsx b/app/views/ProfileView/components/DeleteAccountActionSheetContent/ConfirmDeleteAccountContent.tsx index dd04c697e63..35855b77e30 100644 --- a/app/views/ProfileView/components/DeleteAccountActionSheetContent/ConfirmDeleteAccountContent.tsx +++ b/app/views/ProfileView/components/DeleteAccountActionSheetContent/ConfirmDeleteAccountContent.tsx @@ -7,6 +7,7 @@ import sharedStyles from '../../../Styles'; import FooterButtons from './FooterButtons'; import AlertText from './AlertText'; import { deleteOwnAccount } from '../../../../lib/services/restApi'; +import { isTwoFactorCancelled } from '../../../../lib/services/twoFactor'; import { deleteAccount } from '../../../../actions/login'; import { CustomIcon } from '../../../../containers/CustomIcon'; import { useTheme } from '../../../../theme'; @@ -55,7 +56,14 @@ const ConfirmDeleteAccountContent = ({ const handleDeleteAccount = async () => { hideActionSheet(); - await deleteOwnAccount(password, true); + try { + await deleteOwnAccount(password, true); + } catch (e) { + if (isTwoFactorCancelled(e)) { + return; + } + throw e; + } dispatch(deleteAccount()); }; diff --git a/app/views/ProfileView/components/DeleteAccountActionSheetContent/index.tsx b/app/views/ProfileView/components/DeleteAccountActionSheetContent/index.tsx index 4ca697b96c7..c36a427724b 100644 --- a/app/views/ProfileView/components/DeleteAccountActionSheetContent/index.tsx +++ b/app/views/ProfileView/components/DeleteAccountActionSheetContent/index.tsx @@ -9,6 +9,7 @@ import sharedStyles from '../../../Styles'; import FooterButtons from './FooterButtons'; import ConfirmDeleteAccountContent from './ConfirmDeleteAccountContent'; import { deleteOwnAccount } from '../../../../lib/services/restApi'; +import { isTwoFactorCancelled } from '../../../../lib/services/twoFactor'; import { deleteAccount } from '../../../../actions/login'; import { CustomIcon } from '../../../../containers/CustomIcon'; import { useTheme } from '../../../../theme'; @@ -63,6 +64,9 @@ const DeleteAccountActionSheetContent = (): ReactElement => { await deleteOwnAccount(sha256(password)); hideActionSheet(); } catch (error: any) { + if (isTwoFactorCancelled(error)) { + return; + } if (error.data.errorType === 'user-last-owner') { const { shouldChangeOwner, shouldBeRemoved } = error.data.details; const { changeOwnerRooms, removedRooms } = getTranslations({ shouldChangeOwner, shouldBeRemoved }); diff --git a/app/views/ProfileView/index.tsx b/app/views/ProfileView/index.tsx index 451fe06adc9..87a4e2d5e2c 100644 --- a/app/views/ProfileView/index.tsx +++ b/app/views/ProfileView/index.tsx @@ -26,7 +26,7 @@ import EventEmitter from '../../lib/methods/helpers/events'; import { events, logEvent } from '../../lib/methods/helpers/log'; import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps'; import { saveUserProfile } from '../../lib/services/restApi'; -import { twoFactor } from '../../lib/services/twoFactor'; +import { twoFactor, isTwoFactorCancelled } from '../../lib/services/twoFactor'; import { getUserSelector } from '../../selectors/login'; import { type ProfileStackParamList } from '../../stacks/types'; import { useTheme } from '../../theme'; @@ -205,7 +205,6 @@ const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { } }; - // Returns true if a 2FA retry was issued and submit should yield to it. const handleTwoFactorChallenge = async (e: any): Promise => { if (e?.error !== 'totp-invalid' || e?.details.method === TwoFactorMethods.PASSWORD) { return false; @@ -215,8 +214,11 @@ const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { setTwoFactorCode(code as any); await submit(); return true; - } catch { - // cancelled twoFactor modal + } catch (twoFactorError) { + if (isTwoFactorCancelled(twoFactorError)) { + resetSavingState(); + return true; + } return false; } }; @@ -249,8 +251,8 @@ const ProfileView = ({ navigation }: IProfileViewProps): ReactElement => { const { email } = getValues(); setFieldErrorsFromResponse(e, email); - const retried = await handleTwoFactorChallenge(e); - if (retried) return; + const handled = await handleTwoFactorChallenge(e); + if (handled) return; logEvent(events.PROFILE_SAVE_CHANGES_F); resetSavingState(); diff --git a/app/views/ProfileView/methods/buildProfileParams.ts b/app/views/ProfileView/methods/buildProfileParams.ts index 8adfa561568..4fa03121447 100644 --- a/app/views/ProfileView/methods/buildProfileParams.ts +++ b/app/views/ProfileView/methods/buildProfileParams.ts @@ -4,7 +4,7 @@ import { type IProfileParams, type IUser } from '../../../definitions'; interface IProfileFormValues { name: string; - username: string; + username?: string; email: string | null; currentPassword: string | null; bio?: string; diff --git a/app/views/ProfileView/methods/logoutOtherLocations.ts b/app/views/ProfileView/methods/logoutOtherLocations.ts index ab561beb248..17b713e999f 100644 --- a/app/views/ProfileView/methods/logoutOtherLocations.ts +++ b/app/views/ProfileView/methods/logoutOtherLocations.ts @@ -4,6 +4,7 @@ import EventEmitter from '../../../lib/methods/helpers/events'; import { showConfirmationAlert } from '../../../lib/methods/helpers'; import { events, logEvent } from '../../../lib/methods/helpers/log'; import { logoutOtherLocations as logoutOtherLocationsService } from '../../../lib/services/restApi'; +import { isTwoFactorCancelled } from '../../../lib/services/twoFactor'; const logoutOtherLocations = () => { logEvent(events.PL_OTHER_LOCATIONS); @@ -14,7 +15,10 @@ const logoutOtherLocations = () => { try { await logoutOtherLocationsService(); EventEmitter.emit(LISTENER, { message: I18n.t('Logged_out_of_other_clients_successfully') }); - } catch { + } catch (e) { + if (isTwoFactorCancelled(e)) { + return; + } logEvent(events.PL_OTHER_LOCATIONS_F); EventEmitter.emit(LISTENER, { message: I18n.t('Logout_failed') }); } diff --git a/app/views/RoomInfoView/index.tsx b/app/views/RoomInfoView/index.tsx index 1e8b14ea772..aebabe5733a 100644 --- a/app/views/RoomInfoView/index.tsx +++ b/app/views/RoomInfoView/index.tsx @@ -229,17 +229,14 @@ const RoomInfoView = (): ReactElement => { setHeader(roomType === SubscriptionType.DIRECT ? false : canEdit); }; - const createDirect = () => - new Promise(async (resolve, reject) => { - // We don't need to create a direct - if (!isEmpty(member)) return resolve(); - try { - const result = await createDirectMessage(roomUser.username); - if (result.success) return resolve({ ...roomUser, rid: result.room.rid }); - } catch (e) { - reject(e); - } - }); + const createDirect = async (): Promise => { + if (!isEmpty(member)) return; + const result = await createDirectMessage(roomUser.username); + if (!result?.success || !result.room?._id) { + throw new Error('Failed to create direct message'); + } + return { ...roomUser, rid: result.room._id }; + }; const handleGoRoom = (r?: ISubscription) => { logEvent(events.RI_GO_ROOM_USER); @@ -268,7 +265,7 @@ const RoomInfoView = (): ReactElement => { } handleGoRoom(r); } catch (e: any) { - emitErrorCreateDirectMessage(e?.data); + emitErrorCreateDirectMessage(e?.data ?? e); } }; diff --git a/app/views/RoomMembersView/helpers.ts b/app/views/RoomMembersView/helpers.ts index 80bd68e6bd3..662bd4a796e 100644 --- a/app/views/RoomMembersView/helpers.ts +++ b/app/views/RoomMembersView/helpers.ts @@ -49,7 +49,7 @@ export const fetchRoomMembersRoles = async (roomType: TRoomType, rid: string, up export const handleMute = async (user: TUserModel, rid: string) => { try { - await toggleMuteUserInRoom(rid, user?.username, user?._id, !user.muted); + await toggleMuteUserInRoom(rid, user.username, user._id, !user.muted); EventEmitter.emit(LISTENER, { message: I18n.t('User_has_been_key', { key: user?.muted ? I18n.t('unmuted') : I18n.t('muted') }) }); @@ -88,6 +88,9 @@ export const handleModerator = async ( }; export const navToDirectMessage = async (item: IUser, isMasterDetail: boolean): Promise => { + if (!item.username) { + return; + } try { const db = database.active; const subsCollection = db.get('subscriptions'); diff --git a/app/views/RoomMembersView/index.tsx b/app/views/RoomMembersView/index.tsx index f448ed61cc7..695639f1298 100644 --- a/app/views/RoomMembersView/index.tsx +++ b/app/views/RoomMembersView/index.tsx @@ -282,7 +282,11 @@ const RoomMembersView = (): ReactElement => { }); }; - const getUserDisplayName = (user: TUserModel) => (useRealName ? user.name : user.username) || user.username; + const getUserDisplayName = (user: TUserModel) => { + const preferred = useRealName ? user.name : user.username; + const fallback = useRealName ? user.username : user.name; + return preferred || fallback || user._id; + }; const onPressUser = (selectedUser: TUserModel) => { const { room, roomRoles, members } = state; diff --git a/app/views/SelectedUsersView/index.tsx b/app/views/SelectedUsersView/index.tsx index 9ffcedcf323..d46cb675167 100644 --- a/app/views/SelectedUsersView/index.tsx +++ b/app/views/SelectedUsersView/index.tsx @@ -94,7 +94,7 @@ const SelectedUsersView = () => { }, [navigation, users.length, maxUsers, buttonText, nextAction]); useEffect(() => { - if (isGroupChat()) { + if (isGroupChat() && user.username) { dispatch(addUser({ _id: user.id, name: user.username, fname: user.name as string })); } }, []); diff --git a/app/views/SetUsernameView.tsx b/app/views/SetUsernameView.tsx index ddfe57a0f4e..25cebd61fba 100644 --- a/app/views/SetUsernameView.tsx +++ b/app/views/SetUsernameView.tsx @@ -20,6 +20,7 @@ import { showErrorAlert } from '../lib/methods/helpers'; import scrollPersistTaps from '../lib/methods/helpers/scrollPersistTaps'; import sharedStyles from './Styles'; import { getUsernameSuggestion, saveUserProfile } from '../lib/services/restApi'; +import { isTwoFactorCancelled } from '../lib/services/twoFactor'; import { useAppSelector } from '../lib/hooks/useAppSelector'; const styles = StyleSheet.create({ @@ -85,7 +86,9 @@ const SetUsernameView = () => { await saveUserProfile({ username, name }); dispatch(loginRequest({ resume: user.token })); } catch (e: any) { - showErrorAlert(e.message, I18n.t('Oops')); + if (!isTwoFactorCancelled(e)) { + showErrorAlert(e.message, I18n.t('Oops')); + } } setLoading(false); }; diff --git a/app/views/ShareView/index.tsx b/app/views/ShareView/index.tsx index 3f8ef84aae8..c5822d5f8f1 100644 --- a/app/views/ShareView/index.tsx +++ b/app/views/ShareView/index.tsx @@ -57,11 +57,7 @@ interface IShareViewProps { navigation: NativeStackNavigationProp; route: RouteProp; theme: TSupportedThemes; - user: { - id: string; - username: string; - token: string; - }; + user: IUser; server: string; serverVersion?: string; FileUpload_MediaTypeWhiteList?: string; diff --git a/package.json b/package.json index ce176ad8ba8..586c1aa1fe9 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "@rocket.chat/media-signaling": "1.0.0-rc.1", "@rocket.chat/message-parser": "0.31.36", "@rocket.chat/mobile-crypto": "RocketChat/rocket.chat-mobile-crypto#main", - "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f", + "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#eef075c8fce25120fda2ef77d69dba842af5d1ba", "@rocket.chat/ui-kit": "^0.39.0", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", diff --git a/patches/@rocket.chat+sdk+1.3.3-mobile.patch b/patches/@rocket.chat+sdk+1.3.3-mobile.patch deleted file mode 100644 index e0e2d0b4465..00000000000 --- a/patches/@rocket.chat+sdk+1.3.3-mobile.patch +++ /dev/null @@ -1,329 +0,0 @@ -diff --git a/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts b/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts -index 19d31ae..068b61e 100644 ---- a/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts -+++ b/node_modules/@rocket.chat/sdk/lib/drivers/ddp.ts -@@ -55,6 +55,7 @@ export class Socket extends EventEmitter { - connection?: WebSocket - session?: string - logger: ILogger -+ reopenPromise?: Promise - - /** Create a websocket handler */ - constructor ( -@@ -82,18 +83,13 @@ export class Socket extends EventEmitter { - } - - /** -- * Open websocket connection, with optional retry interval. -- * Stores connection, setting up handlers for open/close/message events. -- * Resumes login if given token. -+ * Create a new WebSocket, tear down any previous one, and wire up handlers. -+ * Emits 'connecting' exactly once per actual new socket. - */ -- open = (ms: number = this.config.reopen) => { -- return new Promise(async (resolve, reject) => { -+ private createConnection = (): Promise => { -+ return new Promise((resolve, reject) => { - let connection: WebSocket - -- if (this.connected) { -- return resolve() -- } -- - try { - connection = new WebSocket(this.host, null, { headers: settings.customHeaders }) - connection.onerror = reject -@@ -101,14 +97,53 @@ export class Socket extends EventEmitter { - this.logger.error(err) - return reject(err) - } -+ // Tear down the previous connection before replacing it. -+ // Callers only reach here when the existing socket isn't healthy, so -+ // detaching its handlers and closing it stops a stale or still-connecting -+ // socket from later firing onClose and clobbering the live connection. -+ if (this.connection) { -+ try { -+ this.connection.onopen = null as any -+ this.connection.onmessage = null as any -+ this.connection.onerror = null as any -+ this.connection.onclose = null as any -+ this.connection.close(userDisconnectCloseCode) -+ } catch (err) { -+ this.logger.debug(`[ddp] open: previous connection teardown failed: ${(err as Error).message}`) -+ } -+ } - this.connection = connection - this.connection.onmessage = this.onMessage.bind(this) -- this.connection.onclose = this.onClose.bind(this) -+ this.connection.onclose = (ev: any) => this.onClose(ev, connection) // pass closing socket so onClose can compare identity - this.connection.onopen = this.onOpen.bind(this, resolve) - this.emit('connecting') - }) - } - -+ /** -+ * Open websocket connection, with optional retry interval. -+ * Stores connection, setting up handlers for open/close/message events. -+ * Resumes login if given token. -+ */ -+ open = (ms: number = this.config.reopen) => { -+ return new Promise(async (resolve, reject) => { -+ if (this.connected) { -+ return resolve() -+ } -+ -+ if (this.reopenPromise) { -+ return this.reopenPromise.then(() => resolve(this.connection)).catch(reject) -+ } -+ -+ try { -+ await this.createConnection() -+ resolve(this.connection) -+ } catch (err) { -+ reject(err) -+ } -+ }) -+ } -+ - /** Send handshake message to confirm connection, start pinging. */ - onOpen = async (callback: Function) => { - this.lastPing = Date.now() -@@ -125,7 +160,14 @@ export class Socket extends EventEmitter { - } - - /** Emit close event so it can be used for promise resolve in close() */ -- onClose = (e: any) => { -+ onClose = (e: any, closedConnection?: WebSocket) => { -+ // Ignore close events from a socket we've already replaced (an -+ // orphan). Only the current connection's close should flip app state or trigger a -+ // reopen; otherwise a zombie socket's late close clobbers the live connection and -+ // the app falsely shows "Waiting for network". -+ if (closedConnection && closedConnection !== this.connection) { -+ return -+ } - this.emit('close', e) - try { - if (e?.code !== userDisconnectCloseCode) { -@@ -201,6 +243,85 @@ export class Socket extends EventEmitter { - }, this.config.reopen); - } - -+ /** -+ * Force an immediate reconnect. Shared across concurrent callers so only one -+ * new WebSocket is created. Emits 'disconnected' to unblock in-flight sends, -+ * then creates the connection directly so a concurrent open() cannot tear it -+ * down. Unhandled creation errors are swallowed because cleanup already runs -+ * via the open/timeout paths. -+ */ -+ reopenNow = (): Promise => { -+ if (this.reopenPromise) { -+ return this.reopenPromise -+ } -+ -+ this.reopenPromise = new Promise(resolve => { -+ this.openTimeout && clearTimeout(this.openTimeout as any) -+ this.lastPing = 0 -+ this.emit('disconnected') -+ -+ let settled = false -+ const cleanup = () => { -+ if (settled) return -+ settled = true -+ this.off('open', cleanup) -+ if (timeout) clearTimeout(timeout as any) -+ delete this.reopenPromise -+ resolve() -+ } -+ -+ this.once('open', cleanup) -+ -+ this.createConnection().catch(() => {}) -+ -+ const timeout = setTimeout(() => cleanup(), 10000) -+ }) -+ -+ return this.reopenPromise -+ } -+ -+ /** -+ * Bounded liveness check for a socket in the gray zone. Returns true only if -+ * the socket is open and the server answers the ping within the deadline. -+ */ -+ probe = (timeoutMs = 2000): Promise => { -+ return new Promise(resolve => { -+ if (!this.connection || this.connection.readyState !== 1) { -+ return resolve(false) -+ } -+ -+ const lastPingAtStart = this.lastPing -+ -+ let settled = false -+ const cleanup = () => { -+ if (settled) return -+ settled = true -+ this.off('pong', onPong) -+ if (timeout) clearTimeout(timeout as any) -+ } -+ -+ const onPong = () => { -+ if (this.lastPing <= lastPingAtStart) return -+ cleanup() -+ resolve(true) -+ } -+ -+ this.once('pong', onPong) -+ -+ const timeout = setTimeout(() => { -+ cleanup() -+ resolve(false) -+ }, timeoutMs) -+ -+ try { -+ this.connection.send(JSON.stringify({ msg: 'ping' })) -+ } catch { -+ cleanup() -+ resolve(false) -+ } -+ }) -+ } -+ - /** Check if websocket connected and ready. */ - get connected () { - return !!( -@@ -254,7 +375,7 @@ export class Socket extends EventEmitter { - return resolve() - } - this.once(listener, (result: any) => { -- this.off('disconnect', reject) -+ this.off('disconnected', reject) - return (result.error ? reject(result.error) : resolve({ ...(/connect|ping|pong/.test(obj.msg) ? {} : { id }) , ...result })) - }) - }) -@@ -447,7 +568,7 @@ export class DDPDriver extends EventEmitter implements ISocket, IDriver { - ...config, - ...moreConfigs, - host: host.replace(/(^\w+:|^)\/\//, ''), -- timeout: 20000 -+ timeout: 10000 - // reopen: number - // ping: number - // close: number -@@ -503,6 +624,22 @@ export class DDPDriver extends EventEmitter implements ISocket, IDriver { - return this.ddp.checkAndReopen() - } - -+ reopenNow = (): Promise => { -+ return this.ddp.reopenNow() -+ } -+ -+ probe = (timeoutMs?: number): Promise => { -+ return this.ddp.probe(timeoutMs) -+ } -+ -+ get lastPing (): number { -+ return this.ddp.lastPing -+ } -+ -+ get pingInterval (): number { -+ return this.ddp.config.ping -+ } -+ - subscribe = (topic: string, eventname: string, ...args: any[]): Promise => { - this.logger.info(`[DDP driver] Subscribing to ${topic} | ${JSON.stringify(args)}`) - return this.ddp.subscribe(topic, [eventname, { 'useCollection': false, 'args': args }]) -@@ -549,10 +686,70 @@ export class DDPDriver extends EventEmitter implements ISocket, IDriver { - 'uiInteraction', - 'e2ekeyRequest', - 'userData', -- 'video-conference' -+ 'video-conference', -+ 'media-signal', -+ 'media-calls' - ].map(event => this.subscribe(topic, `${this.userId}/${event}`, false))) - } - -+ /** -+ * Re-send the user's media-signal and media-calls subscriptions on the current -+ * socket and resolve when the server acks them with `ready`. This gives the app -+ * an observable readiness signal after a forced reconnect. -+ * -+ * If the subscriptions are not yet present (e.g. immediately after reopenNow), -+ * it polls the socket subscription map until they appear or the timeout expires. -+ */ -+ waitForNotifyUserMediaSubs = (timeoutMs = 8000): Promise => { -+ if (!this.userId) { -+ return Promise.resolve(false) -+ } -+ const topic = 'stream-notify-user' -+ const names = ['media-signal', 'media-calls'] -+ const userId = this.userId -+ const findSubs = () => Object.keys(this.ddp.subscriptions || {}) -+ .map(id => this.ddp.subscriptions[id]) -+ .filter((sub: any) => ( -+ sub && -+ sub.name === topic && -+ names.some(name => sub.params?.[0] === `${userId}/${name}`) -+ )) -+ // Go through the raw socket: the driver's subscribe() wrapper reshapes its -+ // arguments and would drop the subscription id, making the server treat the -+ // resubscribe as a brand new subscription. -+ const resubscribe = (subs: any[]) => Promise.all( -+ subs.map((sub: any) => this.ddp.subscribe(topic, sub.params, undefined, sub.id)) -+ ) -+ .then(() => true) -+ .catch(() => false) -+ return new Promise(resolve => { -+ let settled = false -+ let inFlight = false -+ const finish = (value: boolean) => { -+ if (settled) return -+ settled = true -+ clearInterval(poll) -+ clearTimeout(deadline) -+ resolve(value) -+ } -+ const attempt = () => { -+ if (inFlight) return -+ const subs = findSubs() -+ const allPresent = names.every(name => subs.some((sub: any) => sub.params?.[0] === `${userId}/${name}`)) -+ if (allPresent) { -+ inFlight = true -+ resubscribe(subs).then(value => { -+ inFlight = false -+ finish(value) -+ }) -+ } -+ } -+ const deadline = setTimeout(() => finish(false), timeoutMs) -+ const poll = setInterval(attempt, 100) -+ attempt() -+ }) -+ } -+ - subscribeRoom = (rid: string, ...args: any[]): Promise => { - const topic = 'stream-notify-room' - return Promise.all([ -diff --git a/node_modules/@rocket.chat/sdk/lib/clients/Rocketchat.ts b/node_modules/@rocket.chat/sdk/lib/clients/Rocketchat.ts -index 591c1b9..82165c0 100644 ---- a/node_modules/@rocket.chat/sdk/lib/clients/Rocketchat.ts -+++ b/node_modules/@rocket.chat/sdk/lib/clients/Rocketchat.ts -@@ -6,6 +6,7 @@ export default class RocketChatClient extends ClientRest implements ISocket { - userId: string = '' - logger: ILogger = Logger - socket: Promise -+ ddp?: any - config: any - - constructor ({ logger, allPublic, rooms, integrationId, protocol = Protocols.DDP, ...config }: any) { -@@ -16,7 +17,10 @@ export default class RocketChatClient extends ClientRest implements ISocket { - // this.socket = import(/* webpackChunkName: 'mqtt' */ '../drivers/mqtt').then(({ MQTTDriver }) => new MQTTDriver({ ...config, logger })) - // break - case Protocols.DDP: -- this.socket = import(/* webpackChunkName: 'ddp' */ '../drivers/ddp').then(({ DDPDriver }) => new DDPDriver({ ...config, logger })) -+ this.socket = import(/* webpackChunkName: 'ddp' */ '../drivers/ddp').then(({ DDPDriver }) => { -+ this.ddp = new DDPDriver({ ...config, logger }) -+ return this.ddp -+ }) - break - default: - throw new Error(`Invalid Protocol: ${protocol}, valids: ${Object.keys(Protocols).join()}`) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d411acadbb..977c1d817e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,8 +91,8 @@ importers: specifier: RocketChat/rocket.chat-mobile-crypto#main version: https://codeload.github.com/RocketChat/rocket.chat-mobile-crypto/tar.gz/69a0a250dd7c6ff0808eb659d7202be1cae7fa1c(react-native@0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) '@rocket.chat/sdk': - specifier: RocketChat/Rocket.Chat.js.SDK#b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f + specifier: RocketChat/Rocket.Chat.js.SDK#eef075c8fce25120fda2ef77d69dba842af5d1ba + version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/eef075c8fce25120fda2ef77d69dba842af5d1ba '@rocket.chat/ui-kit': specifier: ^0.39.0 version: 0.39.0(@rocket.chat/icons@0.47.0)(@types/node@25.0.3)(typescript@7.0.2) @@ -2633,10 +2633,9 @@ packages: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f': - resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f} + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/eef075c8fce25120fda2ef77d69dba842af5d1ba': + resolution: {gitHosted: true, integrity: sha512-uIBn017EJiNO9DiWZijJPzT6jxTAkGtlR/iYtC8tavdwVAngdfqs8o6/jhFLpk7zlPpmhO8aBVThrF4mDaEfKQ==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/eef075c8fce25120fda2ef77d69dba842af5d1ba} version: 1.3.3-mobile - engines: {node: '> 8.0.0', npm: '> 5.0.0'} '@rocket.chat/ui-kit@0.39.0': resolution: {integrity: sha512-kdzZsR74DsUNpBQEKyhV8z0jaRsPe4u/VgR9tqHfevP4gQ/dx1GeDtl/LHkw/BADmqTn7nZpwEaelECKhtBNyQ==} @@ -5666,9 +5665,6 @@ packages: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} - lru-cache@4.1.5: - resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5687,10 +5683,6 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} - map-age-cleaner@0.1.3: - resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==} - engines: {node: '>=6'} - map-or-similar@1.5.0: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} @@ -5712,10 +5704,6 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} - mem@4.3.0: - resolution: {integrity: sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==} - engines: {node: '>=6'} - memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -6089,14 +6077,6 @@ packages: vite-plus: optional: true - p-defer@1.0.0: - resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==} - engines: {node: '>=4'} - - p-is-promise@2.1.0: - resolution: {integrity: sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==} - engines: {node: '>=6'} - p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -6303,9 +6283,6 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pseudomap@1.0.2: - resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} - psl@1.15.0: resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} @@ -7791,9 +7768,6 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} - yallist@2.1.2: - resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -10543,11 +10517,9 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.25.9)(@react-native-community/cli@20.0.0(typescript@7.0.2))(@react-native/metro-config@0.81.5(@babel/core@7.25.9))(@types/react@19.1.17)(react@19.1.0) - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f': + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/eef075c8fce25120fda2ef77d69dba842af5d1ba': dependencies: js-sha256: 0.9.0 - lru-cache: 4.1.5 - mem: 4.3.0 tiny-events: 1.0.1 universal-websocket-client: 1.0.3 transitivePeerDependencies: @@ -14014,11 +13986,6 @@ snapshots: lru-cache@11.2.4: {} - lru-cache@4.1.5: - dependencies: - pseudomap: 1.0.2 - yallist: 2.1.2 - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -14037,10 +14004,6 @@ snapshots: dependencies: tmpl: 1.0.5 - map-age-cleaner@0.1.3: - dependencies: - p-defer: 1.0.0 - map-or-similar@1.5.0: {} marky@1.3.0: {} @@ -14058,12 +14021,6 @@ snapshots: media-typer@0.3.0: {} - mem@4.3.0: - dependencies: - map-age-cleaner: 0.1.3 - mimic-fn: 2.1.0 - p-is-promise: 2.1.0 - memoize-one@5.2.1: {} memoizerific@1.11.3: @@ -14558,10 +14515,6 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.75.0 '@oxlint/binding-win32-x64-msvc': 1.75.0 - p-defer@1.0.0: {} - - p-is-promise@2.1.0: {} - p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -14776,8 +14729,6 @@ snapshots: proxy-from-env@1.1.0: {} - pseudomap@1.0.2: {} - psl@1.15.0: dependencies: punycode: 2.3.1 @@ -16449,8 +16400,6 @@ snapshots: y18n@5.0.8: {} - yallist@2.1.2: {} - yallist@3.1.1: {} yallist@4.0.0: {}