Skip to content
Snippets Groups Projects
Unverified Commit 5dac62ce authored by Guilherme Gazzo's avatar Guilherme Gazzo Committed by GitHub
Browse files

regression: Apply right filters to action buttons and convert...

regression:  Apply right filters to action buttons and convert `applyButtonFilters` to `useApplyButtonFilters` (#29822)
parent 841ec667
No related branches found
No related tags found
No related merge requests found
......@@ -38,5 +38,5 @@ module.exports = {
'tests/unit/lib/**/*.tests.ts',
'tests/unit/client/**/*.test.ts',
],
exclude: ['client/hooks/*.spec.{ts,tsx}'],
exclude: ['client/hooks/*.spec.{ts,tsx}', 'client/sidebar/header/actions/hooks/*.spec.{ts,tsx}'],
};
......@@ -4,13 +4,13 @@ import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useMemo } from 'react';
import { applyButtonFilters } from '../../app/ui-message/client/actionButtons/lib/applyButtonFilters';
import type { MessageActionConfig, MessageActionContext } from '../../app/ui-utils/client/lib/MessageAction';
import type { MessageBoxAction } from '../../app/ui-utils/client/lib/messageBox';
import { Utilities } from '../../ee/lib/misc/Utilities';
import type { GenericMenuItemProps } from '../components/GenericMenu/GenericMenuItem';
import { useRoom } from '../views/room/contexts/RoomContext';
import type { ToolboxAction } from '../views/room/lib/Toolbox';
import { useApplyButtonFilters, useApplyButtonAuthFilter } from './useApplyButtonFilters';
import { useUiKitActionManager } from './useUiKitActionManager';
const getIdForActionButton = ({ appId, actionId }: IUIActionButton): string => `${appId}/${actionId}`;
......@@ -49,13 +49,14 @@ export const useAppActionButtons = (context?: `${UIActionButtonContext}`) => {
export const useMessageboxAppsActionButtons = () => {
const result = useAppActionButtons('messageBoxAction');
const actionManager = useUiKitActionManager();
const room = useRoom();
const applyButtonFilters = useApplyButtonFilters();
const data = useMemo(
() =>
result.data
?.filter((action) => {
return applyButtonFilters(action, room);
return applyButtonFilters(action);
})
.map((action) => {
const item: MessageBoxAction = {
......@@ -74,7 +75,7 @@ export const useMessageboxAppsActionButtons = () => {
return item;
}),
[actionManager, result.data, room],
[actionManager, applyButtonFilters, result.data],
);
return {
...result,
......@@ -86,6 +87,8 @@ export const useUserDropdownAppsActionButtons = () => {
const result = useAppActionButtons('userDropdownAction');
const actionManager = useUiKitActionManager();
const applyButtonFilters = useApplyButtonAuthFilter();
const data = useMemo(
() =>
result.data
......@@ -106,7 +109,7 @@ export const useUserDropdownAppsActionButtons = () => {
},
};
}),
[actionManager, result.data],
[actionManager, applyButtonFilters, result.data],
);
return {
...result,
......@@ -117,6 +120,7 @@ export const useUserDropdownAppsActionButtons = () => {
export const useRoomActionAppsActionButtons = (context?: MessageActionContext) => {
const result = useAppActionButtons('roomAction');
const actionManager = useUiKitActionManager();
const applyButtonFilters = useApplyButtonFilters();
const room = useRoom();
const data = useMemo(
() =>
......@@ -125,7 +129,7 @@ export const useRoomActionAppsActionButtons = (context?: MessageActionContext) =
if (context && ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'].includes(context)) {
return false;
}
return applyButtonFilters(action, room);
return applyButtonFilters(action);
})
.map((action) => {
const item: [string, ToolboxAction] = [
......@@ -149,7 +153,7 @@ export const useRoomActionAppsActionButtons = (context?: MessageActionContext) =
];
return item;
}),
[actionManager, context, result.data, room],
[actionManager, applyButtonFilters, context, result.data, room._id],
);
return {
...result,
......@@ -160,8 +164,7 @@ export const useRoomActionAppsActionButtons = (context?: MessageActionContext) =
export const useMessageActionAppsActionButtons = (context?: MessageActionContext) => {
const result = useAppActionButtons('messageAction');
const actionManager = useUiKitActionManager();
const room = useRoom();
const applyButtonFilters = useApplyButtonFilters();
const data = useMemo(
() =>
result.data
......@@ -172,7 +175,7 @@ export const useMessageActionAppsActionButtons = (context?: MessageActionContext
) {
return false;
}
return applyButtonFilters(action, room);
return applyButtonFilters(action);
})
.map((action) => {
const item: MessageActionConfig = {
......@@ -192,7 +195,7 @@ export const useMessageActionAppsActionButtons = (context?: MessageActionContext
return item;
}),
[actionManager, context, result.data, room],
[actionManager, applyButtonFilters, context, result.data],
);
return {
...result,
......
/* Style disabled as having some arrow functions in one-line hurts readability */
/* eslint-disable arrow-body-style */
import { Meteor } from 'meteor/meteor';
import type { IUIActionButton } from '@rocket.chat/apps-engine/definition/ui';
import { RoomTypeFilter } from '@rocket.chat/apps-engine/definition/ui';
import type { IRoom } from '@rocket.chat/core-typings';
......@@ -14,23 +10,10 @@ import {
isPublicDiscussion,
isPublicTeamRoom,
} from '@rocket.chat/core-typings';
import { AuthorizationContext, useUserId } from '@rocket.chat/ui-contexts';
import { useCallback, useContext } from 'react';
import { hasAtLeastOnePermission, hasPermission, hasRole, hasAnyRole } from '../../../../authorization/client';
const applyAuthFilter = (button: IUIActionButton, room?: IRoom, ignoreSubscriptions = false): boolean => {
const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {};
const userId = Meteor.userId();
const hasAllPermissionsResult = hasAllPermissions ? hasPermission(hasAllPermissions) : true;
const hasOnePermissionResult = hasOnePermission ? hasAtLeastOnePermission(hasOnePermission) : true;
const hasAllRolesResult = hasAllRoles
? !!userId && hasAllRoles.every((role) => hasRole(userId, role, room?._id, ignoreSubscriptions))
: true;
const hasOneRoleResult = hasOneRole ? !!userId && hasAnyRole(userId, hasOneRole, room?._id, ignoreSubscriptions) : true;
return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult;
};
import { useRoom } from '../views/room/contexts/RoomContext';
const enumToFilter: { [k in RoomTypeFilter]: (room: IRoom) => boolean } = {
[RoomTypeFilter.PUBLIC_CHANNEL]: (room) => room.t === 'c',
......@@ -49,10 +32,34 @@ const applyRoomFilter = (button: IUIActionButton, room: IRoom): boolean => {
return !roomTypes || roomTypes.some((filter): boolean => enumToFilter[filter]?.(room));
};
export const applyButtonFilters = (button: IUIActionButton, room?: IRoom): boolean => {
return applyAuthFilter(button, room) && (!room || applyRoomFilter(button, room));
export const useApplyButtonFilters = (): ((button: IUIActionButton) => boolean) => {
const room = useRoom();
if (!room) {
throw new Error('useApplyButtonFilters must be used inside a room context');
}
const applyAuthFilter = useApplyButtonAuthFilter();
return useCallback(
(button: IUIActionButton) => applyAuthFilter(button) && (!room || applyRoomFilter(button, room)),
[applyAuthFilter, room],
);
};
export const applyDropdownActionButtonFilters = (button: IUIActionButton): boolean => {
return applyAuthFilter(button, undefined, true);
export const useApplyButtonAuthFilter = (): ((button: IUIActionButton) => boolean) => {
const uid = useUserId();
const { queryAllPermissions, queryAtLeastOnePermission, queryRole } = useContext(AuthorizationContext);
return useCallback(
(button: IUIActionButton, room?: IRoom) => {
const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {};
const hasAllPermissionsResult = hasAllPermissions ? queryAllPermissions(hasAllPermissions)[1]() : true;
const hasOnePermissionResult = hasOnePermission ? queryAtLeastOnePermission(hasOnePermission)[1]() : true;
const hasAllRolesResult = hasAllRoles ? !!uid && hasAllRoles.every((role) => queryRole(role, room?._id)) : true;
const hasOneRoleResult = hasOneRole ? !!uid && hasOneRole.some((role) => queryRole(role, room?._id)[1]()) : true;
return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult;
},
[queryAllPermissions, queryAtLeastOnePermission, queryRole, uid],
);
};
......@@ -20,7 +20,10 @@ const contextValue = {
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) => !!Meteor.userId() && hasRole(Meteor.userId() as string, role)),
queryRole: createReactiveSubscriptionFactory(
(role, scope?, ignoreSubscriptions = false) =>
!!Meteor.userId() && hasRole(Meteor.userId() as string, role, scope, ignoreSubscriptions),
),
roleStore: new RoleStore(),
};
......
/* eslint-disable react/no-multi-comp */
import { MockedAuthorizationContext } from '@rocket.chat/mock-providers/src/MockedAuthorizationContext';
import { MockedServerContext } from '@rocket.chat/mock-providers/src/MockedServerContext';
import { MockedSettingsContext } from '@rocket.chat/mock-providers/src/MockedSettingsContext';
import { MockedUserContext } from '@rocket.chat/mock-providers/src/MockedUserContext';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { ActionManagerContext } from '../../../../contexts/ActionManagerContext';
import { useAppsItems } from './useAppsItems';
it('should return and empty array if the user does not have `manage-apps` and `access-marketplace` permission', () => {
const { result } = renderHook(
() => {
return useAppsItems();
},
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MockedServerContext
handleRequest={(args) => {
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') {
return [] as any;
}
}}
>
<MockedSettingsContext settings={{}}>
<MockedUserContext userPreferences={{}}>
<MockedUiKitActionManager>{children}</MockedUiKitActionManager>
</MockedUserContext>
</MockedSettingsContext>
</MockedServerContext>
</QueryClientProvider>
),
},
);
expect(result.all[0]).toEqual([]);
});
it('should return `marketplace` and `installed` items if the user has `access-marketplace` permission', () => {
const { result } = renderHook(
() => {
return useAppsItems();
},
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MockedServerContext
handleRequest={(args) => {
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') {
return [] as any;
}
}}
>
<MockedAuthorizationContext permissions={['access-marketplace']}>
<MockedSettingsContext settings={{}}>
<MockedUserContext userPreferences={{}}>
<MockedUiKitActionManager>{children}</MockedUiKitActionManager>
</MockedUserContext>
</MockedSettingsContext>
</MockedAuthorizationContext>
</MockedServerContext>
</QueryClientProvider>
),
},
);
expect(result.current[0]).toEqual(
expect.objectContaining({
id: 'marketplace',
}),
);
expect(result.current[1]).toEqual(
expect.objectContaining({
id: 'installed',
}),
);
});
it('should return `marketplace` and `installed` items if the user has `manage-apps` permission', () => {
const { result } = renderHook(
() => {
return useAppsItems();
},
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MockedServerContext
handleRequest={async (args) => {
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') {
return {
data: {
totalSeen: 0,
totalUnseen: 1,
},
} as any;
}
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') {
return [] as any;
}
throw new Error('Method not mocked');
}}
>
<MockedAuthorizationContext permissions={['manage-apps']}>
<MockedSettingsContext settings={{}}>
<MockedUserContext userPreferences={{}}>
<MockedUiKitActionManager>{children}</MockedUiKitActionManager>
</MockedUserContext>
</MockedSettingsContext>
</MockedAuthorizationContext>
</MockedServerContext>
</QueryClientProvider>
),
},
);
expect(result.current[0]).toEqual(
expect.objectContaining({
id: 'marketplace',
}),
);
expect(result.current[1]).toEqual(
expect.objectContaining({
id: 'installed',
}),
);
expect(result.current[2]).toEqual(
expect.objectContaining({
id: 'requested-apps',
}),
);
});
it('should return one action from the server with no conditions', async () => {
const { result, waitForValueToChange } = renderHook(
() => {
return useAppsItems();
},
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MockedServerContext
handleRequest={async (args) => {
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') {
return {
data: {
totalSeen: 0,
totalUnseen: 1,
},
} as any;
}
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') {
return [
{
appId: 'APP_ID',
actionId: 'ACTION_ID',
labelI18n: 'LABEL_I18N',
context: 'userDropdownAction',
},
] as any;
}
throw new Error('Method not mocked');
}}
>
<MockedAuthorizationContext permissions={['manage-apps']}>
<MockedSettingsContext settings={{}}>
<MockedUserContext userPreferences={{}}>
<MockedUiKitActionManager>{children}</MockedUiKitActionManager>
</MockedUserContext>
</MockedSettingsContext>
</MockedAuthorizationContext>
</MockedServerContext>
</QueryClientProvider>
),
},
);
expect(result.current[0]).toEqual(
expect.objectContaining({
id: 'marketplace',
}),
);
expect(result.current[1]).toEqual(
expect.objectContaining({
id: 'installed',
}),
);
await waitForValueToChange(() => result.current[3]);
expect(result.current[3]).toEqual(
expect.objectContaining({
id: 'APP_ID_ACTION_ID',
}),
);
});
describe('User Dropdown actions with role conditions', () => {
it('should return the action if the user has admin role', async () => {
const { result, waitForValueToChange } = renderHook(
() => {
return useAppsItems();
},
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MockedServerContext
handleRequest={async (args) => {
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') {
return {
data: {
totalSeen: 0,
totalUnseen: 1,
},
} as any;
}
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') {
return [
{
appId: 'APP_ID',
actionId: 'ACTION_ID',
labelI18n: 'LABEL_I18N',
context: 'userDropdownAction',
when: {
hasOneRole: ['admin'],
},
},
] as any;
}
throw new Error('Method not mocked');
}}
>
<MockedAuthorizationContext permissions={['manage-apps']} roles={['admin']}>
<MockedSettingsContext settings={{}}>
<MockedUserContext userPreferences={{}}>
<MockedUiKitActionManager>{children}</MockedUiKitActionManager>
</MockedUserContext>
</MockedSettingsContext>
</MockedAuthorizationContext>
</MockedServerContext>
</QueryClientProvider>
),
},
);
await waitForValueToChange(() => {
return queryClient.isFetching();
});
expect(result.current[0]).toEqual(
expect.objectContaining({
id: 'marketplace',
}),
);
expect(result.current[1]).toEqual(
expect.objectContaining({
id: 'installed',
}),
);
expect(result.current[3]).toEqual(
expect.objectContaining({
id: 'APP_ID_ACTION_ID',
}),
);
});
it('should return filter the action if the user doesn`t have admin role', async () => {
const { result, waitForValueToChange } = renderHook(
() => {
return useAppsItems();
},
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MockedServerContext
handleRequest={async (args) => {
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') {
return {
data: {
totalSeen: 0,
totalUnseen: 1,
},
} as any;
}
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') {
return [
{
appId: 'APP_ID',
actionId: 'ACTION_ID',
labelI18n: 'LABEL_I18N',
context: 'userDropdownAction',
when: {
hasOneRole: ['admin'],
},
},
] as any;
}
throw new Error('Method not mocked');
}}
>
<MockedAuthorizationContext permissions={['manage-apps']}>
<MockedSettingsContext settings={{}}>
<MockedUserContext userPreferences={{}}>
<MockedUiKitActionManager>{children}</MockedUiKitActionManager>
</MockedUserContext>
</MockedSettingsContext>
</MockedAuthorizationContext>
</MockedServerContext>
</QueryClientProvider>
),
},
);
await waitForValueToChange(() => {
return queryClient.isFetching();
});
expect(result.current[0]).toEqual(
expect.objectContaining({
id: 'marketplace',
}),
);
expect(result.current[1]).toEqual(
expect.objectContaining({
id: 'installed',
}),
);
expect(result.current[2]).toEqual(
expect.objectContaining({
id: 'requested-apps',
}),
);
expect(result.current[3]).toEqual(undefined);
});
});
describe('User Dropdown actions with permission conditions', () => {
it('should return the action if the user has manage-apps permission', async () => {
const { result, waitForValueToChange } = renderHook(
() => {
return useAppsItems();
},
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MockedServerContext
handleRequest={async (args) => {
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') {
return {
data: {
totalSeen: 0,
totalUnseen: 1,
},
} as any;
}
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') {
return [
{
appId: 'APP_ID',
actionId: 'ACTION_ID',
labelI18n: 'LABEL_I18N',
context: 'userDropdownAction',
when: {
hasOnePermission: ['manage-apps'],
},
},
] as any;
}
throw new Error('Method not mocked');
}}
>
<MockedAuthorizationContext permissions={['manage-apps']}>
<MockedSettingsContext settings={{}}>
<MockedUserContext userPreferences={{}}>
<MockedUiKitActionManager>{children}</MockedUiKitActionManager>
</MockedUserContext>
</MockedSettingsContext>
</MockedAuthorizationContext>
</MockedServerContext>
</QueryClientProvider>
),
},
);
await waitForValueToChange(() => {
return queryClient.isFetching();
});
expect(result.current[0]).toEqual(
expect.objectContaining({
id: 'marketplace',
}),
);
expect(result.current[1]).toEqual(
expect.objectContaining({
id: 'installed',
}),
);
expect(result.current[3]).toEqual(
expect.objectContaining({
id: 'APP_ID_ACTION_ID',
}),
);
});
it('should return filter the action if the user doesn`t have `any` permission', async () => {
const { result, waitForValueToChange } = renderHook(
() => {
return useAppsItems();
},
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<MockedServerContext
handleRequest={async (args) => {
if (args.method === 'GET' && args.pathPattern === '/apps/app-request/stats') {
return {
data: {
totalSeen: 0,
totalUnseen: 1,
},
} as any;
}
if (args.method === 'GET' && args.pathPattern === '/apps/actionButtons') {
return [
{
appId: 'APP_ID',
actionId: 'ACTION_ID',
labelI18n: 'LABEL_I18N',
context: 'userDropdownAction',
when: {
hasOnePermission: ['any'],
},
},
] as any;
}
throw new Error('Method not mocked');
}}
>
<MockedAuthorizationContext permissions={['manage-apps']}>
<MockedSettingsContext settings={{}}>
<MockedUserContext userPreferences={{}}>
<MockedUiKitActionManager>{children}</MockedUiKitActionManager>
</MockedUserContext>
</MockedSettingsContext>
</MockedAuthorizationContext>
</MockedServerContext>
</QueryClientProvider>
),
},
);
await waitForValueToChange(() => {
return queryClient.isFetching();
});
expect(result.current[3]).toEqual(undefined);
});
});
export const MockedUiKitActionManager = ({ children }: { children: React.ReactNode }) => {
return <ActionManagerContext.Provider value={{} as any}>{children}</ActionManagerContext.Provider>;
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// ✅ turns retries off
retry: false,
},
},
});
afterEach(() => {
queryClient.clear();
});
......@@ -3,7 +3,11 @@ export default {
testEnvironment: 'jsdom',
modulePathIgnorePatterns: ['<rootDir>/dist/'],
testMatch: ['<rootDir>/client/hooks/**.spec.[jt]s?(x)', '<rootDir>/client/components/**.spec.[jt]s?(x)'],
testMatch: [
'<rootDir>/client/hooks/**.spec.[jt]s?(x)',
'<rootDir>/client/components/**.spec.[jt]s?(x)',
'<rootDir>/client/sidebar/header/actions/hooks/**/**.spec.[jt]s?(x)',
],
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
},
......
import React from 'react';
import { AuthorizationContext } from '@rocket.chat/ui-contexts';
export const MockedAuthorizationContext = ({ permissions = [], children }: { permissions: string[]; children: React.ReactNode }) => {
export const MockedAuthorizationContext = ({
permissions = [],
roles = [],
children,
}: {
permissions: string[];
roles?: string[];
children: React.ReactNode;
}) => {
return (
<AuthorizationContext.Provider
value={{
queryPermission: (id: string) => [() => (): void => undefined, (): boolean => permissions.includes(id)],
queryAtLeastOnePermission: () => [() => (): void => undefined, (): boolean => false],
queryAllPermissions: () => [() => (): void => undefined, (): boolean => false],
queryRole: () => [() => (): void => undefined, (): boolean => false],
queryAtLeastOnePermission: (ids: string[]) => [
() => (): void => undefined,
(): boolean => ids.some((id) => permissions.includes(id)),
],
queryAllPermissions: (ids: string[]) => [() => (): void => undefined, (): boolean => ids.every((id) => permissions.includes(id))],
queryRole: (id: string) => [() => (): void => undefined, (): boolean => roles.includes(id)],
roleStore: {
roles: {},
emit: (): void => undefined,
......
......@@ -33,6 +33,7 @@ export const MockedServerContext = ({
}) => {
return handleRequest(args);
},
getStream: () => () => undefined,
} as any
}
>
......
import type { IRole } from '@rocket.chat/core-typings';
import type { IRole, IRoom } from '@rocket.chat/core-typings';
import type { IEmitter } from '@rocket.chat/emitter';
import { createContext } from 'react';
import type { ObjectId } from 'mongodb';
......@@ -27,7 +27,11 @@ export type AuthorizationContextValue = {
scope?: string | ObjectId,
scopedRoles?: IRole['_id'][],
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean];
queryRole(role: string | ObjectId): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean];
queryRole(
role: string | ObjectId,
scope?: IRoom['_id'],
ignoreSubscriptions?: boolean,
): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => boolean];
roleStore: RoleStore;
};
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment