diff --git a/app/definitions/rest/v1/push.ts b/app/definitions/rest/v1/push.ts index 3062bcb90c..2db3495cbc 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 6bf0885302..f9706e730c 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 2fbfa4a877..f6da330fab 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 eb01672202..5cfab330f7 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 ce467e94af..54ea9b56e4 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 e036669ad2..013c929638 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 9c56b86d25..678565f5ef 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 0000000000..2ef4e50488 --- /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 78aeb9ca67..dc4d4686cf 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 90eadc278e..aaae464b76 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 5f72e80a3a..ef51619afb 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 5e55395e8e..d8abdc8d76 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 79c246de08..c56edc7a6e 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 8dc0dcf8fb..7405c6d633 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 0eeb5e5360..e44f27f0ff 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 857a76fae4..3da561e9dc 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 51f6779766..c786f05504 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 a8ab6a0d14..1a35d57edc 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 6c7c0ae402..cefa71ba93 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 89ffc492f2..02f2753d22 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 d13045ecc4..b51086e41e 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 8347571f4a..13eb383497 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 aa3a02242c..85adb619e0 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 b7037daf99..f56f47d873 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 406ea6dcf5..7a1535ce95 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 069a23e2ad..6d58e4938e 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 680e6b5e80..b1052c6ded 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 a203c59f3f..24a8498f36 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;