Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/little-steaks-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes incorrect permission checks on workspace registration status, aligning the API and UI hooks with manage-cloud access.
4 changes: 2 additions & 2 deletions apps/meteor/app/api/server/v1/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { isCloudConfirmationPollProps, isCloudCreateRegistrationIntentProps, isC

import { CloudWorkspaceRegistrationError } from '../../../../lib/errors/CloudWorkspaceRegistrationError';
import { SystemLogger } from '../../../../server/lib/logger/system';
import { hasRoleAsync } from '../../../authorization/server/functions/hasRole';
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { getCheckoutUrl } from '../../../cloud/server/functions/getCheckoutUrl';
import { getConfirmationPoll } from '../../../cloud/server/functions/getConfirmationPoll';
import {
Expand Down Expand Up @@ -88,7 +88,7 @@ API.v1.addRoute(
{ authRequired: true },
{
async get() {
if (!(await hasRoleAsync(this.userId, 'admin'))) {
if (!(await hasPermissionAsync(this.userId, 'manage-cloud'))) {
return API.v1.forbidden();
}

Expand Down
68 changes: 68 additions & 0 deletions apps/meteor/client/hooks/useRegistrationStatus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook, waitFor } from '@testing-library/react';

import { useRegistrationStatus } from './useRegistrationStatus';

describe('useRegistrationStatus', () => {
it('should not call API and return error state when user does not have manage-cloud permission', async () => {
const mockGetRegistrationStatus = jest.fn();

const { result } = renderHook(() => useRegistrationStatus(), {
wrapper: mockAppRoot().withEndpoint('GET', '/v1/cloud.registrationStatus', mockGetRegistrationStatus).withJohnDoe().build(),
});

await waitFor(() => {
expect(result.current.isError).toBe(true);
});

expect(result.current.canViewRegistrationStatus).toBe(false);
expect(result.current.isRegistered).toBeFalsy();
expect(mockGetRegistrationStatus).not.toHaveBeenCalled();
});

it('should call API and return isRegistered as true and canViewRegistrationStatus as true when workspace is registered and user has manage-cloud permission', async () => {
const mockGetRegistrationStatus = jest.fn().mockResolvedValue({
registrationStatus: {
workspaceRegistered: true,
},
});

const { result } = renderHook(() => useRegistrationStatus(), {
wrapper: mockAppRoot()
.withEndpoint('GET', '/v1/cloud.registrationStatus', mockGetRegistrationStatus)
.withPermission('manage-cloud')
.withJohnDoe()
.build(),
});

await waitFor(() => {
expect(mockGetRegistrationStatus).toHaveBeenCalled();
});

expect(result.current.isRegistered).toBe(true);
expect(result.current.canViewRegistrationStatus).toBe(true);
});

it('should call API, return isRegistered as false and canViewRegistrationStatus as true when workspace is not registered and user has manage-cloud permission', async () => {
const mockGetRegistrationStatus = jest.fn().mockResolvedValue({
registrationStatus: {
workspaceRegistered: false,
},
});

const { result } = renderHook(() => useRegistrationStatus(), {
wrapper: mockAppRoot()
.withEndpoint('GET', '/v1/cloud.registrationStatus', mockGetRegistrationStatus)
.withPermission('manage-cloud')
.withJohnDoe()
.build(),
});

await waitFor(() => {
expect(mockGetRegistrationStatus).toHaveBeenCalled();
});

expect(result.current.isRegistered).toBe(false);
expect(result.current.canViewRegistrationStatus).toBe(true);
});
});
11 changes: 8 additions & 3 deletions apps/meteor/client/hooks/useRegistrationStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,28 @@ import type { UseQueryResult } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';

type useRegistrationStatusReturnType = {
canViewRegistrationStatus: boolean;
isRegistered?: boolean;
} & UseQueryResult<OperationResult<'GET', '/v1/cloud.registrationStatus'>>;

export const useRegistrationStatus = (): useRegistrationStatusReturnType => {
const getRegistrationStatus = useEndpoint('GET', '/v1/cloud.registrationStatus');
const canViewregistrationStatus = usePermission('manage-cloud');
const canViewRegistrationStatus = usePermission('manage-cloud');

const queryResult = useQuery({
queryKey: ['getRegistrationStatus'],
queryFn: () => {
if (!canViewregistrationStatus) {
if (!canViewRegistrationStatus) {
throw new Error('unauthorized api call');
}
return getRegistrationStatus();
},
staleTime: Infinity,
});

return { isRegistered: !queryResult.isPending && queryResult.data?.registrationStatus?.workspaceRegistered, ...queryResult };
return {
canViewRegistrationStatus,
isRegistered: !queryResult.isPending && queryResult.data?.registrationStatus?.workspaceRegistered,
...queryResult,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const SubscriptionPage = () => {
const showLicense = useShowLicense();
const router = useRouter();
const { data: enterpriseData } = useIsEnterprise();
const { isRegistered } = useRegistrationStatus();
const { canViewRegistrationStatus } = useRegistrationStatus();
const { data: licensesData, isLoading: isLicenseLoading } = useLicenseWithCloudAnnouncement({ loadValues: true });
const syncLicenseUpdate = useWorkspaceSync();
const invalidateLicenseQuery = useInvalidateLicense();
Expand Down Expand Up @@ -108,7 +108,7 @@ const SubscriptionPage = () => {
<Page bg='tint'>
<PageHeaderNoShadow title={t('Subscription')}>
<ButtonGroup>
{isRegistered && (
{canViewRegistrationStatus && (
<Button loading={syncLicenseUpdate.isPending} icon='reload' onClick={() => handleSyncLicenseUpdate()}>
{t('Sync_license_update')}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const VersionCard = ({ serverInfo }: VersionCardProps): ReactElement => {
const formatDate = useFormatDate();

const { data: licenseData, isPending, refetch: refetchLicense } = useLicense({ loadValues: true });
const { isRegistered } = useRegistrationStatus();
const { isRegistered, canViewRegistrationStatus } = useRegistrationStatus();

const { license, limits } = licenseData || {};
const isAirgapped = license?.information?.offline;
Expand Down Expand Up @@ -82,7 +82,7 @@ const VersionCard = ({ serverInfo }: VersionCardProps): ReactElement => {
action: () => void;
label: ReactNode;
} = useMemo(() => {
if (!isRegistered) {
if (canViewRegistrationStatus && !isRegistered) {
return {
action: () => {
const handleModalClose = (): void => {
Expand All @@ -107,7 +107,7 @@ const VersionCard = ({ serverInfo }: VersionCardProps): ReactElement => {
if (isOverLimits) {
return { path: '/admin/subscription', label: t('Manage_subscription') };
}
}, [isRegistered, versionStatus, isOverLimits, t, setModal, refetchLicense]);
}, [canViewRegistrationStatus, isRegistered, versionStatus, isOverLimits, t, setModal, refetchLicense]);

const actionItems = useMemo(() => {
return (
Expand Down Expand Up @@ -154,19 +154,30 @@ const VersionCard = ({ serverInfo }: VersionCardProps): ReactElement => {
</Trans>
),
},
isRegistered
? {
icon: 'check',
label: t('Workspace_registered'),
}
: {
danger: true,
icon: 'warning',
label: t('Workspace_not_registered'),
},
canViewRegistrationStatus &&
(isRegistered
? {
icon: 'check',
label: t('Workspace_registered'),
}
: {
danger: true,
icon: 'warning',
label: t('Workspace_not_registered'),
}),
].filter(Boolean) as VersionActionItem[]
).sort((a) => (a.danger ? -1 : 1));
}, [isOverLimits, t, isAirgapped, versions, versionStatus?.label, versionStatus?.expiration, formatDate, isRegistered]);
}, [
isOverLimits,
t,
isAirgapped,
versions,
versionStatus?.label,
versionStatus?.expiration,
formatDate,
canViewRegistrationStatus,
isRegistered,
]);

if (isPending && !licenseData) {
return (
Expand Down
46 changes: 46 additions & 0 deletions apps/meteor/tests/end-to-end/api/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,50 @@ describe('[Cloud]', function () {
});
});
});

describe('[/cloud.registrationStatus]', () => {
before(async () => {
return updatePermission('manage-cloud', ['admin']);
});

after(async () => {
return updatePermission('manage-cloud', ['admin']);
});

it('should fail if user is not authenticated', async () => {
return request
.get(api('cloud.registrationStatus'))
.expect('Content-Type', 'application/json')
.expect(401)
.expect((res: Response) => {
expect(res.body).to.have.property('status', 'error');
expect(res.body).to.have.property('message', 'You must be logged in to do this.');
});
});

it('should fail when user does not have the manage-cloud permission', async () => {
await updatePermission('manage-cloud', []);
return request
.get(api('cloud.registrationStatus'))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(403)
.expect((res: Response) => {
expect(res.body).to.have.property('success', false);
});
});

it('should return registration status when user has the manage-cloud permission', async () => {
await updatePermission('manage-cloud', ['admin']);
return request
.get(api('cloud.registrationStatus'))
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res: Response) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('registrationStatus');
});
});
});
});
Loading