diff --git a/.changeset/bump-patch-1758105326594.md b/.changeset/bump-patch-1758105326594.md new file mode 100644 index 0000000000000..e1eaa7980afb1 --- /dev/null +++ b/.changeset/bump-patch-1758105326594.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Bump @rocket.chat/meteor version. diff --git a/.changeset/green-ants-shop.md b/.changeset/green-ants-shop.md new file mode 100644 index 0000000000000..406013dc951a8 --- /dev/null +++ b/.changeset/green-ants-shop.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes login using iframe authentication. diff --git a/.changeset/grumpy-berries-arrive.md b/.changeset/grumpy-berries-arrive.md new file mode 100644 index 0000000000000..eacb88108a0f7 --- /dev/null +++ b/.changeset/grumpy-berries-arrive.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Security Hotfix (https://docs.rocket.chat/docs/security-fixes-and-updates) diff --git a/.changeset/old-meals-pull.md b/.changeset/old-meals-pull.md new file mode 100644 index 0000000000000..80a5dadf14f02 --- /dev/null +++ b/.changeset/old-meals-pull.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/apps-engine': patch +'@rocket.chat/meteor': patch +--- + +Changes a strict behavior on reporting slash commands provided by apps diff --git a/.changeset/spicy-crabs-complain.md b/.changeset/spicy-crabs-complain.md new file mode 100644 index 0000000000000..11e6729cd3a53 --- /dev/null +++ b/.changeset/spicy-crabs-complain.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Ensures the formatted volume value is kept between 0 and 1 diff --git a/apps/meteor/app/api/server/ApiClass.ts b/apps/meteor/app/api/server/ApiClass.ts index e81788e6ec4a9..83bbfde404654 100644 --- a/apps/meteor/app/api/server/ApiClass.ts +++ b/apps/meteor/app/api/server/ApiClass.ts @@ -811,12 +811,15 @@ export class APIClass< if (options.authRequired || options.authOrAnonRequired) { const user = await api.authenticatedRoute.call(this, this.request); this.user = user!; - this.userId = String(this.request.headers.get('x-user-id')); + this.userId = this.user?._id; const authToken = this.request.headers.get('x-auth-token'); this.token = (authToken && Accounts._hashLoginToken(String(authToken)))!; } - if (!this.user && options.authRequired && !options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead')) { + const shouldPreventAnonymousRead = !this.user && options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead'); + const shouldPreventUserRead = !this.user && options.authRequired; + + if (shouldPreventAnonymousRead || shouldPreventUserRead) { const result = api.unauthorized('You must be logged in to do this.'); // compatibility with the old API // TODO: MAJOR diff --git a/apps/meteor/client/hooks/iframe/useIframe.ts b/apps/meteor/client/hooks/iframe/useIframe.ts index 78677d7c45421..64d2b4aa99f80 100644 --- a/apps/meteor/client/hooks/iframe/useIframe.ts +++ b/apps/meteor/client/hooks/iframe/useIframe.ts @@ -1,5 +1,5 @@ import { useLoginWithIframe, useLoginWithToken, useSetting } from '@rocket.chat/ui-contexts'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; export const useIframe = () => { const [iframeLoginUrl, setIframeLoginUrl] = useState(undefined); @@ -12,6 +12,8 @@ export const useIframe = () => { const iframeLogin = useLoginWithIframe(); const tokenLogin = useLoginWithToken(); + const enabled = Boolean(iframeEnabled && accountIframeUrl && apiUrl && apiMethod); + const loginWithToken = useCallback( (tokenData: string | { loginToken: string } | { token: string }, callback?: (error: Error | null | undefined) => void) => { if (typeof tokenData === 'string') { @@ -31,6 +33,10 @@ export const useIframe = () => { const tryLogin = useCallback( async (callback?: (error: Error | null | undefined, result: unknown) => void) => { + if (!enabled) { + return; + } + let url = accountIframeUrl; let separator = '?'; if (url.indexOf('?') > -1) { @@ -43,9 +49,7 @@ export const useIframe = () => { const result = await fetch(apiUrl, { method: apiMethod, - headers: { - 'Content-Type': 'application/json', - }, + headers: undefined, credentials: 'include', }); @@ -64,11 +68,15 @@ export const useIframe = () => { callback?.(error, await result.json()); }); }, - [apiMethod, apiUrl, accountIframeUrl, loginWithToken], + [apiMethod, apiUrl, accountIframeUrl, loginWithToken, enabled], ); + useEffect(() => { + tryLogin(); + }, [tryLogin]); + return { - enabled: Boolean(iframeEnabled && accountIframeUrl && apiUrl && apiMethod), + enabled, tryLogin, loginWithToken, iframeLoginUrl, diff --git a/apps/meteor/client/hooks/useAppSlashCommands.spec.tsx b/apps/meteor/client/hooks/useAppSlashCommands.spec.tsx new file mode 100644 index 0000000000000..1640e50d9189d --- /dev/null +++ b/apps/meteor/client/hooks/useAppSlashCommands.spec.tsx @@ -0,0 +1,185 @@ +import type { SlashCommand } from '@rocket.chat/core-typings'; +import { mockAppRoot, type StreamControllerRef } from '@rocket.chat/mock-providers'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useAppSlashCommands } from './useAppSlashCommands'; +import { slashCommands } from '../../app/utils/client/slashCommand'; + +const mockSlashCommands: SlashCommand[] = [ + { + command: '/test', + description: 'Test command', + params: 'param1 param2', + clientOnly: false, + providesPreview: false, + appId: 'test-app-1', + permission: undefined, + }, + { + command: '/weather', + description: 'Get weather information', + params: 'city', + clientOnly: false, + providesPreview: true, + appId: 'weather-app', + permission: undefined, + }, +]; + +const mockApiResponse = { + commands: mockSlashCommands, + total: mockSlashCommands.length, +}; + +describe('useAppSlashCommands', () => { + let mockGetSlashCommands: jest.Mock; + + beforeEach(() => { + mockGetSlashCommands = jest.fn().mockResolvedValue(mockApiResponse); + + slashCommands.commands = {}; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not fetch data when user ID is not available', () => { + renderHook(() => useAppSlashCommands(), { + wrapper: mockAppRoot().withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands).build(), + }); + + expect(mockGetSlashCommands).not.toHaveBeenCalled(); + expect(slashCommands.commands).toEqual({}); + }); + + it('should fetch slash commands when user ID is available', async () => { + renderHook(() => useAppSlashCommands(), { + wrapper: mockAppRoot().withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands).withJohnDoe().build(), + }); + + await waitFor(() => { + expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length); + }); + }); + + it('should handle command/removed event by invalidating queries', async () => { + const streamRef: StreamControllerRef<'apps'> = {}; + + renderHook(() => useAppSlashCommands(), { + wrapper: mockAppRoot() + .withJohnDoe() + .withStream('apps', streamRef) + .withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands) + .build(), + }); + + expect(streamRef.controller).toBeDefined(); + + await waitFor(() => { + expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length); + }); + + streamRef.controller?.emit('apps', [['command/removed', ['/test']]]); + + expect(slashCommands.commands['/test']).toBeUndefined(); + expect(slashCommands.commands['/weather']).toBeDefined(); + }); + + it('should handle command/added event by invalidating queries', async () => { + const streamRef: StreamControllerRef<'apps'> = {}; + + renderHook(() => useAppSlashCommands(), { + wrapper: mockAppRoot() + .withJohnDoe() + .withStream('apps', streamRef) + .withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands) + .build(), + }); + + expect(streamRef.controller).toBeDefined(); + + await waitFor(() => { + expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length); + }); + + mockGetSlashCommands.mockResolvedValue({ + commands: [ + ...mockSlashCommands, + { + command: '/newcommand', + description: 'New command', + params: 'param1 param2', + clientOnly: false, + }, + ], + total: mockSlashCommands.length + 1, + }); + + streamRef.controller?.emit('apps', [['command/added', ['/newcommand']]]); + + await waitFor(() => { + expect(slashCommands.commands['/newcommand']).toBeDefined(); + }); + + expect(slashCommands.commands['/test']).toBeDefined(); + expect(slashCommands.commands['/weather']).toBeDefined(); + }); + + it('should handle command/updated event by invalidating queries', async () => { + const streamRef: StreamControllerRef<'apps'> = {}; + + renderHook(() => useAppSlashCommands(), { + wrapper: mockAppRoot() + .withJohnDoe() + .withStream('apps', streamRef) + .withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands) + .build(), + }); + + expect(streamRef.controller).toBeDefined(); + + await waitFor(() => { + expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length); + }); + + streamRef.controller?.emit('apps', [['command/updated', ['/test']]]); + + expect(slashCommands.commands['/test']).toBeUndefined(); + expect(slashCommands.commands['/weather']).toBeDefined(); + }); + + it('should ignore command/disabled event', async () => { + const streamRef: StreamControllerRef<'apps'> = {}; + + renderHook(() => useAppSlashCommands(), { + wrapper: mockAppRoot() + .withJohnDoe() + .withStream('apps', streamRef) + .withEndpoint('GET', '/v1/commands.list', mockGetSlashCommands) + .build(), + }); + + expect(streamRef.controller).toBeDefined(); + + await waitFor(() => { + expect(Object.keys(slashCommands.commands)).toHaveLength(mockSlashCommands.length); + }); + + streamRef.controller?.emit('apps', [['command/disabled', ['/test']]]); + + expect(slashCommands.commands['/test']).toBeDefined(); + expect(slashCommands.commands['/weather']).toBeDefined(); + }); + + it('should not set up stream listener when user ID is not available', () => { + const streamRef: StreamControllerRef<'apps'> = {}; + + renderHook(() => useAppSlashCommands(), { + wrapper: mockAppRoot().withStream('apps', streamRef).build(), + }); + + expect(streamRef.controller).toBeDefined(); + expect(streamRef.controller?.has('apps')).toBe(false); + }); +}); diff --git a/apps/meteor/client/hooks/useAppSlashCommands.ts b/apps/meteor/client/hooks/useAppSlashCommands.ts index df33dc8af6150..0425d5172c476 100644 --- a/apps/meteor/client/hooks/useAppSlashCommands.ts +++ b/apps/meteor/client/hooks/useAppSlashCommands.ts @@ -29,7 +29,7 @@ export const useAppSlashCommands = () => { return; } return apps('apps', ([key, [command]]) => { - if (['command/added', 'command/updated', 'command/disabled', 'command/removed'].includes(key)) { + if (['command/added', 'command/updated', 'command/removed'].includes(key)) { if (typeof command === 'string') { delete slashCommands.commands[command]; } diff --git a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx index e06dd7a354a19..2d116f61012da 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx +++ b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx @@ -4,7 +4,7 @@ import { CustomSoundContext, useStream, useUserPreference } from '@rocket.chat/u import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useRef, type ReactNode } from 'react'; -import { defaultSounds, formatVolume, getCustomSoundURL } from './lib/helpers'; +import { defaultSounds, getCustomSoundURL, formatVolume } from './lib'; import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { useUserSoundPreferences } from '../../hooks/useUserSoundPreferences'; diff --git a/apps/meteor/client/providers/CustomSoundProvider/lib/formatVolume.spec.ts b/apps/meteor/client/providers/CustomSoundProvider/lib/formatVolume.spec.ts new file mode 100644 index 0000000000000..e437ac545bf19 --- /dev/null +++ b/apps/meteor/client/providers/CustomSoundProvider/lib/formatVolume.spec.ts @@ -0,0 +1,19 @@ +import { formatVolume } from './formatVolume'; + +describe('formatVolume', () => { + it('returns 1 if volume is 100', () => { + expect(formatVolume(100)).toBe(1); + }); + + it('returns 1 if volume is 200', () => { + expect(formatVolume(200)).toBe(1); + }); + + it('returns 0.5 if volume is 50', () => { + expect(formatVolume(50)).toBe(0.5); + }); + + it('returns 0 if volume is -10', () => { + expect(formatVolume(-10)).toBe(0); + }); +}); diff --git a/apps/meteor/client/providers/CustomSoundProvider/lib/formatVolume.ts b/apps/meteor/client/providers/CustomSoundProvider/lib/formatVolume.ts new file mode 100644 index 0000000000000..2c5d77b3a5e8e --- /dev/null +++ b/apps/meteor/client/providers/CustomSoundProvider/lib/formatVolume.ts @@ -0,0 +1,4 @@ +export const formatVolume = (volume: number) => { + const clamped = Math.max(0, Math.min(volume, 100)); + return Number((clamped / 100).toPrecision(2)); +}; diff --git a/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts b/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts index a81e0bdb85115..49cc0a14b6c81 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts +++ b/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts @@ -45,7 +45,3 @@ export const defaultSounds: ICustomSound[] = [ { _id: 'dialtone', name: 'Sound_Dialtone', extension: 'mp3', src: getAssetUrl('sounds/dialtone.mp3') }, { _id: 'ringtone', name: 'Sound_Ringtone', extension: 'mp3', src: getAssetUrl('sounds/ringtone.mp3') }, ]; - -export const formatVolume = (volume: number) => { - return Number((volume / 100).toPrecision(2)); -}; diff --git a/apps/meteor/client/providers/CustomSoundProvider/lib/index.ts b/apps/meteor/client/providers/CustomSoundProvider/lib/index.ts new file mode 100644 index 0000000000000..a0b17e56d8b1b --- /dev/null +++ b/apps/meteor/client/providers/CustomSoundProvider/lib/index.ts @@ -0,0 +1,2 @@ +export * from './helpers'; +export * from './formatVolume'; diff --git a/apps/meteor/tests/end-to-end/api/channels.ts b/apps/meteor/tests/end-to-end/api/channels.ts index 66690df7985b3..02a859a916cef 100644 --- a/apps/meteor/tests/end-to-end/api/channels.ts +++ b/apps/meteor/tests/end-to-end/api/channels.ts @@ -3503,10 +3503,10 @@ describe('[Channels]', () => { roomId: testChannel._id, }) .expect('Content-Type', 'application/json') - .expect(400) + .expect(401) .expect((res) => { expect(res.body).to.have.a.property('success', false); - expect(res.body).to.have.a.property('error', 'Enable "Allow Anonymous Read" [error-not-allowed]'); + expect(res.body).to.have.a.property('error', 'You must be logged in to do this.'); }) .end(done); }); diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index 45beb3b499784..fa2057137a418 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -681,6 +681,53 @@ describe('[Users]', () => { ]), ); + it('should fail when request is without authentication credentials', async () => { + await request + .get(api('users.info')) + .query({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + describe('authentication', () => { + before(() => updateSetting('Accounts_AllowAnonymousRead', true)); + after(() => updateSetting('Accounts_AllowAnonymousRead', false)); + it('should fail when request is without authentication credentials and Anonymous Read is enabled', async () => { + await request + .get(api('users.info')) + .query({ + userId: targetUser._id, + }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + + it('should fail when request is without token and Anonymous Read is enabled', async () => { + await request + .get(api('users.info')) + .query({ + userId: targetUser._id, + }) + .set({ 'X-User-Id': credentials['X-User-Id'] }) + .expect('Content-Type', 'application/json') + .expect(401) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }); + }); + }); + it('should return an error when the user does not exist', (done) => { void request .get(api('users.info')) diff --git a/packages/apps-engine/src/server/AppManager.ts b/packages/apps-engine/src/server/AppManager.ts index 745aabc6105c7..9b7e8fcde1a9d 100644 --- a/packages/apps-engine/src/server/AppManager.ts +++ b/packages/apps-engine/src/server/AppManager.ts @@ -58,6 +58,7 @@ export interface IAppManagerDeps { interface IPurgeAppConfigOpts { keepScheduledJobs?: boolean; + keepSlashcommands?: boolean; } export class AppManager { @@ -491,7 +492,7 @@ export class AppManager { await app.call(AppMethod.ONDISABLE).catch((e) => console.warn('Error while disabling:', e)); } - await this.purgeAppConfig(app, { keepScheduledJobs: true }); + await this.purgeAppConfig(app, { keepScheduledJobs: true, keepSlashcommands: true }); await app.setStatus(status, silent); @@ -825,6 +826,7 @@ export class AppManager { } })(); + // We don't keep slashcommands here as the update could potentially not provide the same list await this.purgeAppConfig(app, { keepScheduledJobs: true }); this.apps.set(app.getID(), app); @@ -1049,6 +1051,8 @@ export class AppManager { await app.call(AppMethod.INITIALIZE); await app.setStatus(AppStatus.INITIALIZED, silenceStatus); + await this.commandManager.registerCommands(app.getID()); + result = true; } catch (e) { let status = AppStatus.ERROR_DISABLED; @@ -1085,9 +1089,13 @@ export class AppManager { if (!opts.keepScheduledJobs) { await this.schedulerManager.cleanUp(app.getID()); } + + if (!opts.keepSlashcommands) { + await this.commandManager.unregisterCommands(app.getID()); + } + this.listenerManager.unregisterListeners(app); this.listenerManager.lockEssentialEvents(app); - await this.commandManager.unregisterCommands(app.getID()); this.externalComponentManager.unregisterExternalComponents(app.getID()); await this.apiManager.unregisterApis(app.getID()); this.accessorManager.purifyApp(app.getID()); @@ -1161,7 +1169,6 @@ export class AppManager { } if (enable) { - await this.commandManager.registerCommands(app.getID()); this.externalComponentManager.registerExternalComponents(app.getID()); await this.apiManager.registerApis(app.getID()); this.listenerManager.registerListeners(app); @@ -1169,7 +1176,7 @@ export class AppManager { this.videoConfProviderManager.registerProviders(app.getID()); await this.outboundCommunicationProviderManager.registerProviders(app.getID()); } else { - await this.purgeAppConfig(app); + await this.purgeAppConfig(app, { keepScheduledJobs: true, keepSlashcommands: true }); } if (saveToDb) { diff --git a/packages/apps-engine/src/server/managers/AppSlashCommandManager.ts b/packages/apps-engine/src/server/managers/AppSlashCommandManager.ts index 6bdbc7bf85e95..b5c5662adf2a7 100644 --- a/packages/apps-engine/src/server/managers/AppSlashCommandManager.ts +++ b/packages/apps-engine/src/server/managers/AppSlashCommandManager.ts @@ -333,10 +333,12 @@ export class AppSlashCommandManager { const app = this.manager.getOneById(this.touchedCommandsToApps.get(cmd)); - if (!app || AppStatusUtils.isDisabled(await app.getStatus())) { - // Just in case someone decides to do something they shouldn't - // let's ensure the app actually exists - return; + if (!app) { + throw new Error('App not found'); + } + + if (!AppStatusUtils.isEnabled(await app.getStatus())) { + throw new Error('App not enabled'); } const appCmd = this.retrieveCommandInfo(cmd, app.getID()); diff --git a/packages/apps-engine/tests/server/managers/AppSlashCommandManager.spec.ts b/packages/apps-engine/tests/server/managers/AppSlashCommandManager.spec.ts index 7ac88e13ddf69..73d09c18f5e91 100644 --- a/packages/apps-engine/tests/server/managers/AppSlashCommandManager.spec.ts +++ b/packages/apps-engine/tests/server/managers/AppSlashCommandManager.spec.ts @@ -407,7 +407,7 @@ export class AppSlashCommandManagerTestFixture { failedItems.set('failure', asm); (ascm as any).providedCommands.set('failMePlease', failedItems); (ascm as any).touchedCommandsToApps.set('failure', 'failMePlease'); - await Expect(() => ascm.executeCommand('failure', context)).not.toThrowAsync(); + await Expect(() => ascm.executeCommand('failure', context)).toThrowAsync(); AppSlashCommandManagerTestFixture.doThrow = true; await Expect(() => ascm.executeCommand('command', context)).not.toThrowAsync(); diff --git a/packages/mock-providers/src/MockedAppRootBuilder.tsx b/packages/mock-providers/src/MockedAppRootBuilder.tsx index 625934876a135..9b3f706570520 100644 --- a/packages/mock-providers/src/MockedAppRootBuilder.tsx +++ b/packages/mock-providers/src/MockedAppRootBuilder.tsx @@ -9,7 +9,16 @@ import type { Serialized, SettingValue, } from '@rocket.chat/core-typings'; -import type { ServerMethodName, ServerMethodParameters, ServerMethodReturn } from '@rocket.chat/ddp-client'; +import type { + ServerMethodName, + ServerMethodParameters, + ServerMethodReturn, + StreamerCallback, + StreamerCallbackArgs, + StreamerEvents, + StreamKeys, + StreamNames, +} from '@rocket.chat/ddp-client'; import { Emitter } from '@rocket.chat/emitter'; import languages from '@rocket.chat/i18n/dist/languages'; import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; @@ -52,9 +61,35 @@ type Mutable = { }; // eslint-disable-next-line @typescript-eslint/naming-convention -interface MockedAppRootEvents { +// interface MockedAppRootEvents extends Record<`stream-${StreamNames}-${StreamKeys}`, any> { +// 'update-modal': void; +// } +// Extract all key values from objects that have a 'key' property +type ExtractKeys = T extends readonly (infer U)[] + ? U extends { key: infer K } + ? K extends string + ? string extends K + ? never + : `stream-${N}-${K}` + : never + : never + : never; + +// Union of all key values from all streams +type AllStreamerEventKeys = { + [K in keyof StreamerEvents]: ExtractKeys; +}[keyof StreamerEvents]; + +type MockedAppRootEvents = { 'update-modal': void; -} +} & Record; + +export type StreamControllerRef = { + controller?: { + emit: >(eventName: K, args: StreamerCallbackArgs) => void; + has: (eventName: StreamKeys) => boolean; + }; +}; const empty = [] as const; @@ -248,6 +283,30 @@ export class MockedAppRootBuilder { return this; } + withStream(streamName: N, ref: StreamControllerRef): this { + const innerFn = this.server.getStream; + + const outerFn: ServerContextValue['getStream'] = (innerStreamName) => { + if (innerStreamName === (streamName as StreamNames)) { + ref.controller = { + emit: >(eventName: K, args: StreamerCallbackArgs) => { + this.events.emit(`stream-${innerStreamName}-${eventName}` as AllStreamerEventKeys, ...args); + }, + has: (eventName: string) => this.events.has(`stream-${innerStreamName}-${eventName}` as AllStreamerEventKeys), + }; + + return >(eventName: K, callback: StreamerCallback) => + this.events.on(`stream-${innerStreamName}-${eventName}` as AllStreamerEventKeys, callback); + } + + return innerFn(innerStreamName); + }; + + this.server.getStream = outerFn; + + return this; + } + withMethod(methodName: TMethodName, response: () => ServerMethodReturn): this { const innerFn = this.server.callMethod; diff --git a/packages/mock-providers/src/index.ts b/packages/mock-providers/src/index.ts index 941d613a14125..0c3bd99c8dc01 100644 --- a/packages/mock-providers/src/index.ts +++ b/packages/mock-providers/src/index.ts @@ -9,3 +9,4 @@ export * from './MockedServerContext'; export * from './MockedSettingsContext'; export * from './MockedUserContext'; export * from './MockedDeviceContext'; +export * from './MockedAppRootBuilder'; diff --git a/yarn.lock b/yarn.lock index 3105d85186b1d..fc855640fb328 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9387,7 +9387,7 @@ __metadata: peerDependencies: "@rocket.chat/layout": "*" "@rocket.chat/tools": 0.2.3 - "@rocket.chat/ui-contexts": 22.0.0-rc.6 + "@rocket.chat/ui-contexts": 22.0.0 "@tanstack/react-query": "*" react: "*" react-hook-form: "*"