From 6def48c7efe6eeb27bd5e2a909961aee74eab4bf Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 14 Aug 2026 18:37:46 -0300 Subject: [PATCH 01/35] chore: update @rocket.chat/sdk to mobile branch HEAD Bump the SDK from b6d2b3f to 1e16344. The mobile fork now ships the reconnect/probe/media-subscription fixes the app previously applied as a patch, so drop @rocket.chat+sdk+1.3.3-mobile.patch. Declare the tiny-events module the SDK source depends on, and update the DDP driver tests to the SDK's new error contract. --- app/externalModules.d.ts | 9 + app/lib/services/ddpSocket.test.ts | 18 +- package.json | 2 +- patches/@rocket.chat+sdk+1.3.3-mobile.patch | 329 -------------------- pnpm-lock.yaml | 61 +--- 5 files changed, 19 insertions(+), 400 deletions(-) delete mode 100644 patches/@rocket.chat+sdk+1.3.3-mobile.patch diff --git a/app/externalModules.d.ts b/app/externalModules.d.ts index 1b220ba2dd3..344d3c0b885 100644 --- a/app/externalModules.d.ts +++ b/app/externalModules.d.ts @@ -7,3 +7,12 @@ declare module '@env' { export const RUNNING_E2E_TESTS: string; export const USE_STORYBOOK: string; } +declare module 'tiny-events' { + export class EventEmitter { + _listeners: { [type: string]: Function[] }; + on(event: string, listener: Function): EventEmitter; + once(event: string, listener: Function): EventEmitter; + off(event?: string, listener?: Function): EventEmitter; + emit(event: string, ...args: any[]): EventEmitter; + } +} diff --git a/app/lib/services/ddpSocket.test.ts b/app/lib/services/ddpSocket.test.ts index 44acd6e7320..801785c3603 100644 --- a/app/lib/services/ddpSocket.test.ts +++ b/app/lib/services/ddpSocket.test.ts @@ -98,19 +98,6 @@ describe('Socket.probe', () => { 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', () => { @@ -157,7 +144,10 @@ describe('Socket.reopenNow', () => { const reopenPromise = socket.reopenNow(); expect(disconnectedListener).toHaveBeenCalledTimes(1); - await expect(sendPromise).rejects.toBeUndefined(); + await expect(sendPromise).rejects.toMatchObject({ + message: '[ddp] connection reopened before the response arrived', + id: 'ddp-0' + }); mockConnections[0].onopen(); await reopenPromise; diff --git a/package.json b/package.json index ce176ad8ba8..9de88f72b09 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#1e1634472a96822241e55b14515e2a4857f0dba9", "@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..1c50475cf65 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#1e1634472a96822241e55b14515e2a4857f0dba9 + version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1e1634472a96822241e55b14515e2a4857f0dba9 '@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/1e1634472a96822241e55b14515e2a4857f0dba9': + resolution: {gitHosted: true, integrity: sha512-HJRLEHTY87unFBJ9PGIChLHML+ll8NAB4JRuOso3OhGLYKMXLiLz6GmHl4Kti78sZ4vGxIxAGN2BNGKFL8SdjQ==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1e1634472a96822241e55b14515e2a4857f0dba9} 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/1e1634472a96822241e55b14515e2a4857f0dba9': 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: {} From 34e6d0feced5151d9d2d44d40fa8eea8f6303fa7 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 14 Aug 2026 21:18:45 -0300 Subject: [PATCH 02/35] test: integration-test the app against the real SDK lib Drive connect/login/streams, RoomSubscription, socket recovery, and accept-after-reconnect through the real @rocket.chat/sdk DDPDriver/Socket/REST client, replacing SDK-internal unit tests. Rewrite the SDK's dynamic import to a require in the test env only. --- .../roomSubscription.integration.test.ts | 274 ++++++++++ .../__tests__/connect.integration.test.ts | 505 ++++++++++++++++++ .../socketHealth.integration.test.ts | 76 +++ app/lib/services/ddpSocket.test.ts | 355 ------------ .../acceptNativeCall.sdk.integration.test.ts | 227 ++++++++ babel.config.js | 6 + 6 files changed, 1088 insertions(+), 355 deletions(-) create mode 100644 app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts create mode 100644 app/lib/services/__tests__/connect.integration.test.ts delete mode 100644 app/lib/services/ddpSocket.test.ts create mode 100644 app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts 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..e0e764071e1 --- /dev/null +++ b/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts @@ -0,0 +1,274 @@ +import type { Store } from 'redux'; + +// The repo auto-applies `__mocks__/@rocket.chat/sdk.js` (an empty class). Drive the real SDK. +jest.unmock('@rocket.chat/sdk'); + +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; method?: 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] }) })); + } else if (message.msg === 'unsub') { + setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'nosub', id: message.id }) })); + } + }), + close: jest.fn(), + readyState: 1, + onopen: jest.fn(), + onmessage: jest.fn(), + onerror: jest.fn(), + onclose: jest.fn() + }; + mockConnections.push(connection); + return connection; + }) +); + +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 type { IApplicationState } from '../../../../definitions'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const database = require('../../../database').default as { + active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; +}; + +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?: unknown[]; +} + +const mockConnections: MockConnection[] = []; + +function makeReduxStore() { + const listeners = new Set<() => void>(); + const state = { + login: { user: null as Record | null, isAuthenticated: false }, + server: { version: '5.0.0' }, + settings: {} as Record, + room: { subscribedRoom: 'room-rid' as string | null } + }; + return { + state, + store: { + getState: () => state, + dispatch: jest.fn(), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + } + } as unknown as Store + }; +} + +async function flush(turns = 10) { + for (let i = 0; i < turns; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(0); + } +} + +function framesOn(connection: MockConnection, msg: string) { + return connection.send.mock.calls + .map(([data]: [string]) => JSON.parse(data) as WireFrame) + .filter(message => message.msg === msg); +} + +function receiveFrame(connection: MockConnection, frame: Record) { + connection.onmessage({ data: JSON.stringify(frame) }); +} + +function makeCollection(name: string) { + return { + name, + find: jest.fn(), + query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), + create: jest.fn(), + prepareCreate: jest.fn((fn: (record: Record) => void) => { + const record = { _raw: { id: '' }, subscription: { id: '' } }; + fn(record); + return record; + }), + schema: { columnArray: [] } + }; +} + +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(); +}); + +/** Build a real SDK client, open its socket, and settle the handshake. */ +async function connectDriver() { + sdk.initialize('https://example.com'); + const connectPromise = (sdk.current as unknown as { connect(): Promise }).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()); + + // A frame on the room stream after unsubscribe no longer reaches the handler. + 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/services/__tests__/connect.integration.test.ts b/app/lib/services/__tests__/connect.integration.test.ts new file mode 100644 index 00000000000..78bbbd04ee7 --- /dev/null +++ b/app/lib/services/__tests__/connect.integration.test.ts @@ -0,0 +1,505 @@ +import type { Store } from 'redux'; + +// The repo auto-applies `__mocks__/@rocket.chat/sdk.js` (an empty class). Drive the real SDK. +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 type { IApplicationState } from '../../../definitions'; + +interface MockConnection { + send: jest.Mock; + close: jest.Mock; + readyState: number; + onopen: () => void; + onmessage: (event: { data: string }) => void; + onerror: () => void; + onclose: (event?: { code?: number }) => void; +} + +interface WireFrame { + msg: string; + id?: string; + name?: string; + method?: string; + params?: unknown[]; +} + +const mockConnections: MockConnection[] = []; + +const DDP_LOGIN_RESULT = { id: 'user-id', token: 'auth-token' }; + +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; method?: 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] }) })); + } else if (message.msg === 'method' && message.method === 'login') { + setImmediate(() => + connection.onmessage({ data: JSON.stringify({ msg: 'result', id: message.id, result: DDP_LOGIN_RESULT }) }) + ); + } + }), + close: jest.fn(), + readyState: 1, + onopen: jest.fn(), + onmessage: jest.fn(), + onerror: jest.fn(), + onclose: jest.fn() + }; + mockConnections.push(connection); + return connection; + }) +); + +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() + } + } +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +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 +}; + +function makeReduxStore() { + const listeners = new Set<() => void>(); + const state = { + meteor: { connected: false }, + login: { user: null as Record | null, isAuthenticated: false }, + server: { version: '5.0.0' }, + settings: {} as Record, + room: { subscribedRoom: null as string | 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 } + }; +} + +async function flush(turns = 10) { + for (let i = 0; i < turns; i++) { + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(0); + } +} + +function framesOn(connection: MockConnection, msg: string) { + return connection.send.mock.calls + .map(([data]: [string]) => JSON.parse(data) as WireFrame) + .filter(message => message.msg === msg); +} + +function receiveFrame(connection: MockConnection, frame: Record) { + connection.onmessage({ data: JSON.stringify(frame) }); +} + +function makeCollection(name: string) { + return { + name, + find: jest.fn(), + query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), + create: jest.fn(), + prepareCreate: jest.fn(), + schema: {} + }; +} + +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(); +}); + +/** Connect to a server, resolve the SDK dynamic import, then drive the handshake to completion. */ +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(); + + // A connected frame on the discarded socket no longer reaches the store. + 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..1ba9dbdd4c7 100644 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ b/app/lib/services/__tests__/socketHealth.integration.test.ts @@ -256,4 +256,80 @@ describe('recoverSocket against the real patched socket', () => { 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 () => { + // A fresh `lastPing` proves nothing once the transport itself is closed. + mockConnections[0].readyState = 3; + + 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'); + + // No media subs are registered yet, so the wait polls instead of resolving. + const resubscribed = driver.waitForNotifyUserMediaSubs(1000); + await jest.advanceTimersByTimeAsync(100); + expect(framesOn(mockConnections[1], 'sub')).toHaveLength(0); + + // The subs appear after the wait is already polling. + addMediaSubs(driver); + 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); + + const recovery = recoverSocket(); + await jest.advanceTimersByTimeAsync(0); + mockConnections[1].onopen(); + await jest.advanceTimersByTimeAsync(0); + await expect(recovery).resolves.toBe('reopened'); + + // The new socket swallows the re-sub frames, so the ack never arrives. + mockConnections[1].send.mockImplementation(() => undefined); + + 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/ddpSocket.test.ts b/app/lib/services/ddpSocket.test.ts deleted file mode 100644 index 801785c3603..00000000000 --- a/app/lib/services/ddpSocket.test.ts +++ /dev/null @@ -1,355 +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); - }); -}); - -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.toMatchObject({ - message: '[ddp] connection reopened before the response arrived', - id: 'ddp-0' - }); - - 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/voip/acceptNativeCall.sdk.integration.test.ts b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts new file mode 100644 index 00000000000..6af25c87050 --- /dev/null +++ b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts @@ -0,0 +1,227 @@ +import sdk from '../sdk'; +import { acceptNativeCallWithReadiness } from './acceptNativeCall'; +import { useCallStore } from './useCallStore'; +import { terminateNativeCall } from './terminateNativeCall'; +import { waitForLoginReady } from '../waitForLoginReady'; + +// 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; +}; + +jest.mock('../sdk', () => ({ + __esModule: true, + default: { current: undefined } +})); + +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() +})); + +interface MockConnection { + send: jest.Mock; + close: jest.Mock; + readyState: number; + onopen: () => void; + onmessage: (event: { data: string }) => void; + onerror: () => void; + onclose: () => void; +} + +interface PatchedDriver { + userId: string; + pingInterval: number; + reopenNow(): Promise; + ddp: { + lastPing: number; + pingTimeout?: ReturnType; + openTimeout?: ReturnType; + open(): Promise; + subscriptions: Record; + }; +} + +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] }) })); + } else if (message.msg === 'unsub') { + setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'nosub', id: 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 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; + +const logger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }; + +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 + }; +} + +/** 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; +} + +let driver: PatchedDriver; + +beforeEach(async () => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockConnections.length = 0; + driver = await buildConnectedDriver(); + (sdk as unknown as { current: { ddp: PatchedDriver } }).current = { ddp: driver }; + mockWaitForLoginReady.mockResolvedValue(true); + mockGetState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); +}); + +afterEach(() => { + if (driver.ddp.pingTimeout) clearTimeout(driver.ddp.pingTimeout); + if (driver.ddp.openTimeout) clearTimeout(driver.ddp.openTimeout); + jest.useRealTimers(); +}); + +describe('acceptNativeCallWithReadiness against the real patched 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); + + 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); + + const accept = acceptNativeCallWithReadiness(CALL_ID, mediaSession); + await jest.advanceTimersByTimeAsync(0); + mockConnections[1].onopen(); + + // Swallow the re-sub frames before the reopen handshake settles, so the + // media ack never arrives while the connect handshake still completes. + mockConnections[1].send.mockImplementation(() => undefined); + 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); + + // No media subs registered yet, so the wait polls instead of resolving. + await jest.advanceTimersByTimeAsync(100); + + // The subs appear after the poll is already underway. + addMediaSubs(driver); + await jest.advanceTimersByTimeAsync(200); + await accept; + + expect(mediaSession.applyRestStateSignals).toHaveBeenCalledTimes(1); + expect(mediaSession.answerCall).toHaveBeenCalledWith(CALL_ID); + expect(mockTerminateNativeCall).not.toHaveBeenCalled(); + }); +}); diff --git a/babel.config.js b/babel.config.js index 57933130d6b..960d454b8da 100644 --- a/babel.config.js +++ b/babel.config.js @@ -22,6 +22,12 @@ module.exports = { } ], env: { + // Jest's CommonJS runtime rejects the SDK's dynamic `import('../drivers/ddp')` + // as long as babel-preset-expo (caller "metro") leaves it native. Rewrite it to + // a synchronous require in the test env only. + test: { + plugins: ['@babel/plugin-transform-dynamic-import'] + }, production: { plugins: ['transform-remove-console'] } From 37925d94f8d80776e4791bef33bb96af9f46a023 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 17 Aug 2026 17:57:03 -0300 Subject: [PATCH 03/35] chore: bump @rocket.chat/sdk to mobile HEAD 4202408 Upstream renamed DDPDriver (lib/drivers/ddp) to Driver (lib/drivers/driver); update the SDK integration tests to match. --- .../__tests__/socketHealth.integration.test.ts | 8 ++++---- .../voip/acceptNativeCall.sdk.integration.test.ts | 8 ++++---- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/lib/services/__tests__/socketHealth.integration.test.ts b/app/lib/services/__tests__/socketHealth.integration.test.ts index 1ba9dbdd4c7..94dc10127b7 100644 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ b/app/lib/services/__tests__/socketHealth.integration.test.ts @@ -2,8 +2,8 @@ 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; +const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { + Driver: new (options: { host: string; logger: unknown }) => PatchedDriver; }; interface MockConnection { @@ -75,9 +75,9 @@ const PING_INTERVAL = 10000; 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. */ +/** Real patched Driver over a mocked WebSocket, connected and logged in. */ async function buildConnectedDriver() { - const driver = new DDPDriver({ host: 'localhost:3000', logger }); + const driver = new Driver({ host: 'localhost:3000', logger }); driver.userId = USER_ID; const openPromise = driver.ddp.open(); mockConnections[0].onopen(); diff --git a/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts index 6af25c87050..760de1da98e 100644 --- a/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts +++ b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts @@ -5,8 +5,8 @@ import { terminateNativeCall } from './terminateNativeCall'; import { waitForLoginReady } from '../waitForLoginReady'; // 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; +const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { + Driver: new (options: { host: string; logger: unknown }) => PatchedDriver; }; jest.mock('../sdk', () => ({ @@ -110,9 +110,9 @@ function makeMediaSession(overrides: Partial = {}): IMediaSession }; } -/** Real patched DDPDriver over a mocked WebSocket, connected and logged in. */ +/** Real patched Driver over a mocked WebSocket, connected and logged in. */ async function buildConnectedDriver() { - const driver = new DDPDriver({ host: 'localhost:3000', logger }); + const driver = new Driver({ host: 'localhost:3000', logger }); driver.userId = USER_ID; const openPromise = driver.ddp.open(); mockConnections[0].onopen(); diff --git a/package.json b/package.json index 9de88f72b09..62be9de561c 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#1e1634472a96822241e55b14515e2a4857f0dba9", + "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#4202408370108bd0f084e798fb5c4f3a48e9db1c", "@rocket.chat/ui-kit": "^0.39.0", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c50475cf65..673def3c60d 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#1e1634472a96822241e55b14515e2a4857f0dba9 - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1e1634472a96822241e55b14515e2a4857f0dba9 + specifier: RocketChat/Rocket.Chat.js.SDK#4202408370108bd0f084e798fb5c4f3a48e9db1c + version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4202408370108bd0f084e798fb5c4f3a48e9db1c '@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,8 +2633,8 @@ packages: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1e1634472a96822241e55b14515e2a4857f0dba9': - resolution: {gitHosted: true, integrity: sha512-HJRLEHTY87unFBJ9PGIChLHML+ll8NAB4JRuOso3OhGLYKMXLiLz6GmHl4Kti78sZ4vGxIxAGN2BNGKFL8SdjQ==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1e1634472a96822241e55b14515e2a4857f0dba9} + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4202408370108bd0f084e798fb5c4f3a48e9db1c': + resolution: {gitHosted: true, integrity: sha512-byVmE1xTYZwm1eTUSwMsQIPXAGGL/rhljDh854aEQl2Cp3x/vTzjG+OFgc9TZEvQAP4Tfua8Y4bBffckXzHoEg==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4202408370108bd0f084e798fb5c4f3a48e9db1c} version: 1.3.3-mobile '@rocket.chat/ui-kit@0.39.0': @@ -10517,7 +10517,7 @@ 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/1e1634472a96822241e55b14515e2a4857f0dba9': + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4202408370108bd0f084e798fb5c4f3a48e9db1c': dependencies: js-sha256: 0.9.0 tiny-events: 1.0.1 From e32cca68986b1d8480f59c78fda0d93fc00fadd3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 18 Aug 2026 10:27:35 -0300 Subject: [PATCH 04/35] chore: bump @rocket.chat/sdk to mobile HEAD and drop local module shims Remove the '@rocket.chat/sdk' and 'tiny-events' declare-module shims from externalModules.d.ts now that the SDK ships its own types, and align the app with the real SDK typings. --- app/definitions/rocketchatSdkClient.d.ts | 7 ++++ app/externalModules.d.ts | 10 ------ app/lib/methods/actions.ts | 2 +- app/lib/methods/getSettings.ts | 3 +- .../helpers/fileUpload/Upload.android.ts | 12 +++---- app/lib/methods/helpers/fileUpload/Upload.ts | 10 ++---- .../methods/helpers/fileUpload/definitions.ts | 2 ++ app/lib/methods/helpers/fileUpload/index.ts | 4 +-- app/lib/methods/logout.ts | 3 +- app/lib/methods/subscriptions/room.ts | 6 ++-- app/lib/services/connect.ts | 17 +++++----- app/lib/services/sdk.ts | 33 +++++++++++-------- app/lib/services/voip/MediaSessionInstance.ts | 6 ++-- package.json | 2 +- pnpm-lock.yaml | 10 +++--- 15 files changed, 64 insertions(+), 63 deletions(-) create mode 100644 app/definitions/rocketchatSdkClient.d.ts diff --git a/app/definitions/rocketchatSdkClient.d.ts b/app/definitions/rocketchatSdkClient.d.ts new file mode 100644 index 00000000000..1c8ef292835 --- /dev/null +++ b/app/definitions/rocketchatSdkClient.d.ts @@ -0,0 +1,7 @@ +import '@rocket.chat/sdk/lib/api/api'; + +declare module '@rocket.chat/sdk/lib/api/api' { + interface IClient { + host: string; + } +} diff --git a/app/externalModules.d.ts b/app/externalModules.d.ts index 344d3c0b885..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'; @@ -7,12 +6,3 @@ declare module '@env' { export const RUNNING_E2E_TESTS: string; export const USE_STORYBOOK: string; } -declare module 'tiny-events' { - export class EventEmitter { - _listeners: { [type: string]: Function[] }; - on(event: string, listener: Function): EventEmitter; - once(event: string, listener: Function): EventEmitter; - off(event?: string, listener?: Function): EventEmitter; - emit(event: string, ...args: any[]): EventEmitter; - } -} diff --git a/app/lib/methods/actions.ts b/app/lib/methods/actions.ts index 7945f9ad7a5..3a6f5c80730 100644 --- a/app/lib/methods/actions.ts +++ b/app/lib/methods/actions.ts @@ -108,7 +108,7 @@ export async function triggerAction({ const payload = rest.payload ?? rest.value; try { - const { userId, authToken } = sdk.current.currentLogin; + const { userId, authToken } = sdk.current.currentLogin!; const { host } = sdk.current.client; const interaction = toUserInteraction({ type, diff --git a/app/lib/methods/getSettings.ts b/app/lib/methods/getSettings.ts index e1f3a7d733b..5960f55950e 100644 --- a/app/lib/methods/getSettings.ts +++ b/app/lib/methods/getSettings.ts @@ -1,4 +1,5 @@ import { Q } from '@nozbe/watermelondb'; +import { type ISubscription } from '@rocket.chat/sdk/interfaces'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { addSettings, clearSettings } from '../../actions/settings'; @@ -143,7 +144,7 @@ export async function setSettings(): Promise { reduxStore.dispatch(addSettings(parseSettings(parsed.slice(0, parsed.length)))); } -export function subscribeSettings(): void { +export function subscribeSettings(): Promise { return sdk.subscribe('stream-notify-all', 'public-settings-changed'); } diff --git a/app/lib/methods/helpers/fileUpload/Upload.android.ts b/app/lib/methods/helpers/fileUpload/Upload.android.ts index 975ae4f7a66..6a9aefa6102 100644 --- a/app/lib/methods/helpers/fileUpload/Upload.android.ts +++ b/app/lib/methods/helpers/fileUpload/Upload.android.ts @@ -1,7 +1,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import { type TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; -import { type IFormData } from './definitions'; +import { type IFormData, type TUploadHeaders } from './definitions'; export class Upload { private uploadUrl: string; @@ -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; @@ -26,13 +26,9 @@ export class Upload { this.isCancelled = false; } - public setupRequest( - url: string, - headers: { [key: string]: string }, - progressCallback?: (loaded: number, total: number) => void - ): void { + public setupRequest(url: string, headers: TUploadHeaders, progressCallback?: (loaded: number, total: number) => void): void { this.uploadUrl = url; - this.headers = headers; + this.headers = headers as Record; this.progressCallback = progressCallback; } diff --git a/app/lib/methods/helpers/fileUpload/Upload.ts b/app/lib/methods/helpers/fileUpload/Upload.ts index b37656a8221..2d8e213b45f 100644 --- a/app/lib/methods/helpers/fileUpload/Upload.ts +++ b/app/lib/methods/helpers/fileUpload/Upload.ts @@ -1,5 +1,5 @@ import { type TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; -import { type IFormData } from './definitions'; +import { type IFormData, type TUploadHeaders } from './definitions'; export class Upload { private xhr: XMLHttpRequest; @@ -12,14 +12,10 @@ export class Upload { this.isCancelled = false; } - public setupRequest( - url: string, - headers: { [key: string]: string }, - progressCallback?: (loaded: number, total: number) => void - ): void { + public setupRequest(url: string, headers: TUploadHeaders, progressCallback?: (loaded: number, total: number) => void): void { this.xhr.open('POST', url); Object.keys(headers).forEach(key => { - this.xhr.setRequestHeader(key, headers[key]); + this.xhr.setRequestHeader(key, headers[key] as string); }); if (progressCallback) { 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.ts b/app/lib/methods/helpers/fileUpload/index.ts index 413b0c7db9d..7322ec2ba93 100644 --- a/app/lib/methods/helpers/fileUpload/index.ts +++ b/app/lib/methods/helpers/fileUpload/index.ts @@ -1,13 +1,13 @@ import { type TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; import { Upload } from './Upload'; -import { type IFormData } from './definitions'; +import { type IFormData, type TUploadHeaders } from './definitions'; class FileUpload { private upload: Upload; constructor( url: string, - headers: { [key: string]: string }, + headers: TUploadHeaders, data: IFormData[], progressCallback?: (loaded: number, total: number) => void ) { diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index d27819472d4..69d76f66522 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -1,4 +1,5 @@ import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; +import { type ICredentials as ISdkCredentials } from '@rocket.chat/sdk/interfaces'; import type Model from '@nozbe/watermelondb/Model'; import { getDeviceToken } from '../notifications'; @@ -68,7 +69,7 @@ export async function removeServer({ server }: { server: string }): 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/services/connect.ts b/app/lib/services/connect.ts index 1f9043efd1f..043c7806e1a 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -1,4 +1,5 @@ import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; +import { type ICredentials as ISdkCredentials } from '@rocket.chat/sdk/interfaces'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { InteractionManager } from 'react-native'; import { Q } from '@nozbe/watermelondb'; @@ -15,7 +16,7 @@ import sdk 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 ICredentials, type ILoggedUser, type ILoginResultFromServer, STATUSES } from '../../definitions'; import { connectRequest, connectSuccess, disconnect as disconnectAction } from '../../actions/connect'; import { updatePermission } from '../../actions/permissions'; import EventEmitter from '../methods/helpers/events'; @@ -105,7 +106,7 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr getSettings(); sdk.current - .connect() + .connect({}) .then(() => { console.log('connected'); }) @@ -199,7 +200,7 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr ); // RC 4.1 - sdk.current.onStreamData('stream-user-presence', (ddpMessage: { fields: { args?: any; uid?: any } }) => { + sdk.current.onStreamData('stream-user-presence', (ddpMessage: any) => { const userStatus = ddpMessage.fields.args[0]; const { uid } = ddpMessage.fields; const [, status, statusText, statusSource, statusExpiresAtRaw] = userStatus; @@ -318,15 +319,15 @@ function stopListener(listener: any): boolean { async function login(credentials: ICredentials): Promise { // RC 0.64.0 - await sdk.current.login(credentials); + await sdk.current.login(credentials as unknown as ISdkCredentials); const serverVersion = store.getState().server.version; - const result = sdk.current.currentLogin?.result; + const result = sdk.current.currentLogin?.result as unknown as ILoginResultFromServer | undefined; let enableMessageParserEarlyAdoption = true; let showMessageInMainThread = false; if (compareServerVersion(serverVersion, 'lowerThan', '5.0.0')) { - enableMessageParserEarlyAdoption = result.me.settings?.preferences?.enableMessageParserEarlyAdoption ?? true; - showMessageInMainThread = result.me.settings?.preferences?.showMessageInMainThread ?? true; + enableMessageParserEarlyAdoption = result!.me.settings?.preferences?.enableMessageParserEarlyAdoption ?? true; + showMessageInMainThread = result!.me.settings?.preferences?.showMessageInMainThread ?? true; } if (result) { @@ -453,7 +454,7 @@ async function getWebsocketInfo({ const websocketSdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: isSsl(server) }); try { - await websocketSdk.connect(); + await websocketSdk.connect({}); } catch (err: any) { if (err.message && err.message.includes('400')) { return { diff --git a/app/lib/services/sdk.ts b/app/lib/services/sdk.ts index d8be776f45a..2c80b909264 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 ISubscription } from '@rocket.chat/sdk/interfaces'; import EJSON from 'ejson'; import isEmpty from 'lodash/isEmpty'; @@ -14,11 +15,17 @@ import { } from '../../definitions/rest/helpers'; import { compareServerVersion, random } from '../methods/helpers'; +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 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 }); } @@ -30,8 +37,8 @@ class Sdk { return this.sdk; } - get current() { - return this.sdk; + get current(): Rocketchat { + return this.sdk as Rocketchat; } /** @@ -108,20 +115,20 @@ class Sdk { }); } - methodCall(...args: any[]): Promise { + 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.current.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)); + return resolve(this.methodCall(method, ...args)); } catch { // twoFactor was canceled return resolve({}); @@ -152,11 +159,11 @@ class Sdk { return this.methodCall(method, ...parsedParams); } - subscribe(...args: any[]) { - return this.current.subscribe(...args); + subscribe(topic: string, ...args: any[]): Promise { + return this.current.subscribe(topic, ...args); } - subscribeRaw(...args: any[]) { + subscribeRaw(...args: any[]): Promise { return this.current.subscribeRaw(...args); } @@ -181,12 +188,12 @@ class Sdk { ]); } - unsubscribe(subscription: any[]) { + unsubscribe(subscription: ISubscription) { return this.current.unsubscribe(subscription); } - onStreamData(...args: any[]) { - return this.current.onStreamData(...args); + onStreamData(event: string, callback: TStreamDataCallback): Promise { + return this.current.onStreamData(event, callback as ICallback); } } diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 4c7df62da10..92a3b59ec41 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; @@ -150,7 +150,7 @@ class MediaSessionInstance { log(error); } this.tryAnswerIfNativeAcceptedNotification(signal as ServerMediaSignal, true); - }); + }) as unknown as IStreamDataListener; this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { if (call && !call.hidden) { diff --git a/package.json b/package.json index 62be9de561c..0d1445c43fd 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#4202408370108bd0f084e798fb5c4f3a48e9db1c", + "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#1ccb76a663c0d0f6d4359873d30c73ce217779c8", "@rocket.chat/ui-kit": "^0.39.0", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 673def3c60d..dedf17b03d3 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#4202408370108bd0f084e798fb5c4f3a48e9db1c - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4202408370108bd0f084e798fb5c4f3a48e9db1c + specifier: RocketChat/Rocket.Chat.js.SDK#1ccb76a663c0d0f6d4359873d30c73ce217779c8 + version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8 '@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,8 +2633,8 @@ packages: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4202408370108bd0f084e798fb5c4f3a48e9db1c': - resolution: {gitHosted: true, integrity: sha512-byVmE1xTYZwm1eTUSwMsQIPXAGGL/rhljDh854aEQl2Cp3x/vTzjG+OFgc9TZEvQAP4Tfua8Y4bBffckXzHoEg==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4202408370108bd0f084e798fb5c4f3a48e9db1c} + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8': + resolution: {gitHosted: true, integrity: sha512-dDi+sfoT51GPCX4Mc67U6Fx6yabJKaCGj70zxWXFgamrCYIIwrkdg67e80mERX25knjXcnk7XK0OFQUNE1yAxw==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8} version: 1.3.3-mobile '@rocket.chat/ui-kit@0.39.0': @@ -10517,7 +10517,7 @@ 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/4202408370108bd0f084e798fb5c4f3a48e9db1c': + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8': dependencies: js-sha256: 0.9.0 tiny-events: 1.0.1 From fa7d7159b45c2ade917d827477fdb246d7236511 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 18 Aug 2026 13:11:55 -0300 Subject: [PATCH 05/35] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20awa?= =?UTF-8?q?it=20voip=20listener,=20filter=20undefined=20upload=20headers,?= =?UTF-8?q?=20declare=20babel=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/methods/actions.ts | 6 +++++- app/lib/methods/helpers/fileUpload/Upload.android.ts | 7 ++++++- app/lib/methods/helpers/fileUpload/Upload.ts | 5 ++++- app/lib/services/voip/MediaSessionInstance.ts | 4 ++-- package.json | 1 + pnpm-lock.yaml | 5 ++++- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/app/lib/methods/actions.ts b/app/lib/methods/actions.ts index 3a6f5c80730..2fbfa4a8772 100644 --- a/app/lib/methods/actions.ts +++ b/app/lib/methods/actions.ts @@ -108,7 +108,11 @@ export async function triggerAction({ const payload = rest.payload ?? rest.value; try { - const { userId, authToken } = sdk.current.currentLogin!; + const { currentLogin } = sdk.current; + if (!currentLogin) { + throw new Error('triggerAction requires an authenticated session'); + } + const { userId, authToken } = currentLogin; const { host } = sdk.current.client; const interaction = toUserInteraction({ type, diff --git a/app/lib/methods/helpers/fileUpload/Upload.android.ts b/app/lib/methods/helpers/fileUpload/Upload.android.ts index 6a9aefa6102..afe450d6e67 100644 --- a/app/lib/methods/helpers/fileUpload/Upload.android.ts +++ b/app/lib/methods/helpers/fileUpload/Upload.android.ts @@ -28,7 +28,12 @@ export class Upload { public setupRequest(url: string, headers: TUploadHeaders, progressCallback?: (loaded: number, total: number) => void): void { this.uploadUrl = url; - this.headers = headers as Record; + Object.keys(headers).forEach(key => { + const value = headers[key]; + if (value !== undefined) { + this.headers[key] = value; + } + }); this.progressCallback = progressCallback; } diff --git a/app/lib/methods/helpers/fileUpload/Upload.ts b/app/lib/methods/helpers/fileUpload/Upload.ts index 2d8e213b45f..fb89deb80df 100644 --- a/app/lib/methods/helpers/fileUpload/Upload.ts +++ b/app/lib/methods/helpers/fileUpload/Upload.ts @@ -15,7 +15,10 @@ export class Upload { public setupRequest(url: string, headers: TUploadHeaders, progressCallback?: (loaded: number, total: number) => void): void { this.xhr.open('POST', url); Object.keys(headers).forEach(key => { - this.xhr.setRequestHeader(key, headers[key] as string); + const value = headers[key]; + if (value !== undefined) { + this.xhr.setRequestHeader(key, value); + } }); if (progressCallback) { diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 92a3b59ec41..6c7c0ae402b 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -134,7 +134,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; } @@ -150,7 +150,7 @@ class MediaSessionInstance { log(error); } this.tryAnswerIfNativeAcceptedNotification(signal as ServerMediaSignal, true); - }) as unknown as IStreamDataListener; + }); this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { if (call && !call.hidden) { diff --git a/package.json b/package.json index 0d1445c43fd..8d3ac0bf96c 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "devDependencies": { "@babel/core": "~7.25.9", "@babel/plugin-proposal-decorators": "~7.25.9", + "@babel/plugin-transform-dynamic-import": "~7.25.9", "@babel/plugin-transform-named-capturing-groups-regex": "~7.25.9", "@babel/preset-env": "~7.25.9", "@babel/runtime": "~7.25.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dedf17b03d3..4e0ae48c3e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,6 +370,9 @@ importers: '@babel/plugin-proposal-decorators': specifier: ~7.25.9 version: 7.25.9(@babel/core@7.25.9) + '@babel/plugin-transform-dynamic-import': + specifier: ~7.25.9 + version: 7.25.9(@babel/core@7.25.9) '@babel/plugin-transform-named-capturing-groups-regex': specifier: ~7.25.9 version: 7.25.9(@babel/core@7.25.9) @@ -2634,7 +2637,7 @@ packages: react-native: '*' '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8': - resolution: {gitHosted: true, integrity: sha512-dDi+sfoT51GPCX4Mc67U6Fx6yabJKaCGj70zxWXFgamrCYIIwrkdg67e80mERX25knjXcnk7XK0OFQUNE1yAxw==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8} + resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8} version: 1.3.3-mobile '@rocket.chat/ui-kit@0.39.0': From 7b8f589dc9cd6db72b5dfee823ff0cd2ffa97561 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 18 Aug 2026 16:24:56 -0300 Subject: [PATCH 06/35] chore: bump @rocket.chat/sdk to mobile HEAD 383e457b and drop sdk client shim --- app/definitions/rocketchatSdkClient.d.ts | 7 -- app/lib/methods/getUsersPresence.ts | 2 +- .../helpers/fileUpload/Upload.android.ts | 15 ++-- app/lib/methods/helpers/fileUpload/Upload.ts | 13 ++-- app/lib/methods/helpers/fileUpload/index.ts | 5 +- app/lib/methods/logout.ts | 4 +- .../roomSubscription.integration.test.ts | 3 - .../__tests__/connect.integration.test.ts | 3 - .../socketHealth.integration.test.ts | 46 +++++------- .../services/__tests__/socketHealth.test.ts | 29 ++++---- app/lib/services/connect.ts | 71 +++++++------------ app/lib/services/sdk.ts | 8 ++- app/lib/services/socketHealth.ts | 24 ++----- app/lib/services/toLoginResult.ts | 6 ++ app/lib/services/toSdkCredentials.ts | 5 ++ .../acceptNativeCall.sdk.integration.test.ts | 25 ++++--- app/lib/services/voip/acceptNativeCall.ts | 17 ++--- babel.config.js | 6 -- package.json | 3 +- pnpm-lock.yaml | 13 ++-- 20 files changed, 118 insertions(+), 187 deletions(-) delete mode 100644 app/definitions/rocketchatSdkClient.d.ts create mode 100644 app/lib/services/toLoginResult.ts create mode 100644 app/lib/services/toSdkCredentials.ts diff --git a/app/definitions/rocketchatSdkClient.d.ts b/app/definitions/rocketchatSdkClient.d.ts deleted file mode 100644 index 1c8ef292835..00000000000 --- a/app/definitions/rocketchatSdkClient.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import '@rocket.chat/sdk/lib/api/api'; - -declare module '@rocket.chat/sdk/lib/api/api' { - interface IClient { - host: string; - } -} 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/fileUpload/Upload.android.ts b/app/lib/methods/helpers/fileUpload/Upload.android.ts index afe450d6e67..9b910608d40 100644 --- a/app/lib/methods/helpers/fileUpload/Upload.android.ts +++ b/app/lib/methods/helpers/fileUpload/Upload.android.ts @@ -1,7 +1,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import { type TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; -import { type IFormData, type TUploadHeaders } from './definitions'; +import { type IFormData } from './definitions'; export class Upload { private uploadUrl: string; @@ -26,14 +26,13 @@ export class Upload { this.isCancelled = false; } - public setupRequest(url: string, headers: TUploadHeaders, progressCallback?: (loaded: number, total: number) => void): void { + public setupRequest( + url: string, + headers: Record, + progressCallback?: (loaded: number, total: number) => void + ): void { this.uploadUrl = url; - Object.keys(headers).forEach(key => { - const value = headers[key]; - if (value !== undefined) { - this.headers[key] = value; - } - }); + this.headers = headers; this.progressCallback = progressCallback; } diff --git a/app/lib/methods/helpers/fileUpload/Upload.ts b/app/lib/methods/helpers/fileUpload/Upload.ts index fb89deb80df..5eb8037e693 100644 --- a/app/lib/methods/helpers/fileUpload/Upload.ts +++ b/app/lib/methods/helpers/fileUpload/Upload.ts @@ -1,5 +1,5 @@ import { type TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; -import { type IFormData, type TUploadHeaders } from './definitions'; +import { type IFormData } from './definitions'; export class Upload { private xhr: XMLHttpRequest; @@ -12,13 +12,14 @@ export class Upload { this.isCancelled = false; } - public setupRequest(url: string, headers: TUploadHeaders, progressCallback?: (loaded: number, total: number) => void): void { + public setupRequest( + url: string, + headers: Record, + progressCallback?: (loaded: number, total: number) => void + ): void { this.xhr.open('POST', url); Object.keys(headers).forEach(key => { - const value = headers[key]; - if (value !== undefined) { - this.xhr.setRequestHeader(key, value); - } + this.xhr.setRequestHeader(key, headers[key]); }); if (progressCallback) { diff --git a/app/lib/methods/helpers/fileUpload/index.ts b/app/lib/methods/helpers/fileUpload/index.ts index 7322ec2ba93..c2c833b2285 100644 --- a/app/lib/methods/helpers/fileUpload/index.ts +++ b/app/lib/methods/helpers/fileUpload/index.ts @@ -2,6 +2,9 @@ import { type TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms' import { Upload } from './Upload'; import { type IFormData, type TUploadHeaders } from './definitions'; +const dropUndefinedHeaders = (headers: TUploadHeaders): Record => + Object.fromEntries(Object.entries(headers).filter(([, value]) => value !== undefined)) as Record; + class FileUpload { private upload: Upload; @@ -12,7 +15,7 @@ class FileUpload { progressCallback?: (loaded: number, total: number) => void ) { this.upload = new Upload(); - this.upload.setupRequest(url, headers, progressCallback); + this.upload.setupRequest(url, dropUndefinedHeaders(headers), progressCallback); data.forEach(item => this.upload.appendFile(item)); } diff --git a/app/lib/methods/logout.ts b/app/lib/methods/logout.ts index 69d76f66522..75a1dc71a52 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -1,5 +1,4 @@ import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; -import { type ICredentials as ISdkCredentials } from '@rocket.chat/sdk/interfaces'; import type Model from '@nozbe/watermelondb/Model'; import { getDeviceToken } from '../notifications'; @@ -9,6 +8,7 @@ import database, { getDatabase } from '../database'; import log from './helpers/log'; import { disconnect } from '../services/connect'; import sdk from '../services/sdk'; +import { toSdkCredentials } from '../services/toSdkCredentials'; import { CURRENT_SERVER, E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, TOKEN_KEY } from '../constants/keys'; import UserPreferences from './userPreferences'; import { removePushToken } from '../services/restApi'; @@ -69,7 +69,7 @@ export async function removeServer({ server }: { server: string }): Promise @@ -203,7 +202,6 @@ afterEach(() => { jest.useRealTimers(); }); -/** Build a real SDK client, open its socket, and settle the handshake. */ async function connectDriver() { sdk.initialize('https://example.com'); const connectPromise = (sdk.current as unknown as { connect(): Promise }).connect(); @@ -261,7 +259,6 @@ describe('RoomSubscription over the real SDK', () => { expect(redux.store.dispatch).toHaveBeenCalledWith(unsubscribeRoom('room-rid')); expect(redux.store.dispatch).toHaveBeenCalledWith(clearUserTyping()); - // A frame on the room stream after unsubscribe no longer reaches the handler. receiveFrame(mockConnections[0], { msg: 'changed', collection: 'stream-room-messages', diff --git a/app/lib/services/__tests__/connect.integration.test.ts b/app/lib/services/__tests__/connect.integration.test.ts index 78bbbd04ee7..c9653a43f65 100644 --- a/app/lib/services/__tests__/connect.integration.test.ts +++ b/app/lib/services/__tests__/connect.integration.test.ts @@ -1,6 +1,5 @@ import type { Store } from 'redux'; -// The repo auto-applies `__mocks__/@rocket.chat/sdk.js` (an empty class). Drive the real SDK. jest.unmock('@rocket.chat/sdk'); import { connect, login, loginWithPassword } from '../connect'; @@ -211,7 +210,6 @@ afterEach(() => { jest.useRealTimers(); }); -/** Connect to a server, resolve the SDK dynamic import, then drive the handshake to completion. */ async function connectAndDriveHandshake(server = 'https://example.com') { await connect({ server }); await flush(); @@ -267,7 +265,6 @@ describe('connect() over the real SDK', () => { expect(firstConnection.close).toHaveBeenCalled(); - // A connected frame on the discarded socket no longer reaches the store. firstConnection.onmessage({ data: JSON.stringify({ msg: 'connected', session: 'x' }) }); await flush(); diff --git a/app/lib/services/__tests__/socketHealth.integration.test.ts b/app/lib/services/__tests__/socketHealth.integration.test.ts index 94dc10127b7..13fef8a84dc 100644 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ b/app/lib/services/__tests__/socketHealth.integration.test.ts @@ -3,7 +3,7 @@ import { recoverSocket } from '../socketHealth'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { - Driver: new (options: { host: string; logger: unknown }) => PatchedDriver; + Driver: new (options: { host: string; logger: unknown }) => SdkDriver; }; interface MockConnection { @@ -23,7 +23,7 @@ interface WireFrame { params?: string[]; } -interface PatchedDriver { +interface SdkDriver { userId: string; pingInterval: number; reopenNow(): Promise; @@ -72,10 +72,10 @@ jest.mock('../sdk', () => ({ 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 Driver over a mocked WebSocket, connected and logged in. */ async function buildConnectedDriver() { const driver = new Driver({ host: 'localhost:3000', logger }); driver.userId = USER_ID; @@ -86,7 +86,7 @@ async function buildConnectedDriver() { return driver; } -function addMediaSubs(driver: PatchedDriver) { +function addMediaSubs(driver: SdkDriver) { ['media-signal', 'media-calls'].forEach((name, index) => { const id = `sub-${index}`; driver.ddp.subscriptions[id] = { @@ -98,26 +98,29 @@ function addMediaSubs(driver: PatchedDriver) { }); } -function backdateLastPing(driver: PatchedDriver, ageMs: number) { +function backdateLastPing(driver: SdkDriver, ageMs: number) { driver.ddp.lastPing = Date.now() - ageMs; } -/** Frames of a given `msg` sent over the wire on one connection. */ +function stopAnsweringFrames(connection: MockConnection) { + connection.send.mockImplementation(() => undefined); +} + 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: SdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); mockConnections.length = 0; driver = await buildConnectedDriver(); - (sdk as unknown as { current: { ddp: PatchedDriver } }).current = { ddp: driver }; + (sdk as unknown as { current: { ddp: SdkDriver } }).current = { ddp: driver }; }); afterEach(() => { @@ -137,20 +140,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 +159,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 +180,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 +191,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,7 +203,6 @@ 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); }); @@ -221,7 +215,6 @@ describe('recoverSocket against the real patched socket', () => { 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(); @@ -249,7 +242,6 @@ 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`] }), @@ -258,8 +250,7 @@ describe('recoverSocket against the real patched socket', () => { }); it('reopens a closed transport without a round trip even when lastPing is fresh', async () => { - // A fresh `lastPing` proves nothing once the transport itself is closed. - mockConnections[0].readyState = 3; + mockConnections[0].readyState = CLOSED; const recovery = recoverSocket(); await jest.advanceTimersByTimeAsync(0); @@ -282,12 +273,10 @@ describe('recoverSocket against the real patched socket', () => { await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); - // No media subs are registered yet, so the wait polls instead of resolving. const resubscribed = driver.waitForNotifyUserMediaSubs(1000); await jest.advanceTimersByTimeAsync(100); expect(framesOn(mockConnections[1], 'sub')).toHaveLength(0); - // The subs appear after the wait is already polling. addMediaSubs(driver); await jest.advanceTimersByTimeAsync(200); @@ -308,8 +297,7 @@ describe('recoverSocket against the real patched socket', () => { await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); - // The new socket swallows the re-sub frames, so the ack never arrives. - mockConnections[1].send.mockImplementation(() => undefined); + stopAnsweringFrames(mockConnections[1]); const resubscribed = driver.waitForNotifyUserMediaSubs(500); await jest.advanceTimersByTimeAsync(500); diff --git a/app/lib/services/__tests__/socketHealth.test.ts b/app/lib/services/__tests__/socketHealth.test.ts index 031c7fe92a2..8c44b683492 100644 --- a/app/lib/services/__tests__/socketHealth.test.ts +++ b/app/lib/services/__tests__/socketHealth.test.ts @@ -5,6 +5,8 @@ jest.mock('../sdk', () => ({ } })); +import type { Driver } from '@rocket.chat/sdk/lib/drivers/driver'; + import sdk from '../sdk'; import { classifySocketHealth, recoverSocket } from '../socketHealth'; @@ -13,19 +15,18 @@ const now = 1_000_000; const sdkMock = sdk as unknown as { current: { ddp: unknown } | undefined }; interface MockDdp { - connected?: boolean; + connected: boolean; lastPing: number; - pingInterval?: number; - config?: { ping?: number }; + pingInterval: number; reopenNow: jest.Mock, []>; probe: jest.Mock, [number]>; } function makeDdp(overrides: Partial = {}): MockDdp { return { + connected: true, lastPing: now, pingInterval: 10000, - config: { ping: 10000 }, reopenNow: jest.fn, []>(() => Promise.resolve()), probe: jest.fn, [number]>(() => Promise.resolve(true)), ...overrides @@ -43,33 +44,27 @@ describe('classifySocketHealth', () => { it('returns reopen when age > 2 * pingInterval', () => { const ddp = makeDdp({ lastPing: now - 21000 }); - expect(classifySocketHealth(ddp)).toBe('reopen'); + expect(classifySocketHealth(ddp as unknown as Driver)).toBe('reopen'); }); it('returns round-trip-check when age <= 2 * pingInterval', () => { const ddp = makeDdp({ lastPing: now - 15000 }); - expect(classifySocketHealth(ddp)).toBe('round-trip-check'); + expect(classifySocketHealth(ddp as unknown as Driver)).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'); + expect(classifySocketHealth(ddp as unknown as Driver)).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('uses 10000ms default when pingInterval is missing', () => { + const ddp = makeDdp({ pingInterval: 0, lastPing: now - 21000 }); + expect(classifySocketHealth(ddp as unknown as Driver)).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'); + expect(classifySocketHealth(ddp as unknown as Driver)).toBe('reopen'); }); }); diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts index 043c7806e1a..f25cbcfdbbe 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -1,5 +1,4 @@ import { Rocketchat as RocketchatClient } from '@rocket.chat/sdk'; -import { type ICredentials as ISdkCredentials } from '@rocket.chat/sdk/interfaces'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { InteractionManager } from 'react-native'; import { Q } from '@nozbe/watermelondb'; @@ -13,10 +12,12 @@ import { store } from '../store/auxStore'; import { loginRequest, logout, setLoginServices, setUser } from '../../actions/login'; import { waitForLoginReady } from './waitForLoginReady'; import sdk from './sdk'; +import { toLoginResult } from './toLoginResult'; +import { toSdkCredentials } from './toSdkCredentials'; import { mediaSessionInstance } from './voip/MediaSessionInstance'; import { pendingHangups } from './voip/pendingHangups'; import I18n from '../../i18n'; -import { type ICredentials, type ILoggedUser, type ILoginResultFromServer, STATUSES } from '../../definitions'; +import { type ICredentials, type ILoggedUser, STATUSES } from '../../definitions'; import { connectRequest, connectSuccess, disconnect as disconnectAction } from '../../actions/connect'; import { updatePermission } from '../../actions/permissions'; import EventEmitter from '../methods/helpers/events'; @@ -49,6 +50,7 @@ let pendingHangupsConnectedListener: any; let usersListener: any; let notifyAllListener: any; let rolesListener: any; +let userPresenceListener: any; let notifyLoggedListener: any; let logoutListener: any; @@ -62,41 +64,18 @@ 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(); @@ -106,7 +85,7 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr getSettings(); sdk.current - .connect({}) + .connect() .then(() => { console.log('connected'); }) @@ -200,7 +179,7 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr ); // RC 4.1 - sdk.current.onStreamData('stream-user-presence', (ddpMessage: any) => { + userPresenceListener = sdk.current.onStreamData('stream-user-presence', (ddpMessage: any) => { const userStatus = ddpMessage.fields.args[0]; const { uid } = ddpMessage.fields; const [, status, statusText, statusSource, statusExpiresAtRaw] = userStatus; @@ -313,21 +292,21 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr }); } -function stopListener(listener: any): boolean { - return listener && listener.stop(); +function stopListener(listener: any): void { + listener?.stop(); } async function login(credentials: ICredentials): Promise { // RC 0.64.0 - await sdk.current.login(credentials as unknown as ISdkCredentials); + await sdk.current.login(toSdkCredentials(credentials)); const serverVersion = store.getState().server.version; - const result = sdk.current.currentLogin?.result as unknown as ILoginResultFromServer | undefined; + const result = toLoginResult(sdk.current.currentLogin?.result); let enableMessageParserEarlyAdoption = true; let showMessageInMainThread = false; if (compareServerVersion(serverVersion, 'lowerThan', '5.0.0')) { - enableMessageParserEarlyAdoption = result!.me.settings?.preferences?.enableMessageParserEarlyAdoption ?? true; - showMessageInMainThread = result!.me.settings?.preferences?.showMessageInMainThread ?? true; + enableMessageParserEarlyAdoption = result?.me.settings?.preferences?.enableMessageParserEarlyAdoption ?? true; + showMessageInMainThread = result?.me.settings?.preferences?.showMessageInMainThread ?? true; } if (result) { @@ -454,7 +433,7 @@ async function getWebsocketInfo({ const websocketSdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: isSsl(server) }); try { - await websocketSdk.connect({}); + await websocketSdk.connect(); } catch (err: any) { if (err.message && err.message.includes('400')) { return { diff --git a/app/lib/services/sdk.ts b/app/lib/services/sdk.ts index 2c80b909264..f62929a95be 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -15,6 +15,8 @@ import { } from '../../definitions/rest/helpers'; import { compareServerVersion, random } from '../methods/helpers'; +export type TDriver = Rocketchat['ddp']; + export type TStreamDataCallback = (ddpMessage: any) => void; export interface IStreamDataListener { @@ -22,7 +24,7 @@ export interface IStreamDataListener { } class Sdk { - private sdk: Rocketchat | null = null; + private sdk!: Rocketchat; private code: any; private initializeSdk(server: string): Rocketchat { @@ -38,7 +40,7 @@ class Sdk { } get current(): Rocketchat { - return this.sdk as Rocketchat; + return this.sdk; } /** @@ -48,6 +50,7 @@ class Sdk { disconnect() { if (this.sdk) { this.sdk.disconnect(); + // @ts-expect-error this.sdk = null; } return null; @@ -118,7 +121,6 @@ class Sdk { 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(method, ...args, ...(code ? [code] : [])); diff --git a/app/lib/services/socketHealth.ts b/app/lib/services/socketHealth.ts index aa7d4ceb5f1..464d639ffa5 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 TDriver } from './sdk'; /** * The recovery plan — what classification decides. @@ -26,12 +12,12 @@ interface SocketHealthDdp { */ export type SocketRecoveryPlan = 'reopen' | 'round-trip-check'; -export function classifySocketHealth(ddp: SocketHealthDdp): SocketRecoveryPlan { +export function classifySocketHealth(ddp: TDriver): SocketRecoveryPlan { // Ping age can't vouch for a socket the OS already closed. - if (ddp.connected === false) { + if (!ddp.connected) { return 'reopen'; } - const pingInterval = (ddp.pingInterval ?? ddp.config?.ping) || 10000; + const pingInterval = ddp.pingInterval || 10000; const age = Date.now() - ddp.lastPing; if (age > pingInterval * 2) { return 'reopen'; @@ -62,7 +48,7 @@ function shareRecovery(): Promise { if (inFlightRecovery) { return inFlightRecovery; } - const ddp = sdk.current?.ddp as SocketHealthDdp | undefined; + const ddp = sdk.current?.ddp; if (!ddp) { return Promise.resolve('no-socket'); } diff --git a/app/lib/services/toLoginResult.ts b/app/lib/services/toLoginResult.ts new file mode 100644 index 00000000000..797b8793a18 --- /dev/null +++ b/app/lib/services/toLoginResult.ts @@ -0,0 +1,6 @@ +import { type ILoginResultAPI } from '@rocket.chat/sdk/interfaces'; + +import { type ILoginResultFromServer } from '../../definitions/ILoggedUser'; + +export const toLoginResult = (result: ILoginResultAPI | null | undefined): ILoginResultFromServer | undefined => + (result ?? undefined) as unknown as ILoginResultFromServer | undefined; diff --git a/app/lib/services/toSdkCredentials.ts b/app/lib/services/toSdkCredentials.ts new file mode 100644 index 00000000000..38b8076a118 --- /dev/null +++ b/app/lib/services/toSdkCredentials.ts @@ -0,0 +1,5 @@ +import { type ICredentials as ISdkCredentials } from '@rocket.chat/sdk/interfaces'; + +import { type ICredentials } from '../../definitions/ICredentials'; + +export const toSdkCredentials = (credentials: ICredentials): ISdkCredentials => credentials as ISdkCredentials; diff --git a/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts index 760de1da98e..e8592a375a8 100644 --- a/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts +++ b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts @@ -6,7 +6,7 @@ import { waitForLoginReady } from '../waitForLoginReady'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { - Driver: new (options: { host: string; logger: unknown }) => PatchedDriver; + Driver: new (options: { host: string; logger: unknown }) => SdkDriver; }; jest.mock('../sdk', () => ({ @@ -41,7 +41,7 @@ interface MockConnection { onclose: () => void; } -interface PatchedDriver { +interface SdkDriver { userId: string; pingInterval: number; reopenNow(): Promise; @@ -110,7 +110,6 @@ function makeMediaSession(overrides: Partial = {}): IMediaSession }; } -/** Real patched Driver over a mocked WebSocket, connected and logged in. */ async function buildConnectedDriver() { const driver = new Driver({ host: 'localhost:3000', logger }); driver.userId = USER_ID; @@ -121,7 +120,7 @@ async function buildConnectedDriver() { return driver; } -function addMediaSubs(driver: PatchedDriver) { +function addMediaSubs(driver: SdkDriver) { ['media-signal', 'media-calls'].forEach((name, index) => { const id = `sub-${index}`; driver.ddp.subscriptions[id] = { @@ -133,18 +132,22 @@ function addMediaSubs(driver: PatchedDriver) { }); } -function backdateLastPing(driver: PatchedDriver, ageMs: number) { +function backdateLastPing(driver: SdkDriver, ageMs: number) { driver.ddp.lastPing = Date.now() - ageMs; } -let driver: PatchedDriver; +function stopAnsweringFrames(connection: MockConnection) { + connection.send.mockImplementation(() => undefined); +} + +let driver: SdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); mockConnections.length = 0; driver = await buildConnectedDriver(); - (sdk as unknown as { current: { ddp: PatchedDriver } }).current = { ddp: driver }; + (sdk as unknown as { current: { ddp: SdkDriver } }).current = { ddp: driver }; mockWaitForLoginReady.mockResolvedValue(true); mockGetState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); }); @@ -155,7 +158,7 @@ afterEach(() => { jest.useRealTimers(); }); -describe('acceptNativeCallWithReadiness against the real patched socket', () => { +describe('acceptNativeCallWithReadiness against the real SDK socket', () => { it('answers the call once media subs re-ack on the reopened socket', async () => { const mediaSession = makeMediaSession(); @@ -188,9 +191,7 @@ describe('acceptNativeCallWithReadiness against the real patched socket', () => await jest.advanceTimersByTimeAsync(0); mockConnections[1].onopen(); - // Swallow the re-sub frames before the reopen handshake settles, so the - // media ack never arrives while the connect handshake still completes. - mockConnections[1].send.mockImplementation(() => undefined); + stopAnsweringFrames(mockConnections[1]); await jest.advanceTimersByTimeAsync(0); await jest.advanceTimersByTimeAsync(8000); await accept; @@ -212,10 +213,8 @@ describe('acceptNativeCallWithReadiness against the real patched socket', () => mockConnections[1].onopen(); await jest.advanceTimersByTimeAsync(0); - // No media subs registered yet, so the wait polls instead of resolving. await jest.advanceTimersByTimeAsync(100); - // The subs appear after the poll is already underway. addMediaSubs(driver); await jest.advanceTimersByTimeAsync(200); await accept; diff --git a/app/lib/services/voip/acceptNativeCall.ts b/app/lib/services/voip/acceptNativeCall.ts index 7aab96d7d67..e51f9a99d77 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 TDriver } 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(ddp: TDriver, 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([ddp.waitForNotifyUserMediaSubs(timeoutMs), aborted]); } catch (error) { log(error); return false; @@ -74,7 +65,7 @@ export async function acceptNativeCallWithReadiness(callId: string, mediaSession return; } - const ddp = sdk.current?.ddp as MediaSignalDdp | undefined; + const ddp = sdk.current?.ddp; if (!ddp) { return handleFailure(callId, mediaSession); } diff --git a/babel.config.js b/babel.config.js index 960d454b8da..57933130d6b 100644 --- a/babel.config.js +++ b/babel.config.js @@ -22,12 +22,6 @@ module.exports = { } ], env: { - // Jest's CommonJS runtime rejects the SDK's dynamic `import('../drivers/ddp')` - // as long as babel-preset-expo (caller "metro") leaves it native. Rewrite it to - // a synchronous require in the test env only. - test: { - plugins: ['@babel/plugin-transform-dynamic-import'] - }, production: { plugins: ['transform-remove-console'] } diff --git a/package.json b/package.json index 8d3ac0bf96c..be7aec19005 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#1ccb76a663c0d0f6d4359873d30c73ce217779c8", + "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#383e457b3bb31598daacf2572d20644c795f58d2", "@rocket.chat/ui-kit": "^0.39.0", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", @@ -161,7 +161,6 @@ "devDependencies": { "@babel/core": "~7.25.9", "@babel/plugin-proposal-decorators": "~7.25.9", - "@babel/plugin-transform-dynamic-import": "~7.25.9", "@babel/plugin-transform-named-capturing-groups-regex": "~7.25.9", "@babel/preset-env": "~7.25.9", "@babel/runtime": "~7.25.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e0ae48c3e5..d596939aa31 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#1ccb76a663c0d0f6d4359873d30c73ce217779c8 - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8 + specifier: RocketChat/Rocket.Chat.js.SDK#383e457b3bb31598daacf2572d20644c795f58d2 + version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2 '@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) @@ -370,9 +370,6 @@ importers: '@babel/plugin-proposal-decorators': specifier: ~7.25.9 version: 7.25.9(@babel/core@7.25.9) - '@babel/plugin-transform-dynamic-import': - specifier: ~7.25.9 - version: 7.25.9(@babel/core@7.25.9) '@babel/plugin-transform-named-capturing-groups-regex': specifier: ~7.25.9 version: 7.25.9(@babel/core@7.25.9) @@ -2636,8 +2633,8 @@ packages: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8': - resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/1ccb76a663c0d0f6d4359873d30c73ce217779c8} + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2': + resolution: {gitHosted: true, integrity: sha512-in4lCtRYlY6PcCN3JfEtO3zVqyVjdic9BXxScWiD2y/BwwBqijgRFKckYm44v9GOwMllav92e9Dsfz8GyuBfhw==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2} version: 1.3.3-mobile '@rocket.chat/ui-kit@0.39.0': @@ -10520,7 +10517,7 @@ 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/1ccb76a663c0d0f6d4359873d30c73ce217779c8': + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2': dependencies: js-sha256: 0.9.0 tiny-events: 1.0.1 From 574125bfdb3a6881089cd482acacf62bb0e42121 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:25:11 -0300 Subject: [PATCH 07/35] fix: remove unreachable ping-age branch in classifySocketHealth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ddp.connected` already folds in the ping-age test — Socket.connected is `transportOpen && alive()`, and `alive()` is `now - lastPing <= config.ping * 2`, the same multiplier `pingInterval * 2` used here. A connected socket therefore never has a stale ping, so the second branch could not run. Drop it, and drop the three unit tests that fed the mock `connected: true` alongside a stale lastPing — a state the real driver cannot produce. --- .../services/__tests__/socketHealth.test.ts | 19 ++----------------- app/lib/services/socketHealth.ts | 12 ++++-------- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/app/lib/services/__tests__/socketHealth.test.ts b/app/lib/services/__tests__/socketHealth.test.ts index 8c44b683492..f29f32e9822 100644 --- a/app/lib/services/__tests__/socketHealth.test.ts +++ b/app/lib/services/__tests__/socketHealth.test.ts @@ -42,26 +42,11 @@ describe('classifySocketHealth', () => { jest.restoreAllMocks(); }); - it('returns reopen when age > 2 * pingInterval', () => { - const ddp = makeDdp({ lastPing: now - 21000 }); - expect(classifySocketHealth(ddp as unknown as Driver)).toBe('reopen'); - }); - - it('returns round-trip-check when age <= 2 * pingInterval', () => { - const ddp = makeDdp({ lastPing: now - 15000 }); + it('returns round-trip-check for a connected socket rather than trusting it outright', () => { + const ddp = makeDdp({ connected: true }); expect(classifySocketHealth(ddp as unknown as Driver)).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 as unknown as Driver)).toBe('round-trip-check'); - }); - - it('uses 10000ms default when pingInterval is missing', () => { - const ddp = makeDdp({ pingInterval: 0, lastPing: now - 21000 }); - expect(classifySocketHealth(ddp as unknown as Driver)).toBe('reopen'); - }); - it('returns reopen for a closed socket even when lastPing is fresh', () => { const ddp = makeDdp({ connected: false, lastPing: now }); expect(classifySocketHealth(ddp as unknown as Driver)).toBe('reopen'); diff --git a/app/lib/services/socketHealth.ts b/app/lib/services/socketHealth.ts index 464d639ffa5..73b3e77f6d7 100644 --- a/app/lib/services/socketHealth.ts +++ b/app/lib/services/socketHealth.ts @@ -13,17 +13,13 @@ import sdk, { type TDriver } from './sdk'; export type SocketRecoveryPlan = 'reopen' | 'round-trip-check'; export function classifySocketHealth(ddp: TDriver): SocketRecoveryPlan { - // Ping age can't vouch for a socket the OS already closed. + // `ddp.connected` already folds in the ping-age test (transportOpen && alive(), + // where alive() is `now - lastPing <= config.ping * 2`), so a stale ping lands here. if (!ddp.connected) { return 'reopen'; } - const pingInterval = ddp.pingInterval || 10000; - const age = Date.now() - ddp.lastPing; - if (age > pingInterval * 2) { - return 'reopen'; - } - // Anything younger is verified by a round trip, never trusted outright: onOpen - // refreshes lastPing before the handshake reply lands. + // A connected socket is still verified by a round trip, never trusted outright: + // onOpen refreshes lastPing before the handshake reply lands. return 'round-trip-check'; } From 736de5e6235ff1e5ba493a9bf7e5f95e5be67dfa Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:37:34 -0300 Subject: [PATCH 08/35] fix(login): surface a failure instead of hanging on a missing login result login() refused nothing when currentLogin.result was absent, returning undefined, and loginTOTP wrapped its body in a hand-built promise whose success branch had no else. A missing result therefore settled neither way and the login screen spun forever with no error and no log entry. login() now throws on a missing result and returns Promise, and loginTOTP is a plain async function, so every login outcome ends in a logged-in user or a visible error. --- app/lib/services/connect.test.ts | 61 ++++++++++++++- app/lib/services/connect.ts | 130 ++++++++++++++----------------- 2 files changed, 116 insertions(+), 75 deletions(-) diff --git a/app/lib/services/connect.test.ts b/app/lib/services/connect.test.ts index 7f1ad5edd3b..6a5db1babce 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'; @@ -24,10 +24,13 @@ const mockSdkConnect = jest.fn, []>(() => Promise.resolve()); const mockSdkAbort = jest.fn(); const mockSdkDisconnect = jest.fn(); const mockSdkInitialize = jest.fn(); -const mockSdkCurrent = { +const mockSdkLogin = jest.fn, [unknown]>(() => Promise.resolve()); +const mockSdkCurrent: Record = { onStreamData: (event: string, cb: (...args: any[]) => void) => mockOnStreamData(event, cb), connect: () => mockSdkConnect(), - abort: () => mockSdkAbort() + abort: () => mockSdkAbort(), + login: (credentials: unknown) => mockSdkLogin(credentials), + currentLogin: undefined }; jest.mock('./sdk', () => ({ __esModule: true, @@ -44,11 +47,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 +634,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 f25cbcfdbbe..633a53f4101 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -296,94 +296,82 @@ function stopListener(listener: any): void { listener?.stop(); } -async function login(credentials: ICredentials): Promise { +async function login(credentials: ICredentials): Promise { // RC 0.64.0 await sdk.current.login(toSdkCredentials(credentials)); const serverVersion = store.getState().server.version; const result = toLoginResult(sdk.current.currentLogin?.result); + if (!result) { + throw new Error('Login failed: missing login result'); + } let enableMessageParserEarlyAdoption = true; let showMessageInMainThread = false; if (compareServerVersion(serverVersion, 'lowerThan', '5.0.0')) { - enableMessageParserEarlyAdoption = result?.me.settings?.preferences?.enableMessageParserEarlyAdoption ?? true; - showMessageInMainThread = result?.me.settings?.preferences?.showMessageInMainThread ?? true; + enableMessageParserEarlyAdoption = result.me.settings?.preferences?.enableMessageParserEarlyAdoption ?? true; + showMessageInMainThread = result.me.settings?.preferences?.showMessageInMainThread ?? true; } - if (result) { - const user: ILoggedUser = { - id: result.userId, - token: result.authToken, - username: result.me.username, - name: result.me.name, - language: result.me.language, - status: result.me.status, - statusText: result.me.statusText, - customFields: result.me.customFields, - statusLivechat: result.me.statusLivechat, - emails: result.me.emails, - roles: result.me.roles, - avatarETag: result.me.avatarETag, - showMessageInMainThread, - enableMessageParserEarlyAdoption, - alsoSendThreadToChannel: result.me.settings?.preferences?.alsoSendThreadToChannel, - bio: result.me.bio, - nickname: result.me.nickname, - requirePasswordChange: result.me.requirePasswordChange - }; - return user; - } + const user: ILoggedUser = { + id: result.userId, + token: result.authToken, + username: result.me.username, + name: result.me.name, + language: result.me.language, + status: result.me.status, + statusText: result.me.statusText, + customFields: result.me.customFields, + statusLivechat: result.me.statusLivechat, + emails: result.me.emails, + roles: result.me.roles, + avatarETag: result.me.avatarETag, + showMessageInMainThread, + enableMessageParserEarlyAdoption, + alsoSendThreadToChannel: result.me.settings?.preferences?.alsoSendThreadToChannel, + bio: result.me.bio, + nickname: result.me.nickname, + requirePasswordChange: result.me.requirePasswordChange + }; + return user; } -function loginTOTP(params: ICredentials, loginEmailPassword?: boolean): 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: ICredentials, 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)); - } + if (loginEmailPassword) { + store.dispatch(setUser({ username: params.user || params.username })); - return resolve( - loginTOTP({ - totp: { - login: { - ...params - }, - code: code?.twoFactorCode - } - }) - ); - } catch { - // twoFactor was canceled - return reject(); + // 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 }; } - } else { - reject(e); + + return loginTOTP({ ...params, code: code?.twoFactorCode }, loginEmailPassword); } + + return loginTOTP({ + totp: { + login: { + ...params + }, + code: code?.twoFactorCode + } + }); } - }); + throw e; + } } function loginWithPassword({ user, password }: { user: string; password: string }): Promise { From 341470f02d1178c97df5d98d73c9a1a628f8f6b4 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:37:50 -0300 Subject: [PATCH 09/35] fix(upload): refuse an upload without auth headers The upload helper filtered absent headers out of the request before sending, which dropped the session's auth headers along with the optional ones and sent an unauthenticated request that came back as an opaque server rejection. FileUpload now refuses to build the request at all when the auth headers are missing, before any network work, so all three upload call sites fail loudly through their existing error surfacing. --- .../methods/helpers/fileUpload/index.test.ts | 66 +++++++++++++++++++ app/lib/methods/helpers/fileUpload/index.ts | 16 +++++ 2 files changed, 82 insertions(+) create mode 100644 app/lib/methods/helpers/fileUpload/index.test.ts 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..3284d5ad8e8 --- /dev/null +++ b/app/lib/methods/helpers/fileUpload/index.test.ts @@ -0,0 +1,66 @@ +import FileUpload, { MissingUploadAuthHeadersError } from './index'; +import { Upload } from './Upload'; + +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 build a request when %s', (_, headers) => { + expect(() => new FileUpload('https://open.rocket.chat/api/v1/users.setAvatar', headers, formData)).toThrow( + MissingUploadAuthHeadersError + ); + expect(Upload).not.toHaveBeenCalled(); + expect(mockSetupRequest).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 c2c833b2285..cb2e396b9b0 100644 --- a/app/lib/methods/helpers/fileUpload/index.ts +++ b/app/lib/methods/helpers/fileUpload/index.ts @@ -1,10 +1,25 @@ import { type TRoomsMediaResponse } from '../../../../definitions/rest/v1/rooms'; +import i18n from '../../../../i18n'; import { Upload } from './Upload'; 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; @@ -14,6 +29,7 @@ class FileUpload { data: IFormData[], progressCallback?: (loaded: number, total: number) => void ) { + assertAuthHeaders(headers); this.upload = new Upload(); this.upload.setupRequest(url, dropUndefinedHeaders(headers), progressCallback); data.forEach(item => this.upload.appendFile(item)); From 454734919ae2e233cf3fd0c9228407d058e8defd Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:37:59 -0300 Subject: [PATCH 10/35] fix(2fa): report a cancelled two-factor prompt as a cancellation Both request paths reported a dismissed two-factor prompt as a successful response with an empty body, so callers could not tell a deliberate cancellation from a server returning nothing. twoFactor() now rejects with TwoFactorCancelledError, and sdk post() and methodCall() propagate it instead of resolving an empty object. isTwoFactorCancelled is the guard callers test against. The error lives in its own module so error reporting can import the guard without pulling in the prompt component. --- app/lib/services/sdk.test.ts | 53 +++++++++++++++++++++++--- app/lib/services/sdk.ts | 10 ++--- app/lib/services/twoFactor.ts | 5 ++- app/lib/services/twoFactorCancelled.ts | 9 +++++ 4 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 app/lib/services/twoFactorCancelled.ts 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 f62929a95be..d41efa968d6 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -107,9 +107,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); @@ -131,9 +130,8 @@ class Sdk { try { this.code = await twoFactor({ method: details?.method, invalid: e.error === 'totp-invalid' }); return resolve(this.methodCall(method, ...args)); - } catch { - // twoFactor was canceled - return resolve({}); + } catch (twoFactorError) { + return reject(twoFactorError); } } else { reject(e); diff --git a/app/lib/services/twoFactor.ts b/app/lib/services/twoFactor.ts index f8b47609b46..eced04aaeb7 100644 --- a/app/lib/services/twoFactor.ts +++ b/app/lib/services/twoFactor.ts @@ -3,6 +3,9 @@ import { settings } from '@rocket.chat/sdk'; import { TWO_FACTOR } from '../../containers/TwoFactor'; import EventEmitter from '../methods/helpers/events'; import { type ICredentials } from '../../definitions'; +import { TwoFactorCancelledError } from './twoFactorCancelled'; + +export { TwoFactorCancelledError, isTwoFactorCancelled } from './twoFactorCancelled'; interface ITwoFactor { method: 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'); From 8ee07d5b136cbe66ba3e06e2b9aa2a678e1f1959 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:38:10 -0300 Subject: [PATCH 11/35] fix(2fa): stop reporting a deliberate cancellation as an error Now that a dismissed two-factor prompt rejects, the paths that can raise one would report the person's own choice as a failure. Cancelling is a choice, not an error. Error reporting and the alert helpers ignore the cancellation centrally, and each two-factor-gated call site treats it as a no-op: profile and username changes, account deletion, logging out other locations, password change, and the two encryption key resets. Cancellation is recognised only through isTwoFactorCancelled, never by matching a message. Genuine failures report exactly as before. The login path deliberately keeps showing an error, otherwise the login screen would silently spin again. --- .../helpers/handleSaveUserProfileError.ts | 4 ++ app/lib/methods/helpers/info.ts | 4 ++ app/lib/methods/helpers/log/index.ts | 4 ++ .../helpers/twoFactorCancellation.test.ts | 64 +++++++++++++++++++ app/views/ChangePasswordView/index.tsx | 8 ++- app/views/E2EEToggleRoomView/resetRoomKey.ts | 4 ++ app/views/E2EEncryptionSecurityView/index.tsx | 4 ++ .../ConfirmDeleteAccountContent.tsx | 10 ++- .../DeleteAccountActionSheetContent/index.tsx | 4 ++ app/views/ProfileView/index.tsx | 14 ++-- .../methods/logoutOtherLocations.ts | 6 +- app/views/SetUsernameView.tsx | 5 +- 12 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 app/lib/methods/helpers/twoFactorCancellation.test.ts 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/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/twoFactorCancellation.test.ts b/app/lib/methods/helpers/twoFactorCancellation.test.ts new file mode 100644 index 00000000000..a84cb7850fd --- /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(undefined as any)).toBe('Login_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/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/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/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/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); }; From 81f32c6ebbc3176eed7174023c6e29ea97c4db04 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:38:20 -0300 Subject: [PATCH 12/35] fix: settle the promises that could strand a caller An audit of every hand-built promise in app/ found three that could end without settling, leaving a caller waiting forever with no error and no timeout. RoomInfoView's createDirect did nothing when the request reported failure, so the Message button died silently; it is now a plain async function that throws. getRoles resolved only inside its non-empty branch, so an empty roles list stalled the login saga's roles fork. An overlapping two-factor request overwrote the open prompt's callbacks and stranded the first caller, which is now cancelled with the existing cancellation error. Every other hand-built promise was read and settles on all paths. --- app/containers/TwoFactor/index.test.tsx | 38 +++++++++++++++++++++++++ app/containers/TwoFactor/index.tsx | 7 ++++- app/lib/methods/getRoles.ts | 2 +- app/views/RoomInfoView/index.tsx | 22 +++++++------- 4 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 app/containers/TwoFactor/index.test.tsx 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..fdc3bb6da8e 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'; @@ -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, @@ -113,6 +114,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 +131,7 @@ const TwoFactor = memo(() => { const onCancel = () => { const { cancel } = data; + pendingCancel.current = undefined; if (cancel) { cancel(); } @@ -136,6 +140,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/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/views/RoomInfoView/index.tsx b/app/views/RoomInfoView/index.tsx index 1e8b14ea772..9cff9711ec5 100644 --- a/app/views/RoomInfoView/index.tsx +++ b/app/views/RoomInfoView/index.tsx @@ -229,17 +229,15 @@ 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 => { + // We don't need to create a direct + if (!isEmpty(member)) return; + const result = await createDirectMessage(roomUser.username); + if (!result.success) { + throw new Error('Failed to create direct message'); + } + return { ...roomUser, rid: result.room.rid }; + }; const handleGoRoom = (r?: ISubscription) => { logEvent(events.RI_GO_ROOM_USER); @@ -268,7 +266,7 @@ const RoomInfoView = (): ReactElement => { } handleGoRoom(r); } catch (e: any) { - emitErrorCreateDirectMessage(e?.data); + emitErrorCreateDirectMessage(e?.data ?? e); } }; From ef8549ef21adc0618dc143349df6b77433e693a7 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:45:42 -0300 Subject: [PATCH 13/35] chore: drop a comment describing the removed empty two-factor response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2eResetOwnKey documented returning {} when TOTP is enabled. That response no longer exists — a cancelled prompt now rejects. --- app/lib/services/restApi.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index c6f087b8209..dbb08e288cd 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1038,7 +1038,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 From 902cb1ec57cadaca881fee9abb53d91a4b11261c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 14:47:30 -0300 Subject: [PATCH 14/35] fix(2fa): cover the cancellation paths that bypass central suppression Three two-factor-gated paths still reported a deliberate cancellation, because each one loses the error before it reaches the shared error reporting. The E2E encryption password change caught into a hardcoded message. The avatar helper re-wrapped every unrecognised error into a generic translated one, which destroyed the cancellation's identity; it now rethrows a cancellation untouched. The login saga updated custom fields after dispatching loginSuccess, so cancelling there marked an already-successful login as failed and dropped the fields. --- app/sagas/login.js | 11 +++++++++-- app/views/ChangeAvatarView/submitHelpers.ts | 4 ++++ .../E2EEncryptionSecurityView/ChangePassword.tsx | 4 ++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/sagas/login.js b/app/sagas/login.js index 6d5f1f8e7d9..76140daa215 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -28,6 +28,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 +124,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) { 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/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')); } From abcf41d315c55c4ea3b592c4a17cfee70e49ccb0 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 15:14:53 -0300 Subject: [PATCH 15/35] fix(upload): validate auth headers when the upload is sent Asserting in the constructor threw before the caller could store the instance in its upload queue, so sendFileMessage and sendFileMessageV2 read the missing queue entry as a user cancellation and swallowed the error instead of persisting and rethrowing it. --- app/lib/methods/helpers/fileUpload/index.test.ts | 12 +++++------- app/lib/methods/helpers/fileUpload/index.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/lib/methods/helpers/fileUpload/index.test.ts b/app/lib/methods/helpers/fileUpload/index.test.ts index 3284d5ad8e8..142adb79228 100644 --- a/app/lib/methods/helpers/fileUpload/index.test.ts +++ b/app/lib/methods/helpers/fileUpload/index.test.ts @@ -1,5 +1,4 @@ import FileUpload, { MissingUploadAuthHeadersError } from './index'; -import { Upload } from './Upload'; const mockSetupRequest = jest.fn(); const mockAppendFile = jest.fn(); @@ -25,12 +24,11 @@ describe('FileUpload', () => { ['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 build a request when %s', (_, headers) => { - expect(() => new FileUpload('https://open.rocket.chat/api/v1/users.setAvatar', headers, formData)).toThrow( - MissingUploadAuthHeadersError - ); - expect(Upload).not.toHaveBeenCalled(); - expect(mockSetupRequest).not.toHaveBeenCalled(); + ])('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 () => { diff --git a/app/lib/methods/helpers/fileUpload/index.ts b/app/lib/methods/helpers/fileUpload/index.ts index cb2e396b9b0..46edcba2095 100644 --- a/app/lib/methods/helpers/fileUpload/index.ts +++ b/app/lib/methods/helpers/fileUpload/index.ts @@ -23,20 +23,23 @@ const assertAuthHeaders = (headers: TUploadHeaders): void => { class FileUpload { private upload: Upload; + private headers: TUploadHeaders; + constructor( url: string, headers: TUploadHeaders, data: IFormData[], progressCallback?: (loaded: number, total: number) => void ) { - assertAuthHeaders(headers); + this.headers = headers; this.upload = new Upload(); 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 { From 654e8c03b263f5d989e1c56241636a79204eeff3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 19 Aug 2026 15:15:06 -0300 Subject: [PATCH 16/35] fix(2fa): stop alerting when the avatar prompt is cancelled The view reports failures through showErrorAlert, which carries no cancellation guard, so dismissing the two-factor prompt during an avatar change still raised an alert. --- app/views/ChangeAvatarView/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) 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 { From b47c77607d33bef63be2c629e257cf386f079824 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 20 Aug 2026 10:32:54 -0300 Subject: [PATCH 17/35] chore: adopt the SDK's login types and test against the real lib (#7582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(types): describe the login payloads and result the app actually sends Adopt the login types from @rocket.chat/sdk (bumped to mobile HEAD 176bdfe4) and delete the two casts that stood in for them. `toLoginResult` and `toSdkCredentials` converted nothing. The second one compiled only by coincidence: the SDK's old flat `ICredentials` declares `password` and `username` as required, so every login — saml, cas, apple, oauth, resume — was typed as if it carried both. - `ICredentials` is now the SDK's `ILoginCredentials` union, so each producer builds the member it means and the guards land where the information exists. - `ILoggedUser.username` is optional: a user who registered without choosing one has none, which `isRegisterUser` already assumed. - Apple sends `{}` rather than `null` for `fullName`. Apple returns null on every sign-in after the first and the server destructures it unguarded, so repeat Apple logins failed with a generic error. - `parseSamlOrCasRedirect` returns null instead of a payload with no credential token, which is a login the server cannot complete. - The 2FA retry always normalizes to `{ user, password, code }`. It used to keep the ldap/crowd shape when the server predated 3.9.0, and `compareServerVersion` reads an absent version as "older", so that branch also ran whenever the version was not yet known — sending a top-level `code` that the server's 2FA gate ignores. The SDK also renamed the client's realtime field to `driver` and the driver's socket to `socket`, and `Driver.subscribe` now declares `eventname` before its rest args; `subscribe` keeps it optional because `activeUsers` subscribes without one. * refactor: keep the login status pass-through and name the credentials union The status mapping defaulted an absent or unrecognised status to 'offline'. Nothing did that before — the old cast declared `status` required and assigned the server's value straight through — so restore the pass-through and leave `ILoggedUser.status` as it was. Whether that field should be optional is a separate question with its own fallout in StatusView. `ICredentials` already named a local interface in actions/login.ts and a different type in the SDK, so the union goes by its own name instead. * fix: report a missing Apple identity token instead of throwing into the catch The guard threw from inside a catch that only fires the failure event, so it read as error handling but produced nothing — and it was indistinguishable from the user dismissing the Apple dialog, which is why that catch is bare. * test: share the SDK integration scaffolding across the four suites Each integration test carried its own copy of the mock connection, the DDP frame helpers, and the collection/store builders. Move them into app/lib/testUtils/sdkIntegration.ts and rename WireFrame to DdpMessage. Jest mock registration stays per file and delegates to the shared MockConnection through jest.requireActual so hoisting rules hold. * refactor: finish the integration-helper sharing and review nits Move buildConnectedDriver, addMediaSubs, backdateLastPing and stopAnsweringFrames into the shared module, prefix its interfaces with the project I, restore the 2FA code-clearing rationale, and give the user-presence listener its typed message shape back via the app wrapper, whose callback type accepts it. * refactor: name the wire frames, trim a version-fragile comment, flatten a fallback eventname to eventName in the subscribe wrapper, data to frame in the shared mock connection, drop the alive() formula quote from the socket health rationale, and spell out the preferred/fallback chain in getUserDisplayName. * refactor: drop the vocabulary footer and type the presence listener The exported names already carry the terms, and the wrapper's return type describes the listener promise on its own. * fix: keep mute working without a username and drop dead SAML fallback --- app/containers/Avatar/useAvatarETag.ts | 2 +- app/containers/LoginServices/serviceLogin.ts | 6 +- app/containers/TwoFactor/index.tsx | 9 +- app/definitions/ICredentials.ts | 24 --- app/definitions/ILoggedUser.ts | 18 +- app/definitions/ILoginCredentials.ts | 12 ++ app/definitions/IProfile.ts | 2 +- app/definitions/index.ts | 2 +- app/lib/hooks/useUserData.ts | 3 + app/lib/methods/helpers/events.ts | 4 +- app/lib/methods/helpers/isReadOnly.ts | 6 +- .../helpers/parseSamlOrCasRedirect.test.ts | 7 +- .../methods/helpers/parseSamlOrCasRedirect.ts | 16 +- app/lib/methods/logout.ts | 23 +-- .../roomSubscription.integration.test.ts | 113 +++--------- .../__tests__/connect.integration.test.ts | 102 +---------- .../socketHealth.integration.test.ts | 130 +++----------- .../services/__tests__/socketHealth.test.ts | 62 +++---- app/lib/services/connect.test.ts | 1 + app/lib/services/connect.ts | 61 ++++--- app/lib/services/restApi.ts | 5 +- app/lib/services/sdk.ts | 7 +- app/lib/services/socketHealth.ts | 30 ++-- app/lib/services/toLoginResult.ts | 6 - app/lib/services/toSdkCredentials.ts | 5 - app/lib/services/twoFactor.ts | 4 +- .../voip/MediaSessionInstance.test.ts | 2 +- .../voip/acceptNativeCall.integration.test.ts | 4 +- .../acceptNativeCall.sdk.integration.test.ts | 103 ++--------- .../services/voip/acceptNativeCall.test.ts | 10 +- app/lib/services/voip/acceptNativeCall.ts | 10 +- app/lib/services/waitForLoginReady.ts | 2 +- app/lib/testUtils/sdkIntegration.ts | 167 ++++++++++++++++++ app/views/AuthenticationWebView.tsx | 6 +- app/views/ForwardLivechatView.tsx | 2 +- .../ProfileView/methods/buildProfileParams.ts | 2 +- app/views/RoomMembersView/helpers.ts | 5 +- app/views/RoomMembersView/index.tsx | 6 +- app/views/SelectedUsersView/index.tsx | 2 +- app/views/ShareView/index.tsx | 6 +- package.json | 2 +- pnpm-lock.yaml | 10 +- 42 files changed, 410 insertions(+), 589 deletions(-) delete mode 100644 app/definitions/ICredentials.ts create mode 100644 app/definitions/ILoginCredentials.ts delete mode 100644 app/lib/services/toLoginResult.ts delete mode 100644 app/lib/services/toSdkCredentials.ts create mode 100644 app/lib/testUtils/sdkIntegration.ts diff --git a/app/containers/Avatar/useAvatarETag.ts b/app/containers/Avatar/useAvatarETag.ts index 4b8a2596c91..a4e9261a33d 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; diff --git a/app/containers/LoginServices/serviceLogin.ts b/app/containers/LoginServices/serviceLogin.ts index fad1a839ac3..b3b05928ce1 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.tsx b/app/containers/TwoFactor/index.tsx index fdc3bb6da8e..fe158025920 100644 --- a/app/containers/TwoFactor/index.tsx +++ b/app/containers/TwoFactor/index.tsx @@ -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; @@ -88,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')); 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/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/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/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/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/logout.ts b/app/lib/methods/logout.ts index 75a1dc71a52..e036669ad24 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -8,7 +8,6 @@ import database, { getDatabase } from '../database'; import log from './helpers/log'; import { disconnect } from '../services/connect'; import sdk from '../services/sdk'; -import { toSdkCredentials } from '../services/toSdkCredentials'; import { CURRENT_SERVER, E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, TOKEN_KEY } from '../constants/keys'; import UserPreferences from './userPreferences'; import { removePushToken } from '../services/restApi'; @@ -67,18 +66,20 @@ export async function removeServer({ server }: { server: string }): Promise jest.fn().mockImplementation(() => { - const connection = { - send: jest.fn((data: string) => { - const message = JSON.parse(data) as { msg: string; id?: string; method?: 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] }) })); - } else if (message.msg === 'unsub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'nosub', id: 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); }) ); @@ -95,83 +73,32 @@ import { getMessageById } from '../../../database/services/Message'; import buildMessage from '../../helpers/buildMessage'; import { subscribeRoom, unsubscribeRoom } from '../../../../actions/room'; import { clearUserTyping } from '../../../../actions/usersTyping'; -import type { IApplicationState } from '../../../../definitions'; +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'; // eslint-disable-next-line @typescript-eslint/no-var-requires const database = require('../../../database').default as { active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; }; -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?: unknown[]; -} - const mockConnections: MockConnection[] = []; -function makeReduxStore() { - const listeners = new Set<() => void>(); - const state = { - login: { user: null as Record | null, isAuthenticated: false }, - server: { version: '5.0.0' }, - settings: {} as Record, - room: { subscribedRoom: 'room-rid' as string | null } - }; - return { - state, - store: { - getState: () => state, - dispatch: jest.fn(), - subscribe: (listener: () => void) => { - listeners.add(listener); - return () => listeners.delete(listener); - } - } as unknown as Store - }; -} - -async function flush(turns = 10) { - for (let i = 0; i < turns; i++) { - await Promise.resolve(); - await jest.advanceTimersByTimeAsync(0); - } -} - -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); -} - -function receiveFrame(connection: MockConnection, frame: Record) { - connection.onmessage({ data: JSON.stringify(frame) }); -} - -function makeCollection(name: string) { - return { - name, - find: jest.fn(), - query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), - create: jest.fn(), - prepareCreate: jest.fn((fn: (record: Record) => void) => { - const record = { _raw: { id: '' }, subscription: { id: '' } }; - fn(record); - return record; - }), - schema: { columnArray: [] } - }; +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 = { diff --git a/app/lib/services/__tests__/connect.integration.test.ts b/app/lib/services/__tests__/connect.integration.test.ts index c9653a43f65..512c959ed20 100644 --- a/app/lib/services/__tests__/connect.integration.test.ts +++ b/app/lib/services/__tests__/connect.integration.test.ts @@ -1,5 +1,3 @@ -import type { Store } from 'redux'; - jest.unmock('@rocket.chat/sdk'); import { connect, login, loginWithPassword } from '../connect'; @@ -11,56 +9,16 @@ import { setActiveUsers } from '../../../actions/activeUsers'; import { updateSettings } from '../../../actions/settings'; import { updatePermission } from '../../../actions/permissions'; import { _activeUsers, _setUserTimer } from '../../methods/setUser'; -import type { IApplicationState } from '../../../definitions'; - -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: (event?: { code?: number }) => void; -} - -interface WireFrame { - msg: string; - id?: string; - name?: string; - method?: string; - params?: unknown[]; -} +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[] = []; -const DDP_LOGIN_RESULT = { id: 'user-id', token: 'auth-token' }; - 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; method?: 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] }) })); - } else if (message.msg === 'method' && message.method === 'login') { - setImmediate(() => - connection.onmessage({ data: JSON.stringify({ msg: 'result', id: message.id, result: DDP_LOGIN_RESULT }) }) - ); - } - }), - 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); }) ); @@ -126,56 +84,6 @@ const REST_LOGIN_ME = { requirePasswordChange: false }; -function makeReduxStore() { - const listeners = new Set<() => void>(); - const state = { - meteor: { connected: false }, - login: { user: null as Record | null, isAuthenticated: false }, - server: { version: '5.0.0' }, - settings: {} as Record, - room: { subscribedRoom: null as string | 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 } - }; -} - -async function flush(turns = 10) { - for (let i = 0; i < turns; i++) { - await Promise.resolve(); - await jest.advanceTimersByTimeAsync(0); - } -} - -function framesOn(connection: MockConnection, msg: string) { - return connection.send.mock.calls - .map(([data]: [string]) => JSON.parse(data) as WireFrame) - .filter(message => message.msg === msg); -} - -function receiveFrame(connection: MockConnection, frame: Record) { - connection.onmessage({ data: JSON.stringify(frame) }); -} - -function makeCollection(name: string) { - return { - name, - find: jest.fn(), - query: jest.fn(() => ({ fetch: jest.fn(() => Promise.resolve([])) })), - create: jest.fn(), - prepareCreate: jest.fn(), - schema: {} - }; -} - let redux: ReturnType; let collections: Record>; diff --git a/app/lib/services/__tests__/socketHealth.integration.test.ts b/app/lib/services/__tests__/socketHealth.integration.test.ts index 13fef8a84dc..90eadc278e0 100644 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ b/app/lib/services/__tests__/socketHealth.integration.test.ts @@ -1,67 +1,21 @@ import sdk from '../sdk'; import { recoverSocket } from '../socketHealth'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { - Driver: new (options: { host: string; logger: unknown }) => SdkDriver; -}; - -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 SdkDriver { - 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 { MockConnection, ISdkDriver } 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); }) ); @@ -74,58 +28,20 @@ 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() }; - -async function buildConnectedDriver() { - const driver = new Driver({ 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: SdkDriver) { - ['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: SdkDriver, ageMs: number) { - driver.ddp.lastPing = Date.now() - ageMs; -} - -function stopAnsweringFrames(connection: MockConnection) { - connection.send.mockImplementation(() => undefined); -} - -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 SDK socket', () => { - let driver: SdkDriver; + let driver: ISdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); mockConnections.length = 0; - driver = await buildConnectedDriver(); - (sdk as unknown as { current: { ddp: SdkDriver } }).current = { ddp: driver }; + driver = await buildConnectedDriver(mockConnections, USER_ID); + (sdk as unknown as { current: { driver: ISdkDriver } }).current = { 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(); }); @@ -209,7 +125,7 @@ describe('recoverSocket against the real SDK socket', () => { 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); @@ -230,7 +146,7 @@ describe('recoverSocket against the real SDK 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); @@ -238,7 +154,7 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); - const resubscribed = driver.waitForNotifyUserMediaSubs(); + const resubscribed = driver.waitForNotifyUserMediaSubs!(); await jest.advanceTimersByTimeAsync(200); await expect(resubscribed).resolves.toBe(true); @@ -273,11 +189,11 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); - const resubscribed = driver.waitForNotifyUserMediaSubs(1000); + const resubscribed = driver.waitForNotifyUserMediaSubs!(1000); await jest.advanceTimersByTimeAsync(100); expect(framesOn(mockConnections[1], 'sub')).toHaveLength(0); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); await jest.advanceTimersByTimeAsync(200); await expect(resubscribed).resolves.toBe(true); @@ -289,7 +205,7 @@ describe('recoverSocket against the real SDK socket', () => { it('resolves false when the reopened socket never acks the re-sub', async () => { backdateLastPing(driver, PING_INTERVAL * 3); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); const recovery = recoverSocket(); await jest.advanceTimersByTimeAsync(0); @@ -299,7 +215,7 @@ describe('recoverSocket against the real SDK socket', () => { stopAnsweringFrames(mockConnections[1]); - const resubscribed = driver.waitForNotifyUserMediaSubs(500); + const resubscribed = driver.waitForNotifyUserMediaSubs!(500); await jest.advanceTimersByTimeAsync(500); await expect(resubscribed).resolves.toBe(false); diff --git a/app/lib/services/__tests__/socketHealth.test.ts b/app/lib/services/__tests__/socketHealth.test.ts index f29f32e9822..dfb21effd7f 100644 --- a/app/lib/services/__tests__/socketHealth.test.ts +++ b/app/lib/services/__tests__/socketHealth.test.ts @@ -1,7 +1,7 @@ jest.mock('../sdk', () => ({ __esModule: true, default: { - current: { ddp: undefined } + current: { driver: undefined } } })); @@ -12,9 +12,9 @@ import { classifySocketHealth, recoverSocket } from '../socketHealth'; const now = 1_000_000; -const sdkMock = sdk as unknown as { current: { ddp: unknown } | undefined }; +const sdkMock = sdk as unknown as { current: { driver: unknown } | undefined }; -interface MockDdp { +interface MockDriver { connected: boolean; lastPing: number; pingInterval: number; @@ -22,7 +22,7 @@ interface MockDdp { probe: jest.Mock, [number]>; } -function makeDdp(overrides: Partial = {}): MockDdp { +function makeDriver(overrides: Partial = {}): MockDriver { return { connected: true, lastPing: now, @@ -43,52 +43,52 @@ describe('classifySocketHealth', () => { }); it('returns round-trip-check for a connected socket rather than trusting it outright', () => { - const ddp = makeDdp({ connected: true }); - expect(classifySocketHealth(ddp as unknown as Driver)).toBe('round-trip-check'); + const driver = makeDriver({ connected: true }); + expect(classifySocketHealth(driver as unknown as Driver)).toBe('round-trip-check'); }); it('returns reopen for a closed socket even when lastPing is fresh', () => { - const ddp = makeDdp({ connected: false, lastPing: now }); - expect(classifySocketHealth(ddp as unknown as Driver)).toBe('reopen'); + const driver = makeDriver({ connected: false, lastPing: now }); + expect(classifySocketHealth(driver as unknown as Driver)).toBe('reopen'); }); }); describe('recoverSocket', () => { - let ddp: MockDdp; + let driver: MockDriver; beforeEach(() => { - ddp = makeDdp({ lastPing: Date.now() }); - sdkMock.current = { ddp }; + driver = makeDriver({ lastPing: Date.now() }); + sdkMock.current = { driver }; }); it('keeps a socket whose round trip answers', async () => { await expect(recoverSocket()).resolves.toBe('confirmed-alive'); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + expect(driver.reopenNow).not.toHaveBeenCalled(); }); it('runs the round trip with a 2s budget', async () => { await recoverSocket(); - expect(ddp.probe).toHaveBeenCalledWith(2000); + expect(driver.probe).toHaveBeenCalledWith(2000); }); it('reopens when the round trip goes unanswered', async () => { - ddp.probe.mockResolvedValue(false); + driver.probe.mockResolvedValue(false); await expect(recoverSocket()).resolves.toBe('reopened'); - expect(ddp.reopenNow).toHaveBeenCalledTimes(1); + expect(driver.reopenNow).toHaveBeenCalledTimes(1); }); it('reopens a known-dead socket without a round trip', async () => { - ddp.connected = false; + driver.connected = false; await expect(recoverSocket()).resolves.toBe('reopened'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).toHaveBeenCalledTimes(1); + expect(driver.probe).not.toHaveBeenCalled(); + expect(driver.reopenNow).toHaveBeenCalledTimes(1); }); - it('reports no-socket when the ddp handle is missing', async () => { - sdkMock.current = { ddp: undefined }; + it('reports no-socket when the driver handle is missing', async () => { + sdkMock.current = { driver: undefined }; await expect(recoverSocket()).resolves.toBe('no-socket'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + expect(driver.probe).not.toHaveBeenCalled(); + expect(driver.reopenNow).not.toHaveBeenCalled(); }); it('reports no-socket when there is no sdk instance', async () => { @@ -97,31 +97,31 @@ describe('recoverSocket', () => { }); it('rejects when the round trip throws', async () => { - ddp.probe.mockRejectedValue(new Error('round trip failed')); + driver.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')); + driver.connected = false; + driver.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); + expect(driver.probe).toHaveBeenCalledTimes(1); }); it('starts a fresh recovery after the shared one settles', async () => { await recoverSocket(); await recoverSocket(); - expect(ddp.probe).toHaveBeenCalledTimes(2); + expect(driver.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))); + driver.probe.mockImplementation(() => new Promise(resolve => (answerRoundTrip = resolve))); const controller = new AbortController(); const aborted = recoverSocket({ abortSignal: controller.signal }); @@ -132,7 +132,7 @@ describe('recoverSocket', () => { answerRoundTrip(true); await expect(other).resolves.toBe('confirmed-alive'); - expect(ddp.probe).toHaveBeenCalledTimes(1); + expect(driver.probe).toHaveBeenCalledTimes(1); }); it('abandons a pre-aborted caller without touching the socket', async () => { @@ -140,7 +140,7 @@ describe('recoverSocket', () => { controller.abort(); await expect(recoverSocket({ abortSignal: controller.signal })).resolves.toBe('abandoned'); - expect(ddp.probe).not.toHaveBeenCalled(); - expect(ddp.reopenNow).not.toHaveBeenCalled(); + expect(driver.probe).not.toHaveBeenCalled(); + expect(driver.reopenNow).not.toHaveBeenCalled(); }); }); diff --git a/app/lib/services/connect.test.ts b/app/lib/services/connect.test.ts index 6a5db1babce..5e55395e8e0 100644 --- a/app/lib/services/connect.test.ts +++ b/app/lib/services/connect.test.ts @@ -37,6 +37,7 @@ jest.mock('./sdk', () => ({ default: { initialize: (server: string) => mockSdkInitialize(server), disconnect: () => mockSdkDisconnect(), + onStreamData: (event: string, cb: (...args: any[]) => void) => mockOnStreamData(event, cb), get current() { return mockSdkCurrent; } diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts index 633a53f4101..79c246de083 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -11,13 +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 { toLoginResult } from './toLoginResult'; -import { toSdkCredentials } from './toSdkCredentials'; +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'; @@ -50,7 +54,7 @@ let pendingHangupsConnectedListener: any; let usersListener: any; let notifyAllListener: any; let rolesListener: any; -let userPresenceListener: any; +let userPresenceListener: Promise | undefined; let notifyLoggedListener: any; let logoutListener: any; @@ -179,7 +183,7 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr ); // RC 4.1 - userPresenceListener = sdk.current.onStreamData('stream-user-presence', (ddpMessage: 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; @@ -296,11 +300,11 @@ function stopListener(listener: any): void { listener?.stop(); } -async function login(credentials: ICredentials): Promise { +async function login(credentials: ILoginCredentials): Promise { // RC 0.64.0 - await sdk.current.login(toSdkCredentials(credentials)); + await sdk.current.login(credentials); const serverVersion = store.getState().server.version; - const result = toLoginResult(sdk.current.currentLogin?.result); + const result = sdk.current.currentLogin?.result; if (!result) { throw new Error('Login failed: missing login result'); } @@ -318,7 +322,7 @@ async function login(credentials: ICredentials): Promise { username: result.me.username, name: result.me.name, language: result.me.language, - status: result.me.status, + status: result.me.status as TUserStatus, statusText: result.me.statusText, customFields: result.me.customFields, statusLivechat: result.me.statusLivechat, @@ -335,7 +339,19 @@ async function login(credentials: ICredentials): Promise { return user; } -async function loginTOTP(params: ICredentials, loginEmailPassword?: boolean): Promise { +function toPasswordLogin(params: ILoginCredentials): ICredentialsPasswordAPI | undefined { + if ('ldap' in params) { + return { user: params.username, password: params.ldapPass }; + } + if ('crowd' in params) { + return { user: params.username, password: params.crowdPassword }; + } + if ('password' in params) { + return params; + } +} + +async function loginTOTP(params: ILoginCredentials, loginEmailPassword?: boolean): Promise { try { return await login(params); } catch (e: any) { @@ -347,25 +363,16 @@ async function loginTOTP(params: ICredentials, loginEmailPassword?: boolean): Pr 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 }; - } + const passwordParams = loginEmailPassword ? toPasswordLogin(params) : undefined; + if (passwordParams) { + store.dispatch(setUser({ username: passwordParams.user || passwordParams.username })); - return loginTOTP({ ...params, code: code?.twoFactorCode }, loginEmailPassword); + return loginTOTP({ ...passwordParams, code: code?.twoFactorCode }, loginEmailPassword); } return loginTOTP({ totp: { - login: { - ...params - }, + login: params, code: code?.twoFactorCode } }); @@ -375,7 +382,7 @@ async function loginTOTP(params: ICredentials, loginEmailPassword?: boolean): Pr } 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) { @@ -396,7 +403,7 @@ 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)); } diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index dbb08e288cd..0eeb5e53607 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 }); }; diff --git a/app/lib/services/sdk.ts b/app/lib/services/sdk.ts index d41efa968d6..f9556c15493 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -15,7 +15,7 @@ import { } from '../../definitions/rest/helpers'; import { compareServerVersion, random } from '../methods/helpers'; -export type TDriver = Rocketchat['ddp']; +export type TDriver = Rocketchat['driver']; export type TStreamDataCallback = (ddpMessage: any) => void; @@ -120,6 +120,7 @@ class Sdk { 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(method, ...args, ...(code ? [code] : [])); @@ -159,8 +160,8 @@ class Sdk { return this.methodCall(method, ...parsedParams); } - subscribe(topic: string, ...args: any[]): Promise { - return this.current.subscribe(topic, ...args); + subscribe(topic: string, eventName?: string, ...args: any[]): Promise { + return this.current.subscribe(topic, eventName as string, ...args); } subscribeRaw(...args: any[]): Promise { diff --git a/app/lib/services/socketHealth.ts b/app/lib/services/socketHealth.ts index 73b3e77f6d7..51f6779766e 100644 --- a/app/lib/services/socketHealth.ts +++ b/app/lib/services/socketHealth.ts @@ -12,10 +12,9 @@ import sdk, { type TDriver } from './sdk'; */ export type SocketRecoveryPlan = 'reopen' | 'round-trip-check'; -export function classifySocketHealth(ddp: TDriver): SocketRecoveryPlan { - // `ddp.connected` already folds in the ping-age test (transportOpen && alive(), - // where alive() is `now - lastPing <= config.ping * 2`), so a stale ping lands here. - if (!ddp.connected) { +export function classifySocketHealth(driver: TDriver): SocketRecoveryPlan { + // `driver.connected` already folds in the ping-age test, so a stale ping lands here. + if (!driver.connected) { return 'reopen'; } // A connected socket is still verified by a round trip, never trusted outright: @@ -27,7 +26,7 @@ export function classifySocketHealth(ddp: TDriver): SocketRecoveryPlan { * 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. + * - `'no-socket'` — `sdk.current?.driver` undefined; nothing to recover. * - `'abandoned'` — caller's abort signal fired while waiting; the * underlying recovery (shared — see below) runs on. * @@ -44,20 +43,20 @@ function shareRecovery(): Promise { if (inFlightRecovery) { return inFlightRecovery; } - const ddp = sdk.current?.ddp; - if (!ddp) { + const driver = sdk.current?.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; @@ -106,12 +105,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/toLoginResult.ts b/app/lib/services/toLoginResult.ts deleted file mode 100644 index 797b8793a18..00000000000 --- a/app/lib/services/toLoginResult.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { type ILoginResultAPI } from '@rocket.chat/sdk/interfaces'; - -import { type ILoginResultFromServer } from '../../definitions/ILoggedUser'; - -export const toLoginResult = (result: ILoginResultAPI | null | undefined): ILoginResultFromServer | undefined => - (result ?? undefined) as unknown as ILoginResultFromServer | undefined; diff --git a/app/lib/services/toSdkCredentials.ts b/app/lib/services/toSdkCredentials.ts deleted file mode 100644 index 38b8076a118..00000000000 --- a/app/lib/services/toSdkCredentials.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { type ICredentials as ISdkCredentials } from '@rocket.chat/sdk/interfaces'; - -import { type ICredentials } from '../../definitions/ICredentials'; - -export const toSdkCredentials = (credentials: ICredentials): ISdkCredentials => credentials as ISdkCredentials; diff --git a/app/lib/services/twoFactor.ts b/app/lib/services/twoFactor.ts index eced04aaeb7..4d2c91dd1dd 100644 --- a/app/lib/services/twoFactor.ts +++ b/app/lib/services/twoFactor.ts @@ -2,7 +2,7 @@ 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'; @@ -10,7 +10,7 @@ export { TwoFactorCancelledError, isTwoFactorCancelled } from './twoFactorCancel interface ITwoFactor { method: string; invalid: boolean; - params?: ICredentials; + params?: ILoginCredentials; } export const twoFactor = ({ method, invalid, params }: ITwoFactor): Promise<{ twoFactorCode: string; twoFactorMethod: string }> => diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 564bddb47d3..a8ab6a0d14d 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -70,7 +70,7 @@ jest.mock('../sdk', () => ({ }, get current() { return { - ddp: { + driver: { reopenNow: jest.fn(() => Promise.resolve()), probe: jest.fn(() => Promise.resolve(true)), lastPing: Date.now(), diff --git a/app/lib/services/voip/acceptNativeCall.integration.test.ts b/app/lib/services/voip/acceptNativeCall.integration.test.ts index ef988bc3825..89ffc492f29 100644 --- a/app/lib/services/voip/acceptNativeCall.integration.test.ts +++ b/app/lib/services/voip/acceptNativeCall.integration.test.ts @@ -105,7 +105,7 @@ describe('acceptNativeCallWithReadiness against real login readiness', () => { initStore(redux.store); mockGetCallState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); mockRecoverSocket.mockResolvedValue('reopened'); - (sdk as any).current = { ddp: mediaSubsAckAfter(100) }; + (sdk as any).current = { driver: mediaSubsAckAfter(100) }; }); afterEach(() => { @@ -148,7 +148,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() }; + (sdk as any).current = { driver: mediaSubsNeverAck() }; 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 index e8592a375a8..d13045ecc4e 100644 --- a/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts +++ b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts @@ -3,11 +3,9 @@ import { acceptNativeCallWithReadiness } from './acceptNativeCall'; import { useCallStore } from './useCallStore'; import { terminateNativeCall } from './terminateNativeCall'; import { waitForLoginReady } from '../waitForLoginReady'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { - Driver: new (options: { host: string; logger: unknown }) => SdkDriver; -}; +import { addMediaSubs, backdateLastPing, buildConnectedDriver, stopAnsweringFrames } from '../../testUtils/sdkIntegration'; +import type { MockConnection, ISdkDriver } from '../../testUtils/sdkIntegration'; +import type * as SdkIntegration from '../../testUtils/sdkIntegration'; jest.mock('../sdk', () => ({ __esModule: true, @@ -31,55 +29,12 @@ jest.mock('../../methods/helpers/log', () => ({ default: jest.fn() })); -interface MockConnection { - send: jest.Mock; - close: jest.Mock; - readyState: number; - onopen: () => void; - onmessage: (event: { data: string }) => void; - onerror: () => void; - onclose: () => void; -} - -interface SdkDriver { - userId: string; - pingInterval: number; - reopenNow(): Promise; - ddp: { - lastPing: number; - pingTimeout?: ReturnType; - openTimeout?: ReturnType; - open(): Promise; - subscriptions: Record; - }; -} - 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] }) })); - } else if (message.msg === 'unsub') { - setImmediate(() => connection.onmessage({ data: JSON.stringify({ msg: 'nosub', id: 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); }) ); @@ -91,8 +46,6 @@ const CALL_ID = 'call-uuid'; const USER_ID = 'user-id'; const PING_INTERVAL = 10000; -const logger = { debug: jest.fn(), info: jest.fn(), error: jest.fn(), warn: jest.fn() }; - interface IMediaSession { applyRestStateSignals: jest.Mock>; answerCall: jest.Mock, [string]>; @@ -110,51 +63,21 @@ function makeMediaSession(overrides: Partial = {}): IMediaSession }; } -async function buildConnectedDriver() { - const driver = new Driver({ 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: SdkDriver) { - ['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: SdkDriver, ageMs: number) { - driver.ddp.lastPing = Date.now() - ageMs; -} - -function stopAnsweringFrames(connection: MockConnection) { - connection.send.mockImplementation(() => undefined); -} - -let driver: SdkDriver; +let driver: ISdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); mockConnections.length = 0; - driver = await buildConnectedDriver(); - (sdk as unknown as { current: { ddp: SdkDriver } }).current = { ddp: driver }; + driver = await buildConnectedDriver(mockConnections, USER_ID); + (sdk as unknown as { current: { driver: ISdkDriver } }).current = { driver }; mockWaitForLoginReady.mockResolvedValue(true); mockGetState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); }); 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(); }); @@ -163,7 +86,7 @@ describe('acceptNativeCallWithReadiness against the real SDK socket', () => { const mediaSession = makeMediaSession(); backdateLastPing(driver, PING_INTERVAL * 3); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); const accept = acceptNativeCallWithReadiness(CALL_ID, mediaSession); await jest.advanceTimersByTimeAsync(0); @@ -185,7 +108,7 @@ describe('acceptNativeCallWithReadiness against the real SDK socket', () => { mockGetState.mockReturnValue({ call: null, resetNativeCallId }); backdateLastPing(driver, PING_INTERVAL * 3); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); const accept = acceptNativeCallWithReadiness(CALL_ID, mediaSession); await jest.advanceTimersByTimeAsync(0); @@ -215,7 +138,7 @@ describe('acceptNativeCallWithReadiness against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(100); - addMediaSubs(driver); + addMediaSubs(driver, USER_ID); await jest.advanceTimersByTimeAsync(200); await accept; diff --git a/app/lib/services/voip/acceptNativeCall.test.ts b/app/lib/services/voip/acceptNativeCall.test.ts index 026f4e9dc0b..8347571f4a7 100644 --- a/app/lib/services/voip/acceptNativeCall.test.ts +++ b/app/lib/services/voip/acceptNativeCall.test.ts @@ -9,7 +9,7 @@ const mockWaitForLoginReady = waitForLoginReady as jest.MockedFunction; const mockGetState = useCallStore.getState as jest.Mock; const mockTerminateNativeCall = terminateNativeCall as jest.Mock; -const mockDdp = () => sdk.current?.ddp as any; +const mockDriver = () => sdk.current?.driver as any; jest.mock('./useCallStore', () => ({ useCallStore: { @@ -24,7 +24,7 @@ jest.mock('./terminateNativeCall', () => ({ jest.mock('../sdk', () => ({ __esModule: true, default: { - current: { ddp: {} } + current: { driver: {} } } })); @@ -59,7 +59,7 @@ function makeMediaSession(overrides: Partial = {}): IMediaSession }; } -function makeDdp(overrides: Record = {}) { +function makeDriver(overrides: Record = {}) { return { waitForNotifyUserMediaSubs: jest.fn(() => Promise.resolve(true)), ...overrides @@ -80,7 +80,7 @@ describe('acceptNativeCallWithReadiness', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - (sdk as any).current = { ddp: makeDdp() }; + (sdk as any).current = { driver: makeDriver() }; mockRecoverSocket.mockResolvedValue('confirmed-alive'); mockWaitForLoginReady.mockResolvedValue(true); mockGetState.mockReturnValue(makeStoreState()); @@ -163,7 +163,7 @@ describe('acceptNativeCallWithReadiness', () => { }); it('terminates and ends the call when media-subscription ack times out', async () => { - mockDdp().waitForNotifyUserMediaSubs = jest.fn(() => Promise.resolve(false)); + mockDriver().waitForNotifyUserMediaSubs = jest.fn(() => Promise.resolve(false)); 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 e51f9a99d77..aa3a02242cf 100644 --- a/app/lib/services/voip/acceptNativeCall.ts +++ b/app/lib/services/voip/acceptNativeCall.ts @@ -15,7 +15,7 @@ export interface NativeCallMediaSession { const activeGates = new Map(); -async function waitForMediaSignalSubs(ddp: TDriver, timeoutMs: number, abortSignal?: AbortSignal): Promise { +async function waitForMediaSignalSubs(driver: TDriver, timeoutMs: number, abortSignal?: AbortSignal): Promise { if (abortSignal?.aborted) { return false; } @@ -25,7 +25,7 @@ async function waitForMediaSignalSubs(ddp: TDriver, timeoutMs: number, abortSign }); try { - return await Promise.race([ddp.waitForNotifyUserMediaSubs(timeoutMs), aborted]); + return await Promise.race([driver.waitForNotifyUserMediaSubs(timeoutMs), aborted]); } catch (error) { log(error); return false; @@ -65,14 +65,14 @@ export async function acceptNativeCallWithReadiness(callId: string, mediaSession return; } - const ddp = sdk.current?.ddp; - if (!ddp) { + const driver = sdk.current?.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..1559d7d6f7c 100644 --- a/app/lib/services/waitForLoginReady.ts +++ b/app/lib/services/waitForLoginReady.ts @@ -1,7 +1,7 @@ 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. +// Reads redux rather than `socket.loggedIn`: `close` clears `meteor.connected`, while `socket.loggedIn` survives it. // Neither survives a silent background death, so callers must bound their wait. export function isLoginReady(): boolean { const state = store.getState(); diff --git a/app/lib/testUtils/sdkIntegration.ts b/app/lib/testUtils/sdkIntegration.ts new file mode 100644 index 00000000000..c8dfc58d4f5 --- /dev/null +++ b/app/lib/testUtils/sdkIntegration.ts @@ -0,0 +1,167 @@ +import type { Store } from 'redux'; + +import type { IApplicationState } from '../../definitions'; + +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 ISdkDriver { + userId: string; + pingInterval: number; + reopenNow(): Promise; + waitForNotifyUserMediaSubs?(timeoutMs?: number): Promise; + socket: { + lastPing: number; + pingTimeout?: ReturnType; + openTimeout?: ReturnType; + open(): Promise; + send(message: Record): Promise; + subscriptions: Record; + }; +} + +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) }); +} + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { Driver } = require('@rocket.chat/sdk/lib/drivers/driver') as { + Driver: new (options: { host: string; logger: unknown }) => ISdkDriver; +}; + +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 Driver({ host: 'localhost:3000', logger: driverLogger }); + driver.userId = userId; + const openPromise = driver.socket.open(); + connections[0].onopen(); + await jest.advanceTimersByTimeAsync(0); + await openPromise; + return driver; +} + +export function addMediaSubs(driver: ISdkDriver, 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: ISdkDriver, 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 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/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/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/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/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/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 be7aec19005..9bb0ae07d97 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#383e457b3bb31598daacf2572d20644c795f58d2", + "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#176bdfe4b5cd2f47370266572cbcb94a5eee7322", "@rocket.chat/ui-kit": "^0.39.0", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d596939aa31..3eae3f8f453 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#383e457b3bb31598daacf2572d20644c795f58d2 - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2 + specifier: RocketChat/Rocket.Chat.js.SDK#176bdfe4b5cd2f47370266572cbcb94a5eee7322 + version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322 '@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,8 +2633,8 @@ packages: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2': - resolution: {gitHosted: true, integrity: sha512-in4lCtRYlY6PcCN3JfEtO3zVqyVjdic9BXxScWiD2y/BwwBqijgRFKckYm44v9GOwMllav92e9Dsfz8GyuBfhw==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/383e457b3bb31598daacf2572d20644c795f58d2} + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322': + resolution: {gitHosted: true, integrity: sha512-SAkGojmE6QbNMVNqz6Sgq2QDwcZ0S3kMLyJDNrCbTvcE18PJ9INoanXztzek65J9cZuq5Z2faeENVft8wlqZ+A==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322} version: 1.3.3-mobile '@rocket.chat/ui-kit@0.39.0': @@ -10517,7 +10517,7 @@ 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/383e457b3bb31598daacf2572d20644c795f58d2': + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322': dependencies: js-sha256: 0.9.0 tiny-events: 1.0.1 From 155f4ea936d271e8148b63d02e37e115a5ea1180 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 20 Aug 2026 11:18:30 -0300 Subject: [PATCH 18/35] chore: update @rocket.chat/sdk to mobile HEAD (#7583) * chore: update @rocket.chat/sdk to mobile HEAD The client exposes the realtime driver as `driver` instead of `ddp`, and `currentLogin.result` is typed as the login payload the SDK returns rather than the response envelope. `subscribeRaw` takes a name and params, so the wrapper forwards them by position. * docs: name the DDP Subscription and keep it apart from Subscription A Subscription is a membership record; a DDP Subscription is a live feed on Meteor Connect whose id the SDK derives from its stream and parameters. --- CONTEXT.md | 37 +++++++++++++++++++------------------ app/lib/services/sdk.ts | 4 ++-- package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ 4 files changed, 28 insertions(+), 27 deletions(-) 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/lib/services/sdk.ts b/app/lib/services/sdk.ts index f9556c15493..857a76fae47 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -164,8 +164,8 @@ class Sdk { return this.current.subscribe(topic, eventName as string, ...args); } - subscribeRaw(...args: any[]): Promise { - return this.current.subscribeRaw(...args); + subscribeRaw(name: string, params: any[]): Promise { + return this.current.subscribeRaw(name, params); } subscribeRoom(...args: any[]) { diff --git a/package.json b/package.json index 9bb0ae07d97..ecc56ddcede 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#176bdfe4b5cd2f47370266572cbcb94a5eee7322", + "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#4a59115412d75e3e7f62e5416400e27068b4eae3", "@rocket.chat/ui-kit": "^0.39.0", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eae3f8f453..3a69243228e 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#176bdfe4b5cd2f47370266572cbcb94a5eee7322 - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322 + specifier: RocketChat/Rocket.Chat.js.SDK#4a59115412d75e3e7f62e5416400e27068b4eae3 + version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4a59115412d75e3e7f62e5416400e27068b4eae3 '@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,8 +2633,8 @@ packages: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322': - resolution: {gitHosted: true, integrity: sha512-SAkGojmE6QbNMVNqz6Sgq2QDwcZ0S3kMLyJDNrCbTvcE18PJ9INoanXztzek65J9cZuq5Z2faeENVft8wlqZ+A==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/176bdfe4b5cd2f47370266572cbcb94a5eee7322} + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4a59115412d75e3e7f62e5416400e27068b4eae3': + resolution: {gitHosted: true, integrity: sha512-sHIAc9rohA38ytCcs8Zm9IoCYWkL1n5IQYd04O7d+Gfjb/2ePpGqLIBuv1XpiBNaiTS5GluBXFzhqhC9CAE1Ew==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4a59115412d75e3e7f62e5416400e27068b4eae3} version: 1.3.3-mobile '@rocket.chat/ui-kit@0.39.0': @@ -6446,7 +6446,7 @@ packages: react-native: '*' react-native-image-crop-picker@https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/47092e8c90550a54d45fe307f7cb2a24c9535ed5: - resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/47092e8c90550a54d45fe307f7cb2a24c9535ed5} + resolution: {gitHosted: true, integrity: sha512-8ZHDglb624R22yARJlIeI6cDX0UjUY3m7Iq5wUI9IhjA4/ZZKHQ8GHiq9k6C/LwjTjoaJ/wXrl1zYQ2LNS9FbQ==, tarball: https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/47092e8c90550a54d45fe307f7cb2a24c9535ed5} version: 0.51.1 peerDependencies: react: '*' @@ -10517,7 +10517,7 @@ 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/176bdfe4b5cd2f47370266572cbcb94a5eee7322': + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4a59115412d75e3e7f62e5416400e27068b4eae3': dependencies: js-sha256: 0.9.0 tiny-events: 1.0.1 From 6d3cbd556632942488cc372c85039ee9ea4da423 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 20 Aug 2026 16:52:29 -0300 Subject: [PATCH 19/35] chore: narrow subscribeSettings and drop its unused SDK type import (#7584) --- app/lib/methods/getSettings.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/lib/methods/getSettings.ts b/app/lib/methods/getSettings.ts index 5960f55950e..eb016722026 100644 --- a/app/lib/methods/getSettings.ts +++ b/app/lib/methods/getSettings.ts @@ -1,5 +1,4 @@ import { Q } from '@nozbe/watermelondb'; -import { type ISubscription } from '@rocket.chat/sdk/interfaces'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { addSettings, clearSettings } from '../../actions/settings'; @@ -144,8 +143,8 @@ export async function setSettings(): Promise { reduxStore.dispatch(addSettings(parseSettings(parsed.slice(0, parsed.length)))); } -export function subscribeSettings(): Promise { - 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; From e5e5929f5b92bb477fd2cf00b06cd29b57e6db85 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 20 Aug 2026 16:53:08 -0300 Subject: [PATCH 20/35] test: use the app's own TDriver instead of reaching into SDK internals (#7585) socketHealth.test.ts imported the Driver type from a deep path inside @rocket.chat/sdk to describe a value whose type the app already exports. TDriver is derived from the public client and is the exact parameter type of the function under test, so the import bought nothing. The two remaining deep reaches stay: they need the Driver class at runtime, and the SDK root exports only settings and Rocketchat, so no public route exists. Verified by asserting Driver, TDriver and the function's parameter type are mutually identical under tsc, with a deliberately falsified control to confirm the check could fail. --- app/lib/services/__tests__/socketHealth.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/lib/services/__tests__/socketHealth.test.ts b/app/lib/services/__tests__/socketHealth.test.ts index dfb21effd7f..5f72e80a3ad 100644 --- a/app/lib/services/__tests__/socketHealth.test.ts +++ b/app/lib/services/__tests__/socketHealth.test.ts @@ -5,9 +5,7 @@ jest.mock('../sdk', () => ({ } })); -import type { Driver } from '@rocket.chat/sdk/lib/drivers/driver'; - -import sdk from '../sdk'; +import sdk, { type TDriver } from '../sdk'; import { classifySocketHealth, recoverSocket } from '../socketHealth'; const now = 1_000_000; @@ -44,12 +42,12 @@ describe('classifySocketHealth', () => { it('returns round-trip-check for a connected socket rather than trusting it outright', () => { const driver = makeDriver({ connected: true }); - expect(classifySocketHealth(driver as unknown as Driver)).toBe('round-trip-check'); + expect(classifySocketHealth(driver as unknown as TDriver)).toBe('round-trip-check'); }); it('returns reopen for a closed socket even when lastPing is fresh', () => { const driver = makeDriver({ connected: false, lastPing: now }); - expect(classifySocketHealth(driver as unknown as Driver)).toBe('reopen'); + expect(classifySocketHealth(driver as unknown as TDriver)).toBe('reopen'); }); }); From 57409d7f1466ef4de642bc932104b95dbc3a90c7 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 20 Aug 2026 17:21:36 -0300 Subject: [PATCH 21/35] chore: remove lint suppressions for a disabled rule (#7586) * chore: remove lint suppressions for a disabled rule * chore: reach the SDK driver through the package root in tests --- .../__tests__/roomSubscription.integration.test.ts | 1 - app/lib/services/__tests__/connect.integration.test.ts | 1 - app/lib/testUtils/sdkIntegration.ts | 8 +++----- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts b/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts index fa375f9deca..9c56b86d252 100644 --- a/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts +++ b/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts @@ -83,7 +83,6 @@ import { import type { IMockCollection, MockConnection } from '../../../testUtils/sdkIntegration'; import type * as SdkIntegration from '../../../testUtils/sdkIntegration'; -// eslint-disable-next-line @typescript-eslint/no-var-requires const database = require('../../../database').default as { active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; }; diff --git a/app/lib/services/__tests__/connect.integration.test.ts b/app/lib/services/__tests__/connect.integration.test.ts index 512c959ed20..ade8f5717c5 100644 --- a/app/lib/services/__tests__/connect.integration.test.ts +++ b/app/lib/services/__tests__/connect.integration.test.ts @@ -61,7 +61,6 @@ jest.mock('../../database', () => ({ } })); -// eslint-disable-next-line @typescript-eslint/no-var-requires const database = require('../../database').default as { setActiveDB: jest.Mock; active: { get: jest.Mock; write: jest.Mock; batch: jest.Mock }; diff --git a/app/lib/testUtils/sdkIntegration.ts b/app/lib/testUtils/sdkIntegration.ts index c8dfc58d4f5..a08b676cb4e 100644 --- a/app/lib/testUtils/sdkIntegration.ts +++ b/app/lib/testUtils/sdkIntegration.ts @@ -1,3 +1,4 @@ +import type * as RocketChatSdk from '@rocket.chat/sdk'; import type { Store } from 'redux'; import type { IApplicationState } from '../../definitions'; @@ -67,15 +68,12 @@ export function receiveFrame(connection: MockConnection, frame: Record ISdkDriver; -}; +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 Driver({ host: 'localhost:3000', logger: driverLogger }); + const driver = new Rocketchat({ host: 'localhost:3000', logger: driverLogger }).driver as unknown as ISdkDriver; driver.userId = userId; const openPromise = driver.socket.open(); connections[0].onopen(); From f9930e8d449b2433e7c2271c37db15eed21c859c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 10:22:24 -0300 Subject: [PATCH 22/35] chore: bump @rocket.chat/sdk to mobile HEAD b6453cc3 --- package.json | 2 +- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index ecc56ddcede..0052abd6694 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#4a59115412d75e3e7f62e5416400e27068b4eae3", + "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#b6453cc3e07c31830ef663ae989ab129851a10a1", "@rocket.chat/ui-kit": "^0.39.0", "@zoontek/react-native-navigation-bar": "^1.1.1", "axios": "0.30.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a69243228e..5515e4fb0ad 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#4a59115412d75e3e7f62e5416400e27068b4eae3 - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4a59115412d75e3e7f62e5416400e27068b4eae3 + specifier: RocketChat/Rocket.Chat.js.SDK#b6453cc3e07c31830ef663ae989ab129851a10a1 + version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6453cc3e07c31830ef663ae989ab129851a10a1 '@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,8 +2633,8 @@ packages: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4a59115412d75e3e7f62e5416400e27068b4eae3': - resolution: {gitHosted: true, integrity: sha512-sHIAc9rohA38ytCcs8Zm9IoCYWkL1n5IQYd04O7d+Gfjb/2ePpGqLIBuv1XpiBNaiTS5GluBXFzhqhC9CAE1Ew==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/4a59115412d75e3e7f62e5416400e27068b4eae3} + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6453cc3e07c31830ef663ae989ab129851a10a1': + resolution: {gitHosted: true, integrity: sha512-LYKf9DO6w4hCKeDaLZJH4MeQwrA94LJeAmjYH6K1DEGeQH8oOK3F8jNyinK82XiRKJJlIiko4ZGE5zzxhTJ/Hw==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6453cc3e07c31830ef663ae989ab129851a10a1} version: 1.3.3-mobile '@rocket.chat/ui-kit@0.39.0': @@ -6446,7 +6446,7 @@ packages: react-native: '*' react-native-image-crop-picker@https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/47092e8c90550a54d45fe307f7cb2a24c9535ed5: - resolution: {gitHosted: true, integrity: sha512-8ZHDglb624R22yARJlIeI6cDX0UjUY3m7Iq5wUI9IhjA4/ZZKHQ8GHiq9k6C/LwjTjoaJ/wXrl1zYQ2LNS9FbQ==, tarball: https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/47092e8c90550a54d45fe307f7cb2a24c9535ed5} + resolution: {gitHosted: true, tarball: https://codeload.github.com/RocketChat/react-native-image-crop-picker/tar.gz/47092e8c90550a54d45fe307f7cb2a24c9535ed5} version: 0.51.1 peerDependencies: react: '*' @@ -10517,7 +10517,7 @@ 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/4a59115412d75e3e7f62e5416400e27068b4eae3': + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6453cc3e07c31830ef663ae989ab129851a10a1': dependencies: js-sha256: 0.9.0 tiny-events: 1.0.1 From dd5da3b084c90a2a7680e5e21befe8d682856ebd Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 12:07:51 -0300 Subject: [PATCH 23/35] test: cover multi-workspace switching (#7590) * chore(e2e): trigger server-switch tests on switch-path changes * test(login): cover switch cancelling the login bootstrap * test(logout): cover removeServerData key scoping * test(selectServer): cover target-workspace user resolution and failure invariant * test(deepLinking): cover unknown host handing off to the add-server flow * test(selectServer): guard the redundant select against the real SDK host * test(selectServer): cover the offline version fallback * test(login): cancel the saga task after each case * test: share the saga store helper and tighten the fallback assertions * test: finish the shared saga store migration and cancel every saga task * test: own the saga task lifecycle in the shared helper * test: tighten the shared helper types and the suite setup * test: declare the cleared keys before use and restore the spied emitter * test(logout): pin the deliberate certificate retention * test: drop the redundant flushes and the unused store preload flushSagaMicrotasks now drains twenty passes, so the back-to-back calls inherited from the two-pass version are no-ops. createRecordingStore's preloadedState had no callers. * test: name the recorded actions for what they are * test: annotate the test helper return types --- .sniffler/test-map.json | 8 +- app/lib/methods/logout.test.ts | 138 ++++++++++++ app/lib/testUtils/sagaStore.ts | 43 ++++ app/sagas/__tests__/deepLinking.test.ts | 116 +++++----- .../__tests__/login.switchCancel.test.ts | 164 ++++++++++++++ .../__tests__/selectServer.sdkHost.test.ts | 73 +++++++ app/sagas/__tests__/selectServer.test.ts | 204 ++++++++++++++++++ 7 files changed, 694 insertions(+), 52 deletions(-) create mode 100644 app/lib/methods/logout.test.ts create mode 100644 app/lib/testUtils/sagaStore.ts create mode 100644 app/sagas/__tests__/login.switchCancel.test.ts create mode 100644 app/sagas/__tests__/selectServer.sdkHost.test.ts create mode 100644 app/sagas/__tests__/selectServer.test.ts diff --git a/.sniffler/test-map.json b/.sniffler/test-map.json index 08de4f27bda..8a16fac1829 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" ] }, { diff --git a/app/lib/methods/logout.test.ts b/app/lib/methods/logout.test.ts new file mode 100644 index 00000000000..ce467e94aff --- /dev/null +++ b/app/lib/methods/logout.test.ts @@ -0,0 +1,138 @@ +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() +})); + +import { removeServerData } from './logout'; +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 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()); + }); +}); 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/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index e6ceccde958..0cddfdadac5 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -92,15 +92,12 @@ 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 { appStart } from '../../actions/app'; +import { APP, 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'; @@ -112,23 +109,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(); -} - -type PreloadedState = Parameters[1]; +const setupStore = (): RecordingStore => createRecordingStore(deepLinkingRoot); -function setupStore(preloadedState?: PreloadedState) { - const sagaMiddleware = createSagaMiddleware(); - const store = createStore(reducers, preloadedState, applyMiddleware(sagaMiddleware)); - sagaMiddleware.run(deepLinkingRoot); - return store; -} +afterEach(cancelSagaTasks); // ─── Factories ──────────────────────────────────────────────────────────────── @@ -201,7 +187,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,7 +209,7 @@ 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. + // Saga has dispatchedActions 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(); @@ -231,7 +217,6 @@ describe('deepLinking saga — Regression race (new server + token + room path)' // 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 +227,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 +247,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 +258,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 +275,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 +286,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 +310,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 +320,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 +337,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); @@ -414,14 +394,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 +412,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 +424,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 +434,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 +474,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 +488,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 +504,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 +516,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, { @@ -585,3 +553,51 @@ 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); + jest.mocked(sdk).current.client.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(); + }); +}); 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..069a23e2ad8 --- /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.current.client.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..204cd8e7822 --- /dev/null +++ b/app/sagas/__tests__/selectServer.test.ts @@ -0,0 +1,204 @@ +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 { 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' }); + }); +}); From ac504704ebc981b621d447a46a3ea506fc0cfd2c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 13:48:43 -0300 Subject: [PATCH 24/35] fix: server version recorded as undefined when falling back to another workspace (#7593) * fix(server): use the fallback workspace's recorded version The three auto-pick-another-workspace paths read `.version` off the record id string, so `selectServerRequest` always received `undefined`. Online the `/info` re-fetch masks it; when that re-fetch fails the stored version is undefined and every `compareServerVersion` gate silently takes the legacy branch. * test: clean up the preferences the fallback test writes --- .../__tests__/init.fallbackServer.test.ts | 50 +++++++++++++++++++ app/sagas/init.js | 4 +- app/sagas/login.js | 8 +-- 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 app/sagas/__tests__/init.fallbackServer.test.ts 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/init.js b/app/sagas/init.js index d9d6024abe8..4e4a89a38b8 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -36,10 +36,10 @@ const restore = function* restore() { // 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; + const { id: newServer, version } = servers[i]; userId = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); if (userId) { - return yield put(selectServerRequest(newServer, newServer.version)); + return yield put(selectServerRequest(newServer, version)); } } } diff --git a/app/sagas/login.js b/app/sagas/login.js index 76140daa215..4c02e2e697e 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -394,10 +394,10 @@ const handleLogout = function* handleLogout({ forcedByServer, message }) { // 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 { id: newServer, version } = servers[i]; const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); if (token) { - yield put(selectServerRequest(newServer, newServer.version)); + yield put(selectServerRequest(newServer, version)); return; } } @@ -461,10 +461,10 @@ const handleDeleteAccount = function* handleDeleteAccount() { // 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 { id: newServer, version } = servers[i]; const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); if (token) { - yield put(selectServerRequest(newServer, newServer.version)); + yield put(selectServerRequest(newServer, version)); return; } } From 6a4e29314930229b5d9c8834a344c2f67c18dfd9 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 14:03:39 -0300 Subject: [PATCH 25/35] fix(logout): keep other workspaces reachable after a forced logout (#7591) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(logout): keep other workspaces reachable after a forced logout The forcedByServer branch of handleLogout emitted the NewServer event without seeding previousServer, and serverFinishAdd had already nulled it on login. NewServerView gates its close button, its Android back handler and its layout on previousServer, so the user landed on a header-less screen with no way back to workspaces they were still logged in to. Seed previousServer with the first remaining server that still holds a token — the same predicate the non-forced branch already uses. The logged-out server itself is not a valid target: logout() destroys its record, token and database, so close() would find nothing and useConnectServer would skip its disconnect. When no other server is logged in, previousServer stays null and the screen correctly offers no way out. * test: move the forced-logout test onto the shared saga store harness * refactor(logout): collapse the duplicated logged-in-server lookup and assert previousServer directly handleLogout's non-forced branch hand-rolled the same 'first server that still holds a token' scan the new findLoggedInServer already performs, so it now calls the helper. selectServerRequest loses its second argument, which was always undefined because newServer was a string id. The forced-logout tests now assert the previousServer the view reads rather than the SERVER.INIT_ADD action that sets it. * fix(logout): pass the real server version when switching after a logout selectServerRequest declares version as required, and the reducer stores it, so omitting it left an offline switch landing with version undefined. findLoggedInServer already returns the record, so the value is at hand. * refactor(logout): look the remaining server up once for both logout branches --------- Co-authored-by: Diego Mello --- .../__tests__/login.forcedLogout.test.ts | 142 ++++++++++++++++++ app/sagas/login.js | 52 +++---- 2 files changed, 161 insertions(+), 33 deletions(-) create mode 100644 app/sagas/__tests__/login.forcedLogout.test.ts 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/login.js b/app/sagas/login.js index 4c02e2e697e..1b032e7b737 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -5,7 +5,7 @@ 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'; @@ -369,6 +369,12 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { } }; +const findLoggedInServer = function* findLoggedInServer() { + const serversCollection = database.servers.get('servers'); + const servers = yield serversCollection.query().fetch(); + return servers.find(({ id }) => UserPreferences.getString(`${TOKEN_KEY}-${id}`)); +}; + const handleLogout = function* handleLogout({ forcedByServer, message }) { yield put(encryptionStop()); yield put(appStart({ root: RootEnum.ROOT_LOADING, text: I18n.t('Logging_out') })); @@ -377,8 +383,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')); @@ -386,23 +397,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 { id: newServer, version } = servers[i]; - const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); - if (token) { - yield put(selectServerRequest(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) { @@ -453,23 +451,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 { id: newServer, version } = servers[i]; - const token = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); - if (token) { - yield put(selectServerRequest(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) { From c2e0de33954de6828c76aad550242074096c394d Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 14:54:24 -0300 Subject: [PATCH 26/35] fix: give every saga exit a user-facing root (#7592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: give every saga exit a terminal UI root restore() and handleShareExtension() each had an early exit that pushed no root-changing action. APP.START is the only thing that hides the boot splash and the only thing that moves the root off a loading value, so those exits stranded the app on a loading root with no recovery. The un-raced take(LOGIN.SUCCESS) in handleShareExtension had the same effect from a far more likely cause: SERVER.SELECT_FAILURE is handled only by the server reducer and never touches app.root, so a failed connect left the take waiting forever. It now races the two failure actions that selectServer and login actually emit. No timeout is added to the race. selectServer's catch always emits selectServerFailure, so the failure modes are covered by action, and a bare timer here would re-introduce the regression recorded at login.js:490. * test: name the saga exit assertions after user-facing roots * fix: close the remaining saga exits that skip a user-facing root restore()'s other-logged-in-server branch passed the server id where a record was expected, so selectServerRequest always received an undefined version, and its return skipped appReady and the pending push handling. handleShareExtension's race missed LOGOUT, which login.js emits instead of LOGIN.FAILURE for logged-out-by-server, expired-token, and 401-with-user, and its body was unguarded, so a throw from localAuthenticate or getServerById left the share sheet on the loading root. * fix: return to the server list when selecting a server fails from a loading root SERVER.SELECT_FAILURE only reaches reducers/server.ts, which never touches app.root, so restore()'s two selectServerRequest exits left the app on a loading root when the switch failed. handleSelectServer's catch now falls back to ROOT_OUTSIDE from the loading roots only, leaving inside and share-extension roots as they were. restore()'s other-server branch becomes a find, dropping the reuse of userId as a did-we-select flag. * fix: deliver the pending push notification instead of throwing into the boot catch The inner const shadowed the payload with removeItem's undefined result, so JSON.parse threw, restore()'s catch dispatched ROOT_OUTSIDE over the server it had just selected, and the notification was dropped. * fix: dispatch the pending push notification deep link call() built the OPEN_VIDEO_CONF action and discarded it, so the deepLinking watcher never ran. put() dispatches it, and a parse guard keeps a malformed stored payload from throwing into restore()'s catch and overriding the server it had just selected. * refactor: userId is no longer reassigned in restore * fix: drop the pending push notification when the boot lands outside The push handling now runs only when restore() reached a server, so a stored OPEN_VIDEO_CONF payload is cleared rather than dispatched into a session that does not exist. Adds coverage for the malformed-payload guard. * refactor: gate the push notification on the restored server, not the root Reading state.app.root after appReady only happened to be correct: on the selectServerRequest branches the connect is async, so the gate passed by timing. serverToRestore returns the record the branch resolved, so the push is gated on the branch actually taken. * refactor: let serverToRestore resolve the stored token itself The userId argument only ever carried a value the generator already reads for every other server. All three branches now return null rather than a mix of null and undefined. Covers the no-stored-server branch. * test: cover both no-token exits and name the token check isLoggedIn states the token lookup once for the guard and the find predicate. Resets the servers collection mock between cases so the no-stored-server test pins its own guard rather than a leaked mock. * fix: fall back to the server list from any root that is not user-facing At cold boot `app.root` is `undefined`, not `ROOT_LOADING` — nothing sets a loading root on that path, so the failed-switch guard never fired where the defect actually lands and `AppContainer` matched no navigator group. Gate on the roots worth keeping instead of the ones worth replacing. --- app/sagas/__tests__/deepLinking.test.ts | 98 ++++++++++- app/sagas/__tests__/init.test.ts | 201 +++++++++++++++++++++++ app/sagas/__tests__/selectServer.test.ts | 54 ++++++ app/sagas/deepLinking.js | 37 +++-- app/sagas/init.js | 58 ++++--- app/sagas/selectServer.ts | 4 + 6 files changed, 411 insertions(+), 41 deletions(-) create mode 100644 app/sagas/__tests__/init.test.ts diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 0cddfdadac5..406ea6dcf5a 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -93,14 +93,15 @@ jest.mock('../../lib/methods/helpers', () => ({ // ─── Real imports (after mocks) ─────────────────────────────────────────────── import { deepLinkingOpen, deepLinkingClickCallPush } from '../../actions/deepLinking'; -import { loginSuccess } from '../../actions/login'; -import { selectServerSuccess } from '../../actions/server'; +import { loginFailure, loginSuccess } from '../../actions/login'; +import { selectServerFailure, selectServerSuccess } from '../../actions/server'; import { appStart } from '../../actions/app'; -import { APP, SERVER } from '../../actions/actionsTypes'; +import { APP, LOGOUT, SERVER } from '../../actions/actionsTypes'; import { RootEnum } from '../../definitions'; import deepLinkingRoot from '../deepLinking'; import UserPreferences from '../../lib/methods/userPreferences'; import { getServerById } from '../../lib/database/services/Server'; +import { localAuthenticate } from '../../lib/methods/helpers/localAuthentication'; import { canOpenRoom } from '../../lib/methods/canOpenRoom'; import { getServerInfo } from '../../lib/methods/getServerInfo'; import { goRoom, navigateToRoom } from '../../lib/methods/helpers/goRoom'; @@ -601,3 +602,94 @@ describe('deepLinking saga — unknown host hands off to the add-server flow', ( emit.mockRestore(); }); }); + +describe('deepLinking saga — handleShareExtension user-facing roots', () => { + beforeEach(() => { + jest.mocked(UserPreferences.getString).mockReset(); + jest.mocked(getServerById).mockReset(); + jest.mocked(UserPreferences.getString).mockImplementation((key: string) => { + if (key === 'currentServer') return HOST; + return makeStoredUser(); + }); + jest.mocked(sdk).current.client.host = ''; + }); + + afterEach(() => { + cancelSagaTasks(); + jest.mocked(sdk).current.client.host = ''; + }); + + it('lands on ROOT_OUTSIDE, not the loading root, when the server record is missing', async () => { + jest.mocked(getServerById).mockResolvedValue(null as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when the login that the share sheet waits on fails', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + expect(store.getState().app.root).toBe(RootEnum.ROOT_LOADING_SHARE_EXTENSION); + + store.dispatch(loginFailure({ message: 'connect failed' })); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when selecting the server fails while the share sheet waits', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + store.dispatch(selectServerFailure()); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when the server logs the share sheet out instead of failing the login', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + store.dispatch({ type: LOGOUT }); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when local authentication throws', async () => { + jest.mocked(localAuthenticate).mockRejectedValueOnce(new Error('biometrics unavailable')); + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('still reaches ROOT_SHARE_EXTENSION when the login succeeds', async () => { + jest.mocked(getServerById).mockResolvedValue(makeServerRecord() as any); + const { store } = setupStore(); + + store.dispatch(deepLinkingOpen({ type: 'shareextension' } as any)); + await flushSagaMicrotasks(); + + store.dispatch(loginSuccess({ id: 'user-1', token: TOKEN } as any)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION); + }); +}); diff --git a/app/sagas/__tests__/init.test.ts b/app/sagas/__tests__/init.test.ts new file mode 100644 index 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__/selectServer.test.ts b/app/sagas/__tests__/selectServer.test.ts index 204cd8e7822..7302338e5a0 100644 --- a/app/sagas/__tests__/selectServer.test.ts +++ b/app/sagas/__tests__/selectServer.test.ts @@ -65,6 +65,8 @@ import { settings as RocketChatSettings } from '@rocket.chat/sdk'; import selectServerRoot from '../selectServer'; import { selectServerRequest } from '../../actions/server'; +import { appStart } from '../../actions/app'; +import { RootEnum } from '../../definitions'; import { SERVER } from '../../actions/actionsTypes'; import UserPreferences from '../../lib/methods/userPreferences'; import { BASIC_AUTH_KEY, setBasicAuth } from '../../lib/methods/helpers/fetch'; @@ -202,3 +204,55 @@ describe('selectServer saga — version and name fallback', () => { expect(success).toMatchObject({ server: SERVER_URL, version: '6.9.0', name: 'Stored A' }); }); }); + +describe('selectServer saga — user-facing root after a failed switch', () => { + beforeEach(() => { + UserPreferences.setString(`${TOKEN_KEY}-${SERVER_URL}`, USER_ID); + jest.mocked(getLoggedUserById).mockRejectedValue(new Error('database unavailable')); + }); + + it('lands on ROOT_OUTSIDE when the switch fails during boot, before any root is set', async () => { + const { store } = setupStore(); + expect(store.getState().app.root).toBeUndefined(); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when the switch fails while the app is on the loading root', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_LOADING })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('lands on ROOT_OUTSIDE when the switch fails while the share sheet is on its loading root', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_OUTSIDE); + }); + + it('keeps the current root when the switch fails while the app is already inside', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_INSIDE })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_INSIDE); + }); + + it('keeps the current root when the switch fails while the share sheet is up', async () => { + const { store } = setupStore(); + store.dispatch(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); + store.dispatch(selectServerRequest(SERVER_URL, '7.0.0', false)); + await flushSagaMicrotasks(); + + expect(store.getState().app.root).toBe(RootEnum.ROOT_SHARE_EXTENSION); + }); +}); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index fac9e3292db..680e6b5e80b 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -1,7 +1,7 @@ import { InteractionManager } from 'react-native'; import RNCallKeep from 'react-native-callkeep'; import I18n from 'i18n-js'; -import { all, call, delay, put, select, take, takeLatest } from 'redux-saga/effects'; +import { all, call, delay, put, race, select, take, takeLatest } from 'redux-saga/effects'; import { shareSetParams } from '../actions/share'; import * as types from '../actions/actionsTypes'; @@ -153,17 +153,32 @@ const handleShareExtension = function* handleOpen({ params }) { } yield put(appStart({ root: RootEnum.ROOT_LOADING_SHARE_EXTENSION })); - yield localAuthenticate(server); - const serverRecord = yield getServerById(server); - if (!serverRecord) { - return; - } - yield put(selectServerRequest(server, serverRecord.version)); - if (sdk.current?.client?.host !== server) { - yield take(types.LOGIN.SUCCESS); + try { + yield localAuthenticate(server); + const serverRecord = yield getServerById(server); + if (!serverRecord) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + return; + } + yield put(selectServerRequest(server, serverRecord.version)); + if (sdk.current?.client?.host !== server) { + const { loginSuccess } = yield race({ + loginSuccess: take(types.LOGIN.SUCCESS), + loginFailure: take(types.LOGIN.FAILURE), + selectServerFailure: take(types.SERVER.SELECT_FAILURE), + logout: take(types.LOGOUT) + }); + if (!loginSuccess) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + return; + } + } + yield put(shareSetParams(params)); + yield put(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); + } catch (e) { + log(e); + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } - yield put(shareSetParams(params)); - yield put(appStart({ root: RootEnum.ROOT_SHARE_EXTENSION })); }; const handleOpen = function* handleOpen({ params }) { diff --git a/app/sagas/init.js b/app/sagas/init.js index 4e4a89a38b8..7dc6ff9f66e 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -21,43 +21,47 @@ export const initLocalSettings = function* initLocalSettings() { yield put(setAllPreferences(sortPreferences)); }; +const isLoggedIn = server => !!UserPreferences.getString(`${TOKEN_KEY}-${server}`); + +const serverToRestore = function* serverToRestore(server) { + if (!server) { + return null; + } + + if (!isLoggedIn(server)) { + const serversDB = database.servers; + const serversCollection = serversDB.get('servers'); + const servers = yield serversCollection.query().fetch(); + + return servers.find(({ id }) => isLoggedIn(id)) || null; + } + + yield localAuthenticate(server); + return (yield getServerById(server)) || null; +}; + const restore = function* restore() { try { const server = UserPreferences.getString(CURRENT_SERVER); - let userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); + const restoredServer = yield* serverToRestore(server); - if (!server) { - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); - } else if (!userId) { - const serversDB = database.servers; - const serversCollection = serversDB.get('servers'); - const servers = yield serversCollection.query().fetch(); - - // Check if there're other logged in servers and picks first one - if (servers.length > 0) { - for (let i = 0; i < servers.length; i += 1) { - const { id: newServer, version } = servers[i]; - userId = UserPreferences.getString(`${TOKEN_KEY}-${newServer}`); - if (userId) { - return yield put(selectServerRequest(newServer, version)); - } - } - } - yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + if (restoredServer) { + yield put(selectServerRequest(restoredServer.id, restoredServer.version)); } else { - yield localAuthenticate(server); - const serverRecord = yield getServerById(server); - if (!serverRecord) { - return; - } - yield put(selectServerRequest(server, serverRecord.version)); + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); } yield put(appReady({})); const pushNotification = yield call(AsyncStorage.getItem, 'pushNotification'); if (pushNotification) { - const pushNotification = yield call(AsyncStorage.removeItem, 'pushNotification'); - yield call(deepLinkingClickCallPush, JSON.parse(pushNotification)); + yield call(AsyncStorage.removeItem, 'pushNotification'); + if (restoredServer) { + try { + yield put(deepLinkingClickCallPush(JSON.parse(pushNotification))); + } catch (e) { + log(e); + } + } } } catch (e) { log(e); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index 5373f6b0fcf..a203c59f3f9 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -218,6 +218,10 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch yield put(selectServerSuccess({ server, version: serverVersion, name: serverInfo?.name || 'Rocket.Chat' })); } catch (e) { yield put(selectServerFailure()); + const currentRoot = yield* appSelector(state => state.app.root); + if (currentRoot !== RootEnum.ROOT_INSIDE && currentRoot !== RootEnum.ROOT_SHARE_EXTENSION) { + yield put(appStart({ root: RootEnum.ROOT_OUTSIDE })); + } log(e); } }; From 0f2a862da1526656555e4247437ba3fffde1c6ae Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 21 Aug 2026 15:53:21 -0300 Subject: [PATCH 27/35] test: cover the background/foreground socket resume path (#7589) * test: cover the background/foreground socket resume path Adds the AppState enhancer unit test and three real-SDK integration scenarios for the foreground resume path: a silently dead socket that reopens after a failed round trip, an actually closed transport that reconnects and resumes the session, and a healthy socket that is left alone. * test: share the websocket mock and prove foreground drives the reconnect * test: make the foreground-resume suite prove what it claims Complete the resume-login round trip in the mock harness so the login actually succeeds and the scenarios assert the resumed user, drop the vacuous ordering and app-state assertions, and cover the foreground and background guards plus the session save and away-presence update. * test: drop the arbitrary sleep and the assertion that could not fail The resume scenarios waited a fixed 100ms for the login round trip, and closed by asserting isAuthenticated, which openSignedInSocket had already set. The wait is now a bounded drain of the pending timer queue, and the surviving assertion is the post-reset loginSuccess payload. Adds the boot-time app-state dispatch the middleware suite previously discarded, reuses the shared makeCollection and a shared latestConnection, and reverts the socketHealth index-to-helper churn. * test: wait for the resume to land instead of a fixed number of rounds settle() stopped early only when every timer had drained, which never happens on a connected socket - the SDK keeps its ping interval alive - so it always ran its full round count and was the arbitrary wait it replaced. settleUntil() takes the condition each call site is actually waiting for and keeps the round count as a cap. Also uses latestConnection consistently, names the app-state helper after what it boots, and reads action types through one helper. * test: name the quiet boot instead of asserting it inside a helper bootMiddlewareFromUnknownState asserted a silent boot behind a name that only promised to boot. Booting now always runs the boot timer, and the quiet case is a test of its own. * test: annotate the return types of the new test helpers * test: state the boot input in the test that depends on it --- .../__tests__/appStateMiddleware.test.ts | 93 ++++ app/lib/testUtils/sdkIntegration.ts | 11 + .../foregroundResume.integration.test.ts | 399 ++++++++++++++++++ 3 files changed, 503 insertions(+) create mode 100644 app/lib/store/__tests__/appStateMiddleware.test.ts create mode 100644 app/sagas/__tests__/foregroundResume.integration.test.ts 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/sdkIntegration.ts b/app/lib/testUtils/sdkIntegration.ts index a08b676cb4e..b7037daf998 100644 --- a/app/lib/testUtils/sdkIntegration.ts +++ b/app/lib/testUtils/sdkIntegration.ts @@ -58,6 +58,10 @@ export interface ISdkDriver { }; } +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) @@ -129,6 +133,13 @@ export async function flush(turns = 10): Promise { } } +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 }; 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(); + }); +}); From 5094c14d72aa73d03dc773e7809ba08bc2559b3a Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 24 Aug 2026 14:23:09 -0300 Subject: [PATCH 28/35] fix: make sdk.current nullable and guard its call sites (#7587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: make sdk.current nullable and guard its call sites `Sdk.current` was declared non-nullable while `disconnect()` assigned null behind a `@ts-expect-error`, so the compiler could not see the absent client. Typing it honestly surfaced 30 unchecked sites across 7 files, including a guard in `subscribeRooms` that tested the always-truthy singleton instead of the field that goes null. - `sdk` is `Rocketchat | null`; the wrapper's own methods read a private `activeSdk` getter that throws when the client is absent - `del` and `logout` join the wrapper, so their call sites stop reaching through `current`; `push.token` gains its DELETE operation - `connect()` uses the client `initialize()` returns instead of re-reading the global 13 times - lifecycle-straddling sites guard and return early; `login()` throws, since its caller resolves on a truthy result only and would otherwise hang * refactor: close the Sdk facade and drop sdk.current `current` exposed the wrapped `Rocketchat`, so callers reached through it into SDK internals — `client.client.host` for the connected host, `.driver` for the socket handle — and every one of them had to repeat the nullability guard the facade already owns. - the facade gains `host`, `currentLogin`, `driver` and `isInitialized`, which read the live client and return null when there is none, plus `login()`, `abort()` and `subscribeNotifyUser()` that delegate through `activeSdk` - `current` is gone; every call site keeps its existing semantics, silent return, early return or throw alike - `login()` captures the client once and returns its `currentLogin`, so a disconnect racing a login cannot turn a success into a missing result - the host comparisons in `rooms.ts` and `selectServer.ts` still read the live client rather than redux, since redux switches servers while the previous socket is still delivering messages * fix: drop stream frames and skip logout when the client is absent * refactor: make the absent-client outcome explicit at each guard * test: tighten the host-guard assertions * fix: forget the cached push tokens even with no client to delete them from * fix: forget the cached push tokens unconditionally * fix: forget the cached push tokens only when a device token exists * fix: drop media signals produced after the client is gone * refactor: ask for client presence through a single predicate * test: build driver-shaped doubles from the shared SDK harness * test: cover the absent-client logout and the matching-host frame * refactor: name the absent connection in the triggerAction failure The guard now also covers a missing host, so the message says so. Drops the invented host default from the SDK test double, which no test reads. * refactor: name the facade predicate for what it reports The client is dropped on disconnect, so the predicate is not a one-shot init flag; hasClient says what the six call sites read it for and stops it reading like mediaSession.isInitialized(). * test: drop the sdk double's getter for a removed property * test: drop the unreachable rejection from the media-signal double * refactor: keep the sdk client and its driver behind the facade initialize() returned the Rocketchat client, so connect.ts held its own reference and bypassed the facade for connect() and nine onStreamData registrations. It now returns void and the facade owns connect(). driver was typed as the whole vendor Rocketchat['driver']. ISocketProbe names the four members the app calls, so the test double and the driver harness derive from the facade instead of restating its shape. * fix: read the subscribed host once when starting the rooms subscription The hasClient guard and the later sdk.host read were two questions to a mutable client: a disconnect between them left subServer null, matching every later message whose host was also missing. subServer now starts null and stop() clears it. * refactor: name the driver contract for what it is and check it against the sdk The facade's driver getter asserted through unknown to a hand-written interface, so nothing verified that interface against the sdk's own Driver. Assigning it directly makes tsc check the two, and the doubles in the sdk harness still satisfy it structurally where Driver's private socket would not. * fix: drop rooms frames that arrive with no subscribed host The host comparison passed when both sides were null, which is the state between stop() clearing subServer and the stream listener being removed. * refactor: annotate the abort exits like the disconnect beside them * test: drop the unread client predicate from the rooms host-guard double * refactor: read the subscribed host the same way in both rooms guards * refactor: name the sdk predicate for the initialization it reports * test: build the inline sdk doubles from the shared mock The four inline jest.mock('../sdk') literals invented their own shape, so a facade rename left them silently stale. They now come from makeSdkMock, whose extra members are typed against the real facade, and presence flips through setClient instead of a hand-rolled isInitialized getter. * refactor: gate rooms frames on the subscribed server itself The guard tested the live host before comparing it, which read as two conditions when only the comparison decides. Both guards now read the host the same way through subscribedHost(). * docs: say what triggerAction needs and stop naming a gone accessor * refactor: read the subscribed host directly and restore the no-socket doc Also isolates the stopped-subscription test to the subServer clear it covers. * refactor: name the subscribed host and the mock driver for what they are * refactor: inline the sdk mock's member constraint --- app/definitions/rest/v1/push.ts | 1 + app/lib/methods/actions.test.ts | 14 +- app/lib/methods/actions.ts | 7 +- app/lib/methods/getSettings.ts | 6 +- app/lib/methods/logout.test.ts | 54 +++- app/lib/methods/logout.ts | 15 +- .../roomSubscription.integration.test.ts | 2 +- .../__tests__/rooms.hostGuard.test.ts | 92 ++++++ app/lib/methods/subscriptions/rooms.ts | 15 +- .../socketHealth.integration.test.ts | 20 +- .../services/__tests__/socketHealth.test.ts | 268 +++++++++--------- app/lib/services/connect.test.ts | 17 +- app/lib/services/connect.ts | 40 +-- app/lib/services/restApi.test.ts | 115 ++++++-- app/lib/services/restApi.ts | 19 +- app/lib/services/sdk.ts | 96 +++++-- app/lib/services/socketHealth.ts | 8 +- .../voip/MediaSessionInstance.test.ts | 61 ++-- app/lib/services/voip/MediaSessionInstance.ts | 3 + .../voip/acceptNativeCall.integration.test.ts | 49 ++-- .../acceptNativeCall.sdk.integration.test.ts | 14 +- .../services/voip/acceptNativeCall.test.ts | 47 +-- app/lib/services/voip/acceptNativeCall.ts | 6 +- app/lib/testUtils/sdkIntegration.ts | 44 ++- app/sagas/__tests__/deepLinking.test.ts | 17 +- .../__tests__/selectServer.sdkHost.test.ts | 2 +- app/sagas/deepLinking.js | 4 +- app/sagas/selectServer.ts | 2 +- 28 files changed, 682 insertions(+), 356 deletions(-) create mode 100644 app/lib/methods/subscriptions/__tests__/rooms.hostGuard.test.ts 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/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 2fbfa4a8772..f6da330fabc 100644 --- a/app/lib/methods/actions.ts +++ b/app/lib/methods/actions.ts @@ -108,12 +108,11 @@ export async function triggerAction({ const payload = rest.payload ?? rest.value; try { - const { currentLogin } = sdk.current; - if (!currentLogin) { - throw new Error('triggerAction requires an authenticated session'); + const { host, currentLogin } = sdk; + if (!host || !currentLogin) { + throw new Error('triggerAction requires an initialized, authenticated session'); } const { userId, authToken } = currentLogin; - const { host } = sdk.current.client; const interaction = toUserInteraction({ type, actionId, diff --git a/app/lib/methods/getSettings.ts b/app/lib/methods/getSettings.ts index eb016722026..5cfab330f75 100644 --- a/app/lib/methods/getSettings.ts +++ b/app/lib/methods/getSettings.ts @@ -149,7 +149,7 @@ export async function subscribeSettings(): Promise { 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/logout.test.ts b/app/lib/methods/logout.test.ts index ce467e94aff..54ea9b56e48 100644 --- a/app/lib/methods/logout.test.ts +++ b/app/lib/methods/logout.test.ts @@ -1,3 +1,5 @@ +import type * as SdkIntegration from '../testUtils/sdkIntegration'; + jest.mock('../database', () => ({ __esModule: true, default: { @@ -28,7 +30,16 @@ jest.mock('../services/restApi', () => ({ removePushToken: jest.fn() })); -import { removeServerData } from './logout'; +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'; @@ -41,6 +52,8 @@ import { 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'; @@ -136,3 +149,42 @@ describe('removeServerData', () => { 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 e036669ad24..013c929638c 100644 --- a/app/lib/methods/logout.ts +++ b/app/lib/methods/logout.ts @@ -106,14 +106,13 @@ export async function logout({ 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 index 9c56b86d252..678565f5ef0 100644 --- a/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts +++ b/app/lib/methods/subscriptions/__tests__/roomSubscription.integration.test.ts @@ -130,7 +130,7 @@ afterEach(() => { async function connectDriver() { sdk.initialize('https://example.com'); - const connectPromise = (sdk.current as unknown as { connect(): Promise }).connect(); + const connectPromise = sdk.connect(); await flush(); mockConnections[0].onopen(); await flush(); 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/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__/socketHealth.integration.test.ts b/app/lib/services/__tests__/socketHealth.integration.test.ts index 90eadc278e0..aaae464b76f 100644 --- a/app/lib/services/__tests__/socketHealth.integration.test.ts +++ b/app/lib/services/__tests__/socketHealth.integration.test.ts @@ -7,7 +7,7 @@ import { framesOn, stopAnsweringFrames } from '../../testUtils/sdkIntegration'; -import type { MockConnection, ISdkDriver } from '../../testUtils/sdkIntegration'; +import type { IMockSdk, MockConnection, IMockSdkDriver } from '../../testUtils/sdkIntegration'; import type * as SdkIntegration from '../../testUtils/sdkIntegration'; const mockConnections: MockConnection[] = []; @@ -19,24 +19,24 @@ jest.mock('universal-websocket-client', () => }) ); -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; describe('recoverSocket against the real SDK socket', () => { - let driver: ISdkDriver; + let driver: IMockSdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); mockConnections.length = 0; driver = await buildConnectedDriver(mockConnections, USER_ID); - (sdk as unknown as { current: { driver: ISdkDriver } }).current = { driver }; + (sdk as unknown as IMockSdk).setClient({ driver }); }); afterEach(() => { @@ -154,7 +154,7 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); - const resubscribed = driver.waitForNotifyUserMediaSubs!(); + const resubscribed = driver.waitForNotifyUserMediaSubs(); await jest.advanceTimersByTimeAsync(200); await expect(resubscribed).resolves.toBe(true); @@ -189,7 +189,7 @@ describe('recoverSocket against the real SDK socket', () => { await jest.advanceTimersByTimeAsync(0); await expect(recovery).resolves.toBe('reopened'); - const resubscribed = driver.waitForNotifyUserMediaSubs!(1000); + const resubscribed = driver.waitForNotifyUserMediaSubs(1000); await jest.advanceTimersByTimeAsync(100); expect(framesOn(mockConnections[1], 'sub')).toHaveLength(0); @@ -215,7 +215,7 @@ describe('recoverSocket against the real SDK socket', () => { stopAnsweringFrames(mockConnections[1]); - const resubscribed = driver.waitForNotifyUserMediaSubs!(500); + const resubscribed = driver.waitForNotifyUserMediaSubs(500); await jest.advanceTimersByTimeAsync(500); await expect(resubscribed).resolves.toBe(false); diff --git a/app/lib/services/__tests__/socketHealth.test.ts b/app/lib/services/__tests__/socketHealth.test.ts index 5f72e80a3ad..ef51619afb4 100644 --- a/app/lib/services/__tests__/socketHealth.test.ts +++ b/app/lib/services/__tests__/socketHealth.test.ts @@ -1,144 +1,144 @@ -jest.mock('../sdk', () => ({ - __esModule: true, - default: { - current: { driver: undefined } - } -})); - -import sdk, { type TDriver } 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: { driver: unknown } | undefined }; - -interface MockDriver { - connected: boolean; - lastPing: number; - pingInterval: number; - reopenNow: jest.Mock, []>; - probe: jest.Mock, [number]>; -} - -function makeDriver(overrides: Partial = {}): MockDriver { - return { - connected: true, - lastPing: now, - pingInterval: 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 round-trip-check for a connected socket rather than trusting it outright', () => { - const driver = makeDriver({ connected: true }); - expect(classifySocketHealth(driver as unknown as TDriver)).toBe('round-trip-check'); - }); - - it('returns reopen for a closed socket even when lastPing is fresh', () => { - const driver = makeDriver({ connected: false, lastPing: now }); - expect(classifySocketHealth(driver as unknown as TDriver)).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 driver: MockDriver; - - beforeEach(() => { - driver = makeDriver({ lastPing: Date.now() }); - sdkMock.current = { driver }; - }); - - it('keeps a socket whose round trip answers', async () => { - await expect(recoverSocket()).resolves.toBe('confirmed-alive'); - expect(driver.reopenNow).not.toHaveBeenCalled(); - }); - - it('runs the round trip with a 2s budget', async () => { - await recoverSocket(); - expect(driver.probe).toHaveBeenCalledWith(2000); - }); - - it('reopens when the round trip goes unanswered', async () => { - driver.probe.mockResolvedValue(false); - await expect(recoverSocket()).resolves.toBe('reopened'); - expect(driver.reopenNow).toHaveBeenCalledTimes(1); - }); - - it('reopens a known-dead socket without a round trip', async () => { - driver.connected = false; - await expect(recoverSocket()).resolves.toBe('reopened'); - expect(driver.probe).not.toHaveBeenCalled(); - expect(driver.reopenNow).toHaveBeenCalledTimes(1); - }); +const sdkMock = sdk as unknown as IMockSdk; - it('reports no-socket when the driver handle is missing', async () => { - sdkMock.current = { driver: undefined }; - await expect(recoverSocket()).resolves.toBe('no-socket'); - expect(driver.probe).not.toHaveBeenCalled(); - expect(driver.reopenNow).not.toHaveBeenCalled(); - }); +const USER_ID = 'user-id'; +const CLOSED = 3; - it('reports no-socket when there is no sdk instance', async () => { - sdkMock.current = undefined; - await expect(recoverSocket()).resolves.toBe('no-socket'); - }); +describe('socket health against a driver from the shared harness', () => { + let driver: IMockSdkDriver; + let probe: jest.SpyInstance, [number?]>; + let reopenNow: jest.SpyInstance, []>; - it('rejects when the round trip throws', async () => { - driver.probe.mockRejectedValue(new Error('round trip failed')); - await expect(recoverSocket()).rejects.toThrow('round trip failed'); + 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 reopening throws', async () => { - driver.connected = false; - driver.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(driver.probe).toHaveBeenCalledTimes(1); - }); - - it('starts a fresh recovery after the shared one settles', async () => { - await recoverSocket(); - await recoverSocket(); - expect(driver.probe).toHaveBeenCalledTimes(2); - }); - - it('abandons the aborted caller while the shared recovery runs on', async () => { - let answerRoundTrip: (alive: boolean) => void = () => {}; - driver.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(driver.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(driver.probe).not.toHaveBeenCalled(); - expect(driver.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 5e55395e8e0..d8abdc8d767 100644 --- a/app/lib/services/connect.test.ts +++ b/app/lib/services/connect.test.ts @@ -23,23 +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 mockSdkLogin = jest.fn, [unknown]>(() => Promise.resolve()); const mockSdkCurrent: Record = { - onStreamData: (event: string, cb: (...args: any[]) => void) => mockOnStreamData(event, cb), - connect: () => mockSdkConnect(), - abort: () => mockSdkAbort(), - login: (credentials: unknown) => mockSdkLogin(credentials), currentLogin: undefined }; +const mockSdkInitialize = jest.fn(); jest.mock('./sdk', () => ({ __esModule: true, default: { initialize: (server: string) => mockSdkInitialize(server), + connect: () => mockSdkConnect(), disconnect: () => mockSdkDisconnect(), onStreamData: (event: string, cb: (...args: any[]) => void) => mockOnStreamData(event, cb), - get current() { - return mockSdkCurrent; + isInitialized: true, + login: async (credentials: unknown) => { + await mockSdkLogin(credentials); + return mockSdkCurrent.currentLogin ?? null; + }, + abort: () => mockSdkAbort(), + get currentLogin() { + return mockSdkCurrent.currentLogin; } } })); diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts index 79c246de083..c56edc7a6e6 100644 --- a/app/lib/services/connect.ts +++ b/app/lib/services/connect.ts @@ -86,9 +86,9 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr EventEmitter.emit('INQUIRY_UNSUBSCRIBE'); sdk.initialize(server); - getSettings(); + getSettings(server); - sdk.current + sdk .connect() .then(() => { console.log('connected'); @@ -97,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; @@ -117,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; @@ -134,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; @@ -177,7 +177,7 @@ function connect({ server, logoutOnError = false }: { server: string; logoutOnEr }) ); - rolesListener = sdk.current.onStreamData( + rolesListener = sdk.onStreamData( 'stream-roles', protectedFunction((ddpMessage: any) => onRolesChanged(ddpMessage)) ); @@ -199,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; @@ -290,7 +290,7 @@ 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(); }); @@ -301,10 +301,13 @@ function stopListener(listener: any): void { } 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'); } @@ -408,16 +411,15 @@ async function loginOAuthOrSso(params: ILoginCredentials) { 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/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 0eeb5e53607..e44f27f0ff2 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1139,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; } @@ -1175,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.ts b/app/lib/services/sdk.ts index 857a76fae47..3da561e9dcf 100644 --- a/app/lib/services/sdk.ts +++ b/app/lib/services/sdk.ts @@ -1,5 +1,5 @@ import { Rocketchat } from '@rocket.chat/sdk'; -import { type ICallback, type ISubscription } from '@rocket.chat/sdk/interfaces'; +import { type ICallback, type ICurrentLogin, type ILoginCredentials, type ISubscription } from '@rocket.chat/sdk/interfaces'; import EJSON from 'ejson'; import isEmpty from 'lodash/isEmpty'; @@ -15,7 +15,12 @@ import { } from '../../definitions/rest/helpers'; import { compareServerVersion, random } from '../methods/helpers'; -export type TDriver = Rocketchat['driver']; +export interface ISocketDriver { + readonly connected: boolean; + reopenNow(): Promise; + probe(timeoutMs?: number): Promise; + waitForNotifyUserMediaSubs(timeoutMs?: number): Promise; +} export type TStreamDataCallback = (ddpMessage: any) => void; @@ -24,36 +29,65 @@ export interface IStreamDataListener { } class Sdk { - private sdk!: Rocketchat; + private sdk: Rocketchat | null = null; private code: any; + 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(): Rocketchat { - 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; + } + + get isInitialized(): boolean { + return this.sdk !== 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() { + 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(); - // @ts-expect-error this.sdk = null; } - return null; } get>( @@ -67,7 +101,7 @@ class Sdk { ? void : Serialized>> ): Promise>>> { - return this.current.get(endpoint, params); + return this.activeSdk.get(endpoint, params); } post>( @@ -84,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, @@ -117,13 +151,31 @@ class Sdk { }); } + 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(method, ...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')) { @@ -161,11 +213,11 @@ class Sdk { } subscribe(topic: string, eventName?: string, ...args: any[]): Promise { - return this.current.subscribe(topic, eventName as string, ...args); + return this.activeSdk.subscribe(topic, eventName as string, ...args); } subscribeRaw(name: string, params: any[]): Promise { - return this.current.subscribeRaw(name, params); + return this.activeSdk.subscribeRaw(name, params); } subscribeRoom(...args: any[]) { @@ -190,11 +242,11 @@ class Sdk { } unsubscribe(subscription: ISubscription) { - return this.current.unsubscribe(subscription); + return this.activeSdk.unsubscribe(subscription); } onStreamData(event: string, callback: TStreamDataCallback): Promise { - return this.current.onStreamData(event, callback as ICallback); + return this.activeSdk.onStreamData(event, callback as ICallback); } } diff --git a/app/lib/services/socketHealth.ts b/app/lib/services/socketHealth.ts index 51f6779766e..c786f05504e 100644 --- a/app/lib/services/socketHealth.ts +++ b/app/lib/services/socketHealth.ts @@ -1,5 +1,5 @@ import { onAbort } from '../methods/helpers/onAbort'; -import sdk, { type TDriver } from './sdk'; +import sdk, { type ISocketDriver } from './sdk'; /** * The recovery plan — what classification decides. @@ -12,7 +12,7 @@ import sdk, { type TDriver } from './sdk'; */ export type SocketRecoveryPlan = 'reopen' | 'round-trip-check'; -export function classifySocketHealth(driver: TDriver): SocketRecoveryPlan { +export function classifySocketHealth(driver: ISocketDriver): SocketRecoveryPlan { // `driver.connected` already folds in the ping-age test, so a stale ping lands here. if (!driver.connected) { return 'reopen'; @@ -26,7 +26,7 @@ export function classifySocketHealth(driver: TDriver): SocketRecoveryPlan { * 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?.driver` undefined; nothing to recover. + * - `'no-socket'` — `sdk.driver` is null; nothing to recover. * - `'abandoned'` — caller's abort signal fired while waiting; the * underlying recovery (shared — see below) runs on. * @@ -43,7 +43,7 @@ function shareRecovery(): Promise { if (inFlightRecovery) { return inFlightRecovery; } - const driver = sdk.current?.driver; + const driver = sdk.driver; if (!driver) { return Promise.resolve('no-socket'); } diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index a8ab6a0d14d..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 { - driver: { - 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 6c7c0ae402b..cefa71ba93f 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -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); }); diff --git a/app/lib/services/voip/acceptNativeCall.integration.test.ts b/app/lib/services/voip/acceptNativeCall.integration.test.ts index 89ffc492f29..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 = { driver: 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 = { driver: 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 index d13045ecc4e..b51086e41ee 100644 --- a/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts +++ b/app/lib/services/voip/acceptNativeCall.sdk.integration.test.ts @@ -4,13 +4,13 @@ import { useCallStore } from './useCallStore'; import { terminateNativeCall } from './terminateNativeCall'; import { waitForLoginReady } from '../waitForLoginReady'; import { addMediaSubs, backdateLastPing, buildConnectedDriver, stopAnsweringFrames } from '../../testUtils/sdkIntegration'; -import type { MockConnection, ISdkDriver } from '../../testUtils/sdkIntegration'; +import type { IMockSdk, MockConnection, IMockSdkDriver } from '../../testUtils/sdkIntegration'; import type * as SdkIntegration from '../../testUtils/sdkIntegration'; -jest.mock('../sdk', () => ({ - __esModule: true, - default: { current: undefined } -})); +jest.mock('../sdk', () => { + const sdkIntegration = jest.requireActual('../../testUtils/sdkIntegration'); + return { __esModule: true, default: sdkIntegration.makeSdkMock() }; +}); jest.mock('./useCallStore', () => ({ useCallStore: { getState: jest.fn() } @@ -63,14 +63,14 @@ function makeMediaSession(overrides: Partial = {}): IMediaSession }; } -let driver: ISdkDriver; +let driver: IMockSdkDriver; beforeEach(async () => { jest.clearAllMocks(); jest.useFakeTimers(); mockConnections.length = 0; driver = await buildConnectedDriver(mockConnections, USER_ID); - (sdk as unknown as { current: { driver: ISdkDriver } }).current = { driver }; + (sdk as unknown as IMockSdk).setClient({ driver }); mockWaitForLoginReady.mockResolvedValue(true); mockGetState.mockReturnValue({ call: null, resetNativeCallId: jest.fn() }); }); diff --git a/app/lib/services/voip/acceptNativeCall.test.ts b/app/lib/services/voip/acceptNativeCall.test.ts index 8347571f4a7..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 mockDriver = () => sdk.current?.driver as any; jest.mock('./useCallStore', () => ({ useCallStore: { @@ -21,12 +23,19 @@ jest.mock('./terminateNativeCall', () => ({ terminateNativeCall: jest.fn() })); -jest.mock('../sdk', () => ({ - __esModule: true, - default: { - current: { driver: {} } - } -})); +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 makeDriver(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 = { driver: makeDriver() }; + 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 () => { - mockDriver().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 aa3a02242cf..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, { type TDriver } from '../sdk'; +import sdk, { type ISocketDriver } from '../sdk'; import { waitForLoginReady } from '../waitForLoginReady'; import { recoverSocket } from '../socketHealth'; import { terminateNativeCall } from './terminateNativeCall'; @@ -15,7 +15,7 @@ export interface NativeCallMediaSession { const activeGates = new Map(); -async function waitForMediaSignalSubs(driver: TDriver, timeoutMs: number, abortSignal?: AbortSignal): Promise { +async function waitForMediaSignalSubs(driver: ISocketDriver, timeoutMs: number, abortSignal?: AbortSignal): Promise { if (abortSignal?.aborted) { return false; } @@ -65,7 +65,7 @@ export async function acceptNativeCallWithReadiness(callId: string, mediaSession return; } - const driver = sdk.current?.driver; + const driver = sdk.driver; if (!driver) { return handleFailure(callId, mediaSession); } diff --git a/app/lib/testUtils/sdkIntegration.ts b/app/lib/testUtils/sdkIntegration.ts index b7037daf998..f56f47d8736 100644 --- a/app/lib/testUtils/sdkIntegration.ts +++ b/app/lib/testUtils/sdkIntegration.ts @@ -2,6 +2,8 @@ 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; @@ -43,11 +45,9 @@ export class MockConnection { } } -export interface ISdkDriver { +export interface IMockSdkDriver extends ISocketDriver { userId: string; pingInterval: number; - reopenNow(): Promise; - waitForNotifyUserMediaSubs?(timeoutMs?: number): Promise; socket: { lastPing: number; pingTimeout?: ReturnType; @@ -58,6 +58,36 @@ export interface ISdkDriver { }; } +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]; } @@ -76,8 +106,8 @@ const { Rocketchat } = jest.requireActual('@rocket.chat/sd 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 ISdkDriver; +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(); @@ -86,7 +116,7 @@ export async function buildConnectedDriver(connections: MockConnection[], userId return driver; } -export function addMediaSubs(driver: ISdkDriver, userId: string): void { +export function addMediaSubs(driver: IMockSdkDriver, userId: string): void { ['media-signal', 'media-calls'].forEach((name, index) => { const id = `sub-${index}`; driver.socket.subscriptions[id] = { @@ -98,7 +128,7 @@ export function addMediaSubs(driver: ISdkDriver, userId: string): void { }); } -export function backdateLastPing(driver: ISdkDriver, ageMs: number): void { +export function backdateLastPing(driver: IMockSdkDriver, ageMs: number): void { driver.socket.lastPing = Date.now() - ageMs; } diff --git a/app/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 406ea6dcf5a..7a1535ce954 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 } })); @@ -380,13 +376,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; }); /** @@ -570,7 +565,7 @@ describe('deepLinking saga — unknown host hands off to the add-server flow', ( }); jest.mocked(getServerById).mockResolvedValue(undefined as any); jest.mocked(getServerInfo).mockResolvedValue({ success: true } as any); - jest.mocked(sdk).current.client.host = PREVIOUS_SERVER; + (sdk as any).host = PREVIOUS_SERVER; }); afterEach(() => { @@ -611,12 +606,12 @@ describe('deepLinking saga — handleShareExtension user-facing roots', () => { if (key === 'currentServer') return HOST; return makeStoredUser(); }); - jest.mocked(sdk).current.client.host = ''; + (sdk as any).host = null; }); afterEach(() => { cancelSagaTasks(); - jest.mocked(sdk).current.client.host = ''; + (sdk as any).host = null; }); it('lands on ROOT_OUTSIDE, not the loading root, when the server record is missing', async () => { diff --git a/app/sagas/__tests__/selectServer.sdkHost.test.ts b/app/sagas/__tests__/selectServer.sdkHost.test.ts index 069a23e2ad8..6d58e4938ef 100644 --- a/app/sagas/__tests__/selectServer.sdkHost.test.ts +++ b/app/sagas/__tests__/selectServer.sdkHost.test.ts @@ -56,7 +56,7 @@ describe('selectServer saga — redundant select for the live SDK host', () => { it('reads the live host off the real SDK client and cancels the select without reconnecting', async () => { sdk.initialize(HOST); - expect(sdk.current.client.host).toBe(HOST); + expect(sdk.host).toBe(HOST); const { store, dispatchedActions } = createRecordingStore(selectServerRoot); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 680e6b5e80b..b1052c6ded3 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -161,7 +161,7 @@ const handleShareExtension = function* handleOpen({ params }) { return; } yield put(selectServerRequest(server, serverRecord.version)); - if (sdk.current?.client?.host !== server) { + if (sdk.host !== server) { const { loginSuccess } = yield race({ loginSuccess: take(types.LOGIN.SUCCESS), loginFailure: take(types.LOGIN.FAILURE), @@ -248,7 +248,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/selectServer.ts b/app/sagas/selectServer.ts index a203c59f3f9..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; From 778fa8644394f702f25cf0cbb8c3f601ba7051d6 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 24 Aug 2026 14:26:02 -0300 Subject: [PATCH 29/35] test: cover messages received while the device is offline (#7596) * test: cover messages received while the device is offline Adds a Maestro flow that drops the connection with airplane mode, posts messages over REST from the host while the device is offline, and asserts the backlog is delivered in order on reconnect. * test: drop the maestro readme section * test: settle before asserting the offline backlog is undelivered * test: point the offline flow at the reconnect backfill code * test: select the offline flow on subscribeRooms changes * test: keep the sleep helper name * test: include the offline flow shard in the rooms saga fan-out scenario --- .../__tests__/fixtures/scenario-catalog.json | 4 +- .maestro/scripts/data-setup.js | 3 +- .../room/messages-received-while-offline.yaml | 55 +++++++++++++++++++ .sniffler/test-map.json | 12 ++++ 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 .maestro/tests/room/messages-received-while-offline.yaml 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 8a16fac1829..8fa1d384893 100644 --- a/.sniffler/test-map.json +++ b/.sniffler/test-map.json @@ -253,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"] From ff9d54e05f3b39f9e4a088e5c0c49dba28c4e2ae Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 24 Aug 2026 14:47:46 -0300 Subject: [PATCH 30/35] chore: drop comments that restate or narrate code --- app/lib/services/socketHealth.ts | 4 ---- app/lib/services/waitForLoginReady.ts | 2 -- app/sagas/__tests__/deepLinking.test.ts | 4 ---- app/views/RoomInfoView/index.tsx | 1 - 4 files changed, 11 deletions(-) diff --git a/app/lib/services/socketHealth.ts b/app/lib/services/socketHealth.ts index c786f05504e..a0716731312 100644 --- a/app/lib/services/socketHealth.ts +++ b/app/lib/services/socketHealth.ts @@ -13,12 +13,9 @@ import sdk, { type ISocketDriver } from './sdk'; export type SocketRecoveryPlan = 'reopen' | 'round-trip-check'; export function classifySocketHealth(driver: ISocketDriver): SocketRecoveryPlan { - // `driver.connected` already folds in the ping-age test, so a stale ping lands here. if (!driver.connected) { return 'reopen'; } - // A connected socket is still verified by a round trip, never trusted outright: - // onOpen refreshes lastPing before the handshake reply lands. return 'round-trip-check'; } @@ -26,7 +23,6 @@ export function classifySocketHealth(driver: ISocketDriver): SocketRecoveryPlan * 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.driver` is null; nothing to recover. * - `'abandoned'` — caller's abort signal fired while waiting; the * underlying recovery (shared — see below) runs on. * diff --git a/app/lib/services/waitForLoginReady.ts b/app/lib/services/waitForLoginReady.ts index 1559d7d6f7c..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 `socket.loggedIn`: `close` clears `meteor.connected`, while `socket.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/sagas/__tests__/deepLinking.test.ts b/app/sagas/__tests__/deepLinking.test.ts index 7a1535ce954..2d7f034ff9a 100644 --- a/app/sagas/__tests__/deepLinking.test.ts +++ b/app/sagas/__tests__/deepLinking.test.ts @@ -206,12 +206,8 @@ describe('deepLinking saga — Regression race (new server + token + room path)' store.dispatch(loginSuccess({ id: 'user-1', token: makeStoredUser() } as any)); await flushSagaMicrotasks(); - // Saga has dispatchedActions 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(); diff --git a/app/views/RoomInfoView/index.tsx b/app/views/RoomInfoView/index.tsx index 9cff9711ec5..2a162e1db05 100644 --- a/app/views/RoomInfoView/index.tsx +++ b/app/views/RoomInfoView/index.tsx @@ -230,7 +230,6 @@ const RoomInfoView = (): ReactElement => { }; const createDirect = async (): Promise => { - // We don't need to create a direct if (!isEmpty(member)) return; const result = await createDirectMessage(roomUser.username); if (!result.success) { From 150fd8c77565387dc910cd0eb2ed8c85e3727197 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 24 Aug 2026 14:52:11 -0300 Subject: [PATCH 31/35] refactor: share the logged-in server lookup between the init and login sagas --- app/lib/methods/loggedInServer.ts | 11 +++++++++++ app/sagas/init.js | 14 ++++---------- app/sagas/login.js | 7 +------ 3 files changed, 16 insertions(+), 16 deletions(-) create mode 100644 app/lib/methods/loggedInServer.ts diff --git a/app/lib/methods/loggedInServer.ts b/app/lib/methods/loggedInServer.ts new file mode 100644 index 00000000000..49e2b2e9843 --- /dev/null +++ b/app/lib/methods/loggedInServer.ts @@ -0,0 +1,11 @@ +import { TOKEN_KEY } from '../constants/keys'; +import database from '../database'; +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'); + const servers = (yield serversCollection.query().fetch()) as { id: string; version: string }[]; + return servers.find(({ id }) => hasStoredLoginToken(id)); +}; diff --git a/app/sagas/init.js b/app/sagas/init.js index 7dc6ff9f66e..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,19 +21,13 @@ export const initLocalSettings = function* initLocalSettings() { yield put(setAllPreferences(sortPreferences)); }; -const isLoggedIn = server => !!UserPreferences.getString(`${TOKEN_KEY}-${server}`); - const serverToRestore = function* serverToRestore(server) { if (!server) { return null; } - if (!isLoggedIn(server)) { - const serversDB = database.servers; - const serversCollection = serversDB.get('servers'); - const servers = yield serversCollection.query().fetch(); - - return servers.find(({ id }) => isLoggedIn(id)) || null; + if (!hasStoredLoginToken(server)) { + return (yield* findLoggedInServer()) || null; } yield localAuthenticate(server); diff --git a/app/sagas/login.js b/app/sagas/login.js index 1b032e7b737..4138d3960b9 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -11,6 +11,7 @@ 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'; @@ -369,12 +370,6 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { } }; -const findLoggedInServer = function* findLoggedInServer() { - const serversCollection = database.servers.get('servers'); - const servers = yield serversCollection.query().fetch(); - return servers.find(({ id }) => UserPreferences.getString(`${TOKEN_KEY}-${id}`)); -}; - const handleLogout = function* handleLogout({ forcedByServer, message }) { yield put(encryptionStop()); yield put(appStart({ root: RootEnum.ROOT_LOADING, text: I18n.t('Logging_out') })); From 0e547f2b205ff7a55038b297458d30d55505065c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 24 Aug 2026 14:53:28 -0300 Subject: [PATCH 32/35] chore: drop the outcome enumeration from the recovery docblock --- app/lib/services/socketHealth.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/lib/services/socketHealth.ts b/app/lib/services/socketHealth.ts index a0716731312..771fb1cb017 100644 --- a/app/lib/services/socketHealth.ts +++ b/app/lib/services/socketHealth.ts @@ -20,12 +20,6 @@ export function classifySocketHealth(driver: ISocketDriver): SocketRecoveryPlan } /** - * What a recovery attempt reports. - * - `'confirmed-alive'` — round trip succeeded; nothing was done. - * - `'reopened'` — socket reopened (stale ping, or round trip failed). - * - `'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 From bf54234f2d77e605d51a33958c4d5f11d991e0e9 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 24 Aug 2026 14:56:15 -0300 Subject: [PATCH 33/35] refactor: type the logged-in server lookup with TServerModel --- app/lib/methods/loggedInServer.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/lib/methods/loggedInServer.ts b/app/lib/methods/loggedInServer.ts index 49e2b2e9843..d6e392bbbd9 100644 --- a/app/lib/methods/loggedInServer.ts +++ b/app/lib/methods/loggedInServer.ts @@ -1,11 +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'); - const servers = (yield serversCollection.query().fetch()) as { id: string; version: string }[]; +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)); }; From e37fd2571cd657f7ac8dfabbeb8b6888e407cd0f Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 24 Aug 2026 15:09:52 -0300 Subject: [PATCH 34/35] fix: use the room _id when navigating to a newly created direct message --- app/containers/Avatar/useAvatarETag.ts | 2 +- app/lib/methods/helpers/twoFactorCancellation.test.ts | 2 +- app/views/RoomInfoView/index.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/containers/Avatar/useAvatarETag.ts b/app/containers/Avatar/useAvatarETag.ts index a4e9261a33d..c1178571ab0 100644 --- a/app/containers/Avatar/useAvatarETag.ts +++ b/app/containers/Avatar/useAvatarETag.ts @@ -61,7 +61,7 @@ export const useAvatarETag = ({ } }; } - }, [text]); + }, [text, username, type, rid, id]); return { avatarETag }; }; diff --git a/app/lib/methods/helpers/twoFactorCancellation.test.ts b/app/lib/methods/helpers/twoFactorCancellation.test.ts index a84cb7850fd..d9b6e7fa667 100644 --- a/app/lib/methods/helpers/twoFactorCancellation.test.ts +++ b/app/lib/methods/helpers/twoFactorCancellation.test.ts @@ -59,6 +59,6 @@ describe('two-factor cancellation', () => { }); it('surfaces a generic login error when the login path reports a cancellation', () => { - expect(handleLoginErrors(undefined as any)).toBe('Login_error'); + expect(handleLoginErrors((cancelled as any).error)).toBe('Login_error'); }); }); diff --git a/app/views/RoomInfoView/index.tsx b/app/views/RoomInfoView/index.tsx index 2a162e1db05..aebabe5733a 100644 --- a/app/views/RoomInfoView/index.tsx +++ b/app/views/RoomInfoView/index.tsx @@ -232,10 +232,10 @@ const RoomInfoView = (): ReactElement => { const createDirect = async (): Promise => { if (!isEmpty(member)) return; const result = await createDirectMessage(roomUser.username); - if (!result.success) { + if (!result?.success || !result.room?._id) { throw new Error('Failed to create direct message'); } - return { ...roomUser, rid: result.room.rid }; + return { ...roomUser, rid: result.room._id }; }; const handleGoRoom = (r?: ISubscription) => { From 8382470c35ce063f3deb769f9e1125cfe6b79f1e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 24 Aug 2026 16:48:41 -0300 Subject: [PATCH 35/35] chore: bump @rocket.chat/sdk to latest mobile HEAD --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 0052abd6694..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#b6453cc3e07c31830ef663ae989ab129851a10a1", + "@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/pnpm-lock.yaml b/pnpm-lock.yaml index 5515e4fb0ad..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#b6453cc3e07c31830ef663ae989ab129851a10a1 - version: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6453cc3e07c31830ef663ae989ab129851a10a1 + 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,8 +2633,8 @@ packages: react: '*' react-native: '*' - '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6453cc3e07c31830ef663ae989ab129851a10a1': - resolution: {gitHosted: true, integrity: sha512-LYKf9DO6w4hCKeDaLZJH4MeQwrA94LJeAmjYH6K1DEGeQH8oOK3F8jNyinK82XiRKJJlIiko4ZGE5zzxhTJ/Hw==, tarball: https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6453cc3e07c31830ef663ae989ab129851a10a1} + '@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 '@rocket.chat/ui-kit@0.39.0': @@ -10517,7 +10517,7 @@ 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/b6453cc3e07c31830ef663ae989ab129851a10a1': + '@rocket.chat/sdk@https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/eef075c8fce25120fda2ef77d69dba842af5d1ba': dependencies: js-sha256: 0.9.0 tiny-events: 1.0.1