diff --git a/.changeset/fix-presence-comma-ids.md b/.changeset/fix-presence-comma-ids.md new file mode 100644 index 0000000000000..c85d20785e3a6 --- /dev/null +++ b/.changeset/fix-presence-comma-ids.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': patch +'@rocket.chat/rest-typings': patch +--- + +Fixes the `users.presence` endpoint returning an empty array when called with multiple comma-separated IDs, caused by `ajvQuery` coercing the string into a single-element array after the OpenAPI migration \ No newline at end of file diff --git a/.changeset/neat-trams-juggle.md b/.changeset/neat-trams-juggle.md new file mode 100644 index 0000000000000..56f327f68cc0c --- /dev/null +++ b/.changeset/neat-trams-juggle.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Ensures the Meteor method for translateMessage validates access and types diff --git a/apps/meteor/app/authorization/client/hasPermission.ts b/apps/meteor/app/authorization/client/hasPermission.ts index dd48ce48216c2..0a89b1f9403aa 100644 --- a/apps/meteor/app/authorization/client/hasPermission.ts +++ b/apps/meteor/app/authorization/client/hasPermission.ts @@ -1,81 +1,3 @@ -import type { IUser, IPermission } from '@rocket.chat/core-typings'; +import { liveAuthorizationFunctions } from './liveAuthorizationFunctions'; -import { hasRole } from './hasRole'; -import { PermissionsCachedStore } from '../../../client/cachedStores'; -import { watchUserId } from '../../../client/meteor/user'; -import { watch } from '../../../client/meteor/watch'; -import { Permissions, Users } from '../../../client/stores'; -import { AuthorizationUtils } from '../lib/AuthorizationUtils'; - -const createPermissionValidator = - (quantifier: (predicate: (permissionId: IPermission['_id']) => boolean) => boolean) => - (permissionIds: IPermission['_id'][], scope: string | undefined, userId: IUser['_id'], scopedRoles?: IPermission['_id'][]): boolean => { - const userRoles = watch(Users.use, (state) => state.get(userId)?.roles); - - const checkEachPermission = quantifier.bind(permissionIds); - - return checkEachPermission((permissionId) => { - if (userRoles) { - if (AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, userRoles)) { - return false; - } - } - - const permission = watch(Permissions.use, (state) => state.get(permissionId)); - const roles = permission?.roles ?? []; - - return roles.some((roleId) => { - if (scopedRoles?.includes(roleId)) { - return true; - } - - return hasRole(userId, roleId, scope); - }); - }); - }; - -const atLeastOne = createPermissionValidator(Array.prototype.some); - -const all = createPermissionValidator(Array.prototype.every); - -const validatePermissions = ( - permissions: IPermission['_id'] | IPermission['_id'][], - scope: string | undefined, - predicate: ( - permissionIds: IPermission['_id'][], - scope: string | undefined, - userId: IUser['_id'], - scopedRoles?: IPermission['_id'][], - ) => boolean, - userId?: IUser['_id'], - scopedRoles?: IPermission['_id'][], -): boolean => { - userId = userId ?? watchUserId() ?? undefined; - - if (!userId) { - return false; - } - - if (!watch(PermissionsCachedStore.useReady, (state) => state)) { - return false; - } - - return predicate(([] as IPermission['_id'][]).concat(permissions), scope, userId, scopedRoles); -}; - -export const hasAllPermission = ( - permissions: IPermission['_id'] | IPermission['_id'][], - scope?: string, - scopedRoles?: IPermission['_id'][], -): boolean => validatePermissions(permissions, scope, all, undefined, scopedRoles); - -export const hasAtLeastOnePermission = (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string): boolean => - validatePermissions(permissions, scope, atLeastOne); - -export const userHasAllPermission = ( - permissions: IPermission['_id'] | IPermission['_id'][], - scope?: string, - userId?: IUser['_id'], -): boolean => validatePermissions(permissions, scope, all, userId); - -export const hasPermission = hasAllPermission; +export const { hasAllPermission, hasAtLeastOnePermission, hasPermission, userHasAllPermission } = liveAuthorizationFunctions; diff --git a/apps/meteor/app/authorization/client/hasRole.ts b/apps/meteor/app/authorization/client/hasRole.ts index a2e1ea5adc229..2c0879d504163 100644 --- a/apps/meteor/app/authorization/client/hasRole.ts +++ b/apps/meteor/app/authorization/client/hasRole.ts @@ -1,21 +1,3 @@ -import type { IUser, IRole, IRoom } from '@rocket.chat/core-typings'; +import { liveAuthorizationFunctions } from './liveAuthorizationFunctions'; -import { watch } from '../../../client/meteor/watch'; -import { Roles, Subscriptions, Users } from '../../../client/stores'; - -export const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: IRoom['_id']): boolean => { - const roleScope = watch(Roles.use, (state) => state.get(roleId)?.scope ?? 'Users'); - - switch (roleScope) { - case 'Subscriptions': - if (!scope) return false; - - return watch(Subscriptions.use, (state) => state.find((record) => record.rid === scope)?.roles?.includes(roleId) ?? false); - - case 'Users': - return watch(Users.use, (state) => state.get(userId)?.roles?.includes(roleId) ?? false); - - default: - return false; - } -}; +export const { hasRole } = liveAuthorizationFunctions; diff --git a/apps/meteor/app/authorization/client/liveAuthorizationFunctions.ts b/apps/meteor/app/authorization/client/liveAuthorizationFunctions.ts new file mode 100644 index 0000000000000..db1981a884a9f --- /dev/null +++ b/apps/meteor/app/authorization/client/liveAuthorizationFunctions.ts @@ -0,0 +1,25 @@ +import { PermissionsCachedStore } from '../../../client/cachedStores'; +import { userIdStore } from '../../../client/lib/user'; +import { Permissions, Roles, Subscriptions, Users } from '../../../client/stores'; +import type { AuthorizationDeps } from '../lib/createAuthorizationFunctions'; +import { createAuthorizationFunctions } from '../lib/createAuthorizationFunctions'; + +// Bind the pure factory to live zustand store accessors. Each accessor reads +// fresh state on every call, so non-React callers (services, lib code, startup +// scripts) keep their previous "always reflects the current store" contract +// without going through Meteor's Tracker. React consumers should use the +// AuthorizationContext instead, which injects React-reactive snapshots. +const liveDeps: AuthorizationDeps = { + getCurrentUserId: () => userIdStore.getState(), + getUserRoles: (userId) => Users.use.getState().get(userId)?.roles, + getPermission: (permissionId) => Permissions.use.getState().get(permissionId), + getRoleScope: (roleId) => Roles.use.getState().get(roleId)?.scope, + hasSubscriptionRole: (rid, roleId) => + Subscriptions.use + .getState() + .find((s) => s.rid === rid) + ?.roles?.includes(roleId) ?? false, + isReady: () => PermissionsCachedStore.useReady.getState(), +}; + +export const liveAuthorizationFunctions = createAuthorizationFunctions(liveDeps); diff --git a/apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts b/apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts new file mode 100644 index 0000000000000..1b689e176aa1b --- /dev/null +++ b/apps/meteor/app/authorization/lib/createAuthorizationFunctions.ts @@ -0,0 +1,101 @@ +import type { IPermission, IRole, IUser } from '@rocket.chat/core-typings'; + +import { AuthorizationUtils } from './AuthorizationUtils'; + +export type AuthorizationDeps = { + /** The currently logged-in user id, or undefined. */ + getCurrentUserId: () => IUser['_id'] | undefined; + /** The role ids assigned to a given user (Users scope). */ + getUserRoles: (userId: IUser['_id']) => IRole['_id'][] | undefined; + /** Lookup a permission by id. */ + getPermission: (permissionId: IPermission['_id']) => IPermission | undefined; + /** The scope of a role; defaults to 'Users' when the role is unknown. */ + getRoleScope: (roleId: IRole['_id']) => IRole['scope'] | undefined; + /** Whether a subscription scoped to `rid` grants `roleId`. */ + hasSubscriptionRole: (rid: string, roleId: IRole['_id']) => boolean; + /** Whether the permissions cache is hydrated; otherwise checks short-circuit to false. */ + isReady: () => boolean; +}; + +export type AuthorizationFunctions = { + hasRole: (userId: IUser['_id'], roleId: IRole['_id'], scope?: string) => boolean; + hasAllPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean; + hasAtLeastOnePermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string) => boolean; + /** Alias of hasAllPermission, kept for parity with the previous API. */ + hasPermission: (permissions: IPermission['_id'] | IPermission['_id'][], scope?: string, scopedRoles?: IRole['_id'][]) => boolean; + userHasAllPermission: ( + permissions: IPermission['_id'] | IPermission['_id'][], + scope: string | undefined, + userId: IUser['_id'], + ) => boolean; +}; + +/** + * Pure factory for the client-side authorization helpers. All store access is + * threaded through the {@link AuthorizationDeps} accessors, so the returned + * functions are testable in isolation and reusable across any state backend. + */ +export const createAuthorizationFunctions = (deps: AuthorizationDeps): AuthorizationFunctions => { + const hasRole = (userId: IUser['_id'], roleId: IRole['_id'], scope?: string): boolean => { + const roleScope = deps.getRoleScope(roleId) ?? 'Users'; + switch (roleScope) { + case 'Subscriptions': + if (!scope) return false; + return deps.hasSubscriptionRole(scope, roleId); + case 'Users': + return deps.getUserRoles(userId)?.includes(roleId) ?? false; + default: + return false; + } + }; + + const checkPermissions = ( + permissionIds: IPermission['_id'][], + scope: string | undefined, + userId: IUser['_id'], + scopedRoles: IRole['_id'][] | undefined, + quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean, + ): boolean => { + const userRoles = deps.getUserRoles(userId); + return quantifier.call(permissionIds, (permissionId) => { + if (userRoles && AuthorizationUtils.isPermissionRestrictedForRoleList(permissionId, userRoles)) { + return false; + } + const roles = deps.getPermission(permissionId)?.roles ?? []; + return roles.some((roleId) => { + if (scopedRoles?.includes(roleId)) return true; + return hasRole(userId, roleId, scope); + }); + }); + }; + + const validatePermissions = ( + permissions: IPermission['_id'] | IPermission['_id'][], + scope: string | undefined, + quantifier: (this: IPermission['_id'][], predicate: (id: IPermission['_id']) => boolean) => boolean, + userId: IUser['_id'] | undefined, + scopedRoles?: IRole['_id'][], + ): boolean => { + if (!userId) return false; + if (!deps.isReady()) return false; + const ids = ([] as IPermission['_id'][]).concat(permissions); + return checkPermissions(ids, scope, userId, scopedRoles, quantifier); + }; + + const hasAllPermission: AuthorizationFunctions['hasAllPermission'] = (permissions, scope, scopedRoles) => + validatePermissions(permissions, scope, Array.prototype.every, deps.getCurrentUserId(), scopedRoles); + + const hasAtLeastOnePermission: AuthorizationFunctions['hasAtLeastOnePermission'] = (permissions, scope) => + validatePermissions(permissions, scope, Array.prototype.some, deps.getCurrentUserId()); + + const userHasAllPermission: AuthorizationFunctions['userHasAllPermission'] = (permissions, scope, userId) => + validatePermissions(permissions, scope, Array.prototype.every, userId); + + return { + hasRole, + hasAllPermission, + hasAtLeastOnePermission, + hasPermission: hasAllPermission, + userHasAllPermission, + }; +}; diff --git a/apps/meteor/app/autotranslate/server/methods/translateMessage.ts b/apps/meteor/app/autotranslate/server/methods/translateMessage.ts index c9d2cc43996be..673f8dcf153e7 100644 --- a/apps/meteor/app/autotranslate/server/methods/translateMessage.ts +++ b/apps/meteor/app/autotranslate/server/methods/translateMessage.ts @@ -1,7 +1,10 @@ import type { IMessage } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Rooms } from '@rocket.chat/models'; +import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { canAccessRoomAsync } from '../../../authorization/server'; import { translateMessage } from '../functions/translateMessage'; declare module '@rocket.chat/ddp-client' { @@ -13,6 +16,22 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async 'autoTranslate.translateMessage'(message, targetLanguage) { - return translateMessage(targetLanguage, message); + const userId = Meteor.userId(); + if (!userId) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { + method: 'autoTranslate.translateMessage', + }); + } + check(message?._id, String); + check(targetLanguage, String); + const msg = await Messages.findOneById(message._id); + if (!msg) { + throw new Meteor.Error('error-message-not-found', 'Message not found'); + } + const room = await Rooms.findOneById(msg.rid); + if (!room || !(await canAccessRoomAsync(room, { _id: userId }))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + return translateMessage(targetLanguage, msg); }, }); diff --git a/apps/meteor/client/providers/AuthorizationProvider.tsx b/apps/meteor/client/providers/AuthorizationProvider.tsx index 43bab7852c273..ca494a25bf9b6 100644 --- a/apps/meteor/client/providers/AuthorizationProvider.tsx +++ b/apps/meteor/client/providers/AuthorizationProvider.tsx @@ -1,20 +1,35 @@ +import type { IUser } from '@rocket.chat/core-typings'; import { AuthorizationContext, useUserId } from '@rocket.chat/ui-contexts'; -import type { ReactNode } from 'react'; -import { useMemo } from 'react'; +import type { ContextType, ReactNode } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; -import { hasPermission, hasAtLeastOnePermission, hasAllPermission, hasRole } from '../../app/authorization/client'; +import { createAuthorizationFunctions } from '../../app/authorization/lib/createAuthorizationFunctions'; import { PermissionsCachedStore } from '../cachedStores'; -import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; -import { Roles } from '../stores'; +import { Permissions, Roles, Subscriptions, Users } from '../stores'; + +// Only the slice of IUser that the authorization helpers actually read. +// Snapshotting just `roles` (instead of the full user document) keeps the +// provider from re-rendering on presence/status updates, last-login flips, +// avatar etag changes, etc. — none of which affect any permission answer. +type AuthorizableUser = Pick; type AuthorizationProviderProps = { children?: ReactNode; }; +const noopSubscribe = (): (() => void) => () => undefined; + +const subscribeToSubscriptions = (onStoreChange: () => void): (() => void) => Subscriptions.use.subscribe(onStoreChange); + +const selectUserRoles = (userId: IUser['_id'] | undefined): AuthorizableUser['roles'] | undefined => { + if (!userId) return undefined; + return Users.use.getState().get(userId)?.roles; +}; + const AuthorizationProvider = ({ children }: AuthorizationProviderProps) => { - const isLoading = !PermissionsCachedStore.useReady(); + const isReady = PermissionsCachedStore.useReady(); - if (isLoading) { + if (!isReady) { throw (async () => { PermissionsCachedStore.listen(); await PermissionsCachedStore.init(); @@ -23,16 +38,72 @@ const AuthorizationProvider = ({ children }: AuthorizationProviderProps) => { const userId = useUserId(); + // Permissions and Roles change infrequently (admin-driven or login-time only); + // observing the whole map is cheap and re-renders propagate the new auth + // answers through context to every consumer. + const permissionsState = useSyncExternalStore(Permissions.use.subscribe, () => Permissions.use.getState()); + const rolesState = useSyncExternalStore(Roles.use.subscribe, () => Roles.use.getState()); + // For Users, only the current user's `roles` array is relevant for auth + // decisions (hooks dispatch via getCurrentUserId; `userHasAllPermission` with + // an arbitrary userId has no real callers). Subscribing to the full Users map + // would re-render the provider on every presence update for every user. The + // custom getSnapshot returns the same array reference until the current + // user's roles actually change, so useSyncExternalStore short-circuits via + // Object.is and the provider stays still through unrelated user churn. + const currentUserRoles = useSyncExternalStore(Users.use.subscribe, () => selectUserRoles(userId)); + // Subscriptions.use is intentionally NOT observed here — it updates on every + // incoming message, member change, and unread-count flip. Subscription-scoped + // permission checks subscribe per-call below. + + const auth = useMemo( + () => + createAuthorizationFunctions({ + getCurrentUserId: () => userId, + // Fast path for the only userId hook consumers ever pass; live read for + // any other userId (only userHasAllPermission can reach this branch). + getUserRoles: (id) => (id === userId ? currentUserRoles : Users.use.getState().get(id)?.roles), + getPermission: (id) => permissionsState.get(id), + getRoleScope: (id) => rolesState.get(id)?.scope, + // Read Subscriptions live — reactivity for scoped checks is wired through + // the per-call subscribe returned by queryPermission/queryRole below. + hasSubscriptionRole: (rid, roleId) => + Subscriptions.use + .getState() + .find((s) => s.rid === rid) + ?.roles?.includes(roleId) ?? false, + isReady: () => true, + }), + [userId, currentUserRoles, permissionsState, rolesState], + ); + const contextValue = useMemo( - () => ({ - queryPermission: createReactiveSubscriptionFactory((permission, scope, scopeRoles) => hasPermission(permission, scope, scopeRoles)), - queryAtLeastOnePermission: createReactiveSubscriptionFactory((permissions, scope) => hasAtLeastOnePermission(permissions, scope)), - queryAllPermissions: createReactiveSubscriptionFactory((permissions, scope) => hasAllPermission(permissions, scope)), - queryRole: createReactiveSubscriptionFactory((role, scope?) => !!userId && hasRole(userId, role, scope)), + (): ContextType => ({ + // Callers without `scope` never touch Subscriptions (the factory short-circuits + // at the role-scope gate). They rely on context-value identity for re-renders + // from Users/Permissions/Roles changes — which is why subscribe is noop. + // Callers with a `scope` (room id) DO touch Subscriptions, so we attach a + // per-call subscribe to that store so they re-evaluate when subscriptions + // for the relevant room flip without dragging the rest of the tree along. + queryPermission: (permission, scope, scopeRoles) => [ + scope !== undefined ? subscribeToSubscriptions : noopSubscribe, + () => auth.hasPermission(String(permission), scope ? String(scope) : undefined, scopeRoles), + ], + queryAtLeastOnePermission: (permissions, scope) => [ + scope !== undefined ? subscribeToSubscriptions : noopSubscribe, + () => auth.hasAtLeastOnePermission(permissions.map(String), scope ? String(scope) : undefined), + ], + queryAllPermissions: (permissions, scope) => [ + scope !== undefined ? subscribeToSubscriptions : noopSubscribe, + () => auth.hasAllPermission(permissions.map(String), scope ? String(scope) : undefined), + ], + queryRole: (role, scope) => [ + scope !== undefined ? subscribeToSubscriptions : noopSubscribe, + () => !!userId && auth.hasRole(userId, String(role), scope), + ], getRoles: () => Roles.state.records, - subscribeToRoles: (callback: () => void) => Roles.use.subscribe(callback), + subscribeToRoles: (callback) => Roles.use.subscribe(callback), }), - [userId], + [auth, userId], ); return {children}; diff --git a/apps/meteor/client/views/admin/workspace/DeploymentCard/components/InstancesModal/InstancesModal.stories.tsx b/apps/meteor/client/views/admin/workspace/DeploymentCard/components/InstancesModal/InstancesModal.stories.tsx index d9fbddc9e3e92..6c52749684d84 100644 --- a/apps/meteor/client/views/admin/workspace/DeploymentCard/components/InstancesModal/InstancesModal.stories.tsx +++ b/apps/meteor/client/views/admin/workspace/DeploymentCard/components/InstancesModal/InstancesModal.stories.tsx @@ -25,7 +25,6 @@ Default.args = { connected: true, }, instanceRecord: { - _updatedAt: new Date(), _createdAt: new Date(), _id: 'instance-id', name: 'instance-name', diff --git a/apps/meteor/client/views/admin/workspace/DeploymentCard/components/InstancesModal/InstancesModal.tsx b/apps/meteor/client/views/admin/workspace/DeploymentCard/components/InstancesModal/InstancesModal.tsx index 0553f692d5d22..06eb87223d30c 100644 --- a/apps/meteor/client/views/admin/workspace/DeploymentCard/components/InstancesModal/InstancesModal.tsx +++ b/apps/meteor/client/views/admin/workspace/DeploymentCard/components/InstancesModal/InstancesModal.tsx @@ -70,15 +70,6 @@ const InstancesModal = ({ instances = [], onClose }: InstancesModalProps) => { > {formatDateAndTime(instanceRecord?._createdAt)} - - {t('Instance_Record')} > {t('Updated_at')} - - } - > - {formatDateAndTime(instanceRecord?._updatedAt)} - ))} diff --git a/apps/meteor/tests/end-to-end/api/autotranslate.ts b/apps/meteor/tests/end-to-end/api/autotranslate.ts index 25171b038a3b0..4d6f65c67de80 100644 --- a/apps/meteor/tests/end-to-end/api/autotranslate.ts +++ b/apps/meteor/tests/end-to-end/api/autotranslate.ts @@ -3,7 +3,7 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { before, describe, after, it } from 'mocha'; -import { getCredentials, api, request, credentials } from '../../data/api-data'; +import { getCredentials, api, request, credentials, methodCall } from '../../data/api-data'; import { sendSimpleMessage } from '../../data/chat.helper'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; @@ -434,6 +434,94 @@ describe('AutoTranslate', () => { }); }); + describe('[autoTranslate.translateMessage method]', () => { + let userA: TestUser; + let userB: TestUser; + let credA: Credentials; + let credB: Credentials; + let privateRoom: IRoom; + let privateMessage: IMessage; + + before(async () => { + await updateSetting('AutoTranslate_Enabled', true); + + userA = await createUser(); + userB = await createUser(); + + credA = await login(userA.username, password); + credB = await login(userB.username, password); + + privateRoom = ( + await createRoom({ + type: 'p', + name: `test-autotranslate-method-${Date.now()}`, + credentials: credA, + }) + ).body.group; + + const msgRes = await sendSimpleMessage({ + roomId: privateRoom._id, + text: 'Isso é um teste', + userCredentials: credA, + }); + privateMessage = msgRes.body.message; + }); + + after(async () => { + await Promise.all([ + updateSetting('AutoTranslate_Enabled', false), + deleteUser(userA), + deleteUser(userB), + deleteRoom({ type: 'p', roomId: privateRoom._id }), + ]); + }); + + it('should fail when messageId is not a string', (done) => { + void request + .post(methodCall('autoTranslate.translateMessage')) + .set(credA) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id', + method: 'autoTranslate.translateMessage', + params: [{ _id: { $gt: '' } }, 'en'], + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.a.property('error'); + }) + .end(done); + }); + + it('should return error-not-allowed when the caller is not a member of the room', (done) => { + void request + .post(methodCall('autoTranslate.translateMessage')) + .set(credB) + .send({ + message: JSON.stringify({ + msg: 'method', + id: 'id', + method: 'autoTranslate.translateMessage', + params: [{ _id: privateMessage._id }, 'en'], + }), + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + const parsedBody = JSON.parse(res.body.message); + expect(parsedBody).to.have.a.property('error'); + expect(parsedBody.error).to.have.a.property('error', 'error-not-allowed'); + }) + .end(done); + }); + }); + describe('Autoenable setting', () => { let userA: TestUser; let userB: TestUser; diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index cd88b8ab6e000..c8b87a1df9d3b 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -1392,6 +1392,48 @@ describe('[Users]', () => { .end(done); }); + it('should return presence for a single id', async () => { + const res = await request + .get(api('users.presence')) + .query({ ids: 'rocket.cat' }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('full', false); + expect(res.body).to.have.property('users').that.is.an('array').with.lengthOf(1); + expect(res.body.users[0]).to.have.property('_id', 'rocket.cat'); + }); + + it('should correctly parse comma-separated ids and not return an empty result', async () => { + const res = await request + .get(api('users.presence')) + .query({ ids: `rocket.cat,${credentials['X-User-Id']}` }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('full', false); + // only rocket.cat is guaranteed to be online; admin may be offline + expect(res.body.users.map((u: IUser) => u._id)).to.include('rocket.cat'); + }); + + it('should return presence for repeated ids params', async () => { + const res = await request + .get(api('users.presence')) + .query(`ids=rocket.cat&ids=${credentials['X-User-Id']}`) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('full', false); + // only rocket.cat is guaranteed to be online; admin may be offline + expect(res.body.users.map((u: IUser) => u._id)).to.include('rocket.cat'); + }); + it('should return full list of online users for more than 10 minutes in the past', (done) => { const date = new Date(); date.setMinutes(date.getMinutes() - 11); diff --git a/packages/core-typings/src/IInstanceStatus.ts b/packages/core-typings/src/IInstanceStatus.ts index d5fd280280b47..a1b762db622bf 100644 --- a/packages/core-typings/src/IInstanceStatus.ts +++ b/packages/core-typings/src/IInstanceStatus.ts @@ -1,6 +1,5 @@ -import type { IRocketChatRecord } from './IRocketChatRecord'; - -export interface IInstanceStatus extends IRocketChatRecord { +export interface IInstanceStatus { + _id: string; _createdAt: Date; name: string; pid: number; diff --git a/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts b/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts index 9fcd9f49b9fd3..d35a98462c2ad 100644 --- a/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts +++ b/packages/rest-typings/src/v1/users/UsersPresenceParamsGET.ts @@ -10,7 +10,8 @@ const UsersPresenceParamsGetSchema = { properties: { from: { type: 'string', nullable: true }, ids: { - anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + type: ['string', 'array'], + items: { type: 'string' }, }, }, additionalProperties: false,