Merge pull request #8972 from backstage/catalog-permission-integration/unregister-entity
Disable unregister entity button in catalog if unauthorized
This commit is contained in:
@@ -20,6 +20,7 @@ import { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { LinkProps } from '@backstage/core-components';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { Overrides } from '@material-ui/core/styles/overrides';
|
||||
import { Permission } from '@backstage/plugin-permission-common';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
@@ -879,6 +880,13 @@ export function useEntityOwnership(): {
|
||||
isOwnedEntity: (entity: Entity | EntityName) => boolean;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function useEntityPermission(permission: Permission): {
|
||||
loading: boolean;
|
||||
allowed: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "EntityTypeReturn" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "useEntityTypeFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"@backstage/core-plugin-api": "^0.6.0-next.0",
|
||||
"@backstage/errors": "^0.2.0",
|
||||
"@backstage/integration": "^0.7.2-next.0",
|
||||
"@backstage/plugin-permission-common": "^0.4.0-next.0",
|
||||
"@backstage/plugin-permission-react": "^0.3.0-next.0",
|
||||
"@backstage/types": "^0.1.1",
|
||||
"@backstage/version-bridge": "^0.1.1",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
@@ -54,6 +56,7 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.12.0-next.0",
|
||||
"@backstage/core-app-api": "^0.5.0-next.0",
|
||||
"@backstage/plugin-catalog-common": "^0.1.1-next.0",
|
||||
"@backstage/test-utils": "^0.2.3-next.0",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
|
||||
@@ -43,3 +43,4 @@ export {
|
||||
loadIdentityOwnerRefs,
|
||||
} from './useEntityOwnership';
|
||||
export { useOwnedEntities } from './useOwnedEntities';
|
||||
export { useEntityPermission } from './useEntityPermission';
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { catalogEntityDeletePermission } from '@backstage/plugin-catalog-common';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useEntityPermission } from './useEntityPermission';
|
||||
import { useEntity } from './useEntity';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
|
||||
jest.mock('./useEntity', () => ({
|
||||
...jest.requireActual('./useEntity'),
|
||||
useEntity: jest.fn(),
|
||||
}));
|
||||
jest.mock('@backstage/plugin-permission-react', () => ({
|
||||
...jest.requireActual('@backstage/plugin-permission-react'),
|
||||
usePermission: jest.fn(),
|
||||
}));
|
||||
const useEntityMock = useEntity as jest.Mock;
|
||||
const usePermissionMock = usePermission as jest.Mock;
|
||||
|
||||
describe('useEntityPermission', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('returns loading when entity is loading', () => {
|
||||
useEntityMock.mockReturnValue({ loading: true, entity: undefined });
|
||||
usePermissionMock.mockReturnValue({ loading: false, allowed: false });
|
||||
const { result } = renderHook(() =>
|
||||
useEntityPermission(catalogEntityDeletePermission),
|
||||
);
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('returns loading when permission is loading', () => {
|
||||
useEntityMock.mockReturnValue({
|
||||
loading: false,
|
||||
entity: {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: { name: 'c' },
|
||||
},
|
||||
});
|
||||
usePermissionMock.mockReturnValue({ loading: true, allowed: false });
|
||||
const { result } = renderHook(() =>
|
||||
useEntityPermission(catalogEntityDeletePermission),
|
||||
);
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
});
|
||||
|
||||
it('does not authorize when there is an entity error', () => {
|
||||
useEntityMock.mockReturnValue({
|
||||
loading: false,
|
||||
entity: undefined,
|
||||
error: new Error(),
|
||||
});
|
||||
usePermissionMock.mockReturnValue({ loading: false, allowed: false });
|
||||
const { result } = renderHook(() =>
|
||||
useEntityPermission(catalogEntityDeletePermission),
|
||||
);
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns authorization result', () => {
|
||||
useEntityMock.mockReturnValue({
|
||||
loading: false,
|
||||
entity: {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: { name: 'c' },
|
||||
},
|
||||
});
|
||||
usePermissionMock.mockReturnValue({ loading: false, allowed: true });
|
||||
const { result } = renderHook(() =>
|
||||
useEntityPermission(catalogEntityDeletePermission),
|
||||
);
|
||||
|
||||
expect(result.current.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { Permission } from '@backstage/plugin-permission-common';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import { useEntity } from './useEntity';
|
||||
|
||||
/**
|
||||
* A thin wrapper around the
|
||||
* {@link @backstage/plugin-permission-react#usePermission} hook which uses the
|
||||
* current entity in context to make an authorization request for the given
|
||||
* permission.
|
||||
*
|
||||
* Note: this hook blocks the permission request until the entity has loaded in
|
||||
* context. If you have the entityRef and need concurrent requests, use the
|
||||
* `usePermission` hook directly.
|
||||
* @public
|
||||
*/
|
||||
export function useEntityPermission(permission: Permission): {
|
||||
loading: boolean;
|
||||
allowed: boolean;
|
||||
error?: Error;
|
||||
} {
|
||||
const { entity, loading: loadingEntity, error: entityError } = useEntity();
|
||||
const {
|
||||
allowed,
|
||||
loading: loadingPermission,
|
||||
error: permissionError,
|
||||
} = usePermission(
|
||||
permission,
|
||||
entity ? stringifyEntityRef(entity) : undefined,
|
||||
);
|
||||
|
||||
if (loadingEntity || loadingPermission) {
|
||||
return { loading: true, allowed: false };
|
||||
}
|
||||
if (entityError) {
|
||||
return { loading: false, allowed: false, error: entityError };
|
||||
}
|
||||
return { loading: false, allowed, error: permissionError };
|
||||
}
|
||||
@@ -37,6 +37,7 @@
|
||||
"@backstage/core-plugin-api": "^0.6.0-next.0",
|
||||
"@backstage/errors": "^0.2.0",
|
||||
"@backstage/integration-react": "^0.1.19-next.0",
|
||||
"@backstage/plugin-catalog-common": "^0.1.1-next.0",
|
||||
"@backstage/plugin-catalog-react": "^0.6.12-next.0",
|
||||
"@backstage/theme": "^0.2.14",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
@@ -56,6 +57,7 @@
|
||||
"@backstage/cli": "^0.12.0-next.0",
|
||||
"@backstage/core-app-api": "^0.5.0-next.0",
|
||||
"@backstage/dev-utils": "^0.2.18-next.0",
|
||||
"@backstage/plugin-permission-react": "^0.3.0-next.0",
|
||||
"@backstage/test-utils": "^0.2.3-next.0",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^11.2.5",
|
||||
|
||||
@@ -14,19 +14,36 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
MockPermissionApi,
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
} from '@backstage/test-utils';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { EntityContextMenu } from './EntityContextMenu';
|
||||
|
||||
const mockPermissionApi = new MockPermissionApi();
|
||||
|
||||
function render(children: React.ReactNode) {
|
||||
return renderInTestApp(
|
||||
<TestApiProvider apis={[[permissionApiRef, mockPermissionApi]]}>
|
||||
<EntityProvider
|
||||
entity={{ apiVersion: 'a', kind: 'b', metadata: { name: 'c' } }}
|
||||
children={children}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ComponentContextMenu', () => {
|
||||
it('should call onUnregisterEntity on button click', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
await renderInTestApp(
|
||||
<EntityContextMenu onUnregisterEntity={mockCallback} />,
|
||||
);
|
||||
await render(<EntityContextMenu onUnregisterEntity={mockCallback} />);
|
||||
|
||||
const button = await screen.findByTestId('menu-button');
|
||||
expect(button).toBeInTheDocument();
|
||||
@@ -46,7 +63,7 @@ describe('ComponentContextMenu', () => {
|
||||
onClick: jest.fn(),
|
||||
};
|
||||
|
||||
await renderInTestApp(
|
||||
await render(
|
||||
<EntityContextMenu
|
||||
onUnregisterEntity={jest.fn()}
|
||||
UNSTABLE_extraContextMenuItems={[extra]}
|
||||
|
||||
@@ -28,6 +28,8 @@ import Cancel from '@material-ui/icons/Cancel';
|
||||
import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import React, { useState } from 'react';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { useEntityPermission } from '@backstage/plugin-catalog-react';
|
||||
import { catalogEntityDeletePermission } from '@backstage/plugin-catalog-common';
|
||||
|
||||
// TODO(freben): It should probably instead be the case that Header sets the theme text color to white inside itself unconditionally instead
|
||||
const useStyles = makeStyles({
|
||||
@@ -62,6 +64,9 @@ export const EntityContextMenu = ({
|
||||
}: Props) => {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
|
||||
const classes = useStyles();
|
||||
const unregisterPermission = useEntityPermission(
|
||||
catalogEntityDeletePermission,
|
||||
);
|
||||
|
||||
const onOpen = (event: React.SyntheticEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
@@ -90,7 +95,9 @@ export const EntityContextMenu = ({
|
||||
];
|
||||
|
||||
const disableUnregister =
|
||||
UNSTABLE_contextMenuOptions?.disableUnregister ?? false;
|
||||
(!unregisterPermission.allowed ||
|
||||
UNSTABLE_contextMenuOptions?.disableUnregister) ??
|
||||
false;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
entityRouteRef,
|
||||
starredEntitiesApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
MockPermissionApi,
|
||||
MockStorageApi,
|
||||
renderInTestApp,
|
||||
TestApiRegistry,
|
||||
@@ -50,6 +52,7 @@ const mockApis = TestApiRegistry.from(
|
||||
starredEntitiesApiRef,
|
||||
new DefaultStarredEntitiesApi({ storageApi: MockStorageApi.create() }),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
);
|
||||
|
||||
describe('EntityLayout', () => {
|
||||
|
||||
Reference in New Issue
Block a user