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: {}