feat(scaffolder): update scaffolder frontend to support additional backend permissions
Signed-off-by: Frank Kong <frkong@redhat.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
const mockAuthorize = jest.fn();
|
||||
|
||||
@@ -546,7 +547,7 @@ describe('<AboutCard />', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders techdocs lin when 3rdparty', async () => {
|
||||
it('renders techdocs link when 3rdparty', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Component',
|
||||
@@ -774,7 +775,9 @@ describe('<AboutCard />', () => {
|
||||
namespace: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
mockAuthorize.mockImplementation(async () => ({
|
||||
result: AuthorizeResult.ALLOW,
|
||||
}));
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
@@ -794,7 +797,7 @@ describe('<AboutCard />', () => {
|
||||
),
|
||||
],
|
||||
[catalogApiRef, catalogApi],
|
||||
[permissionApiRef, {}],
|
||||
[permissionApiRef, mockPermissionApi],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={entity}>
|
||||
@@ -816,6 +819,58 @@ describe('<AboutCard />', () => {
|
||||
'/create/templates/default/create-react-app-template',
|
||||
);
|
||||
});
|
||||
it('renders disabled launch template button if user has insufficient permissions', async () => {
|
||||
const entity = {
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 'create-react-app-template',
|
||||
namespace: 'default',
|
||||
},
|
||||
};
|
||||
mockAuthorize.mockImplementation(async () => ({
|
||||
result: AuthorizeResult.DENY,
|
||||
}));
|
||||
const rendered = await renderInTestApp(
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
scmIntegrationsApiRef,
|
||||
ScmIntegrationsApi.fromConfig(
|
||||
new ConfigReader({
|
||||
integrations: {
|
||||
github: [
|
||||
{
|
||||
host: 'github.com',
|
||||
token: '...',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
[catalogApiRef, catalogApi],
|
||||
[permissionApiRef, mockPermissionApi],
|
||||
]}
|
||||
>
|
||||
<EntityProvider entity={entity}>
|
||||
<AboutCard />
|
||||
</EntityProvider>
|
||||
</TestApiProvider>
|
||||
</SWRConfig>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
'/create/templates/:namespace/:templateName':
|
||||
createFromTemplateRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByText('Launch Template')).toBeVisible();
|
||||
expect(screen.getByText('Launch Template').closest('a')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CompoundEntityRef,
|
||||
DEFAULT_NAMESPACE,
|
||||
stringifyEntityRef,
|
||||
parseEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
@@ -58,10 +59,11 @@ import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import DocsIcon from '@material-ui/icons/Description';
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { parseEntityRef } from '@backstage/catalog-model';
|
||||
import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha';
|
||||
import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha';
|
||||
import { useSourceTemplateCompoundEntityRef } from './hooks';
|
||||
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
|
||||
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
|
||||
|
||||
@@ -114,6 +116,11 @@ export function AboutCard(props: AboutCardProps) {
|
||||
const { allowed: canRefresh } = useEntityPermission(
|
||||
catalogEntityRefreshPermission,
|
||||
);
|
||||
const { kind, name, namespace } = entity.metadata;
|
||||
const { allowed: canCreateTemplateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
resourceRef: `${kind}:${namespace}/${name}`,
|
||||
});
|
||||
|
||||
const entitySourceLocation = getEntitySourceLocation(
|
||||
entity,
|
||||
@@ -172,7 +179,7 @@ export function AboutCard(props: AboutCardProps) {
|
||||
const launchTemplate: IconLinkVerticalProps = {
|
||||
label: 'Launch Template',
|
||||
icon: <Icon />,
|
||||
disabled: !templateRoute,
|
||||
disabled: !templateRoute || !canCreateTemplateTask,
|
||||
href:
|
||||
templateRoute &&
|
||||
templateRoute({
|
||||
|
||||
@@ -65,6 +65,7 @@ import { validate } from 'jsonschema';
|
||||
import { Logger } from 'winston';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
TemplateAction,
|
||||
TaskBroker,
|
||||
TemplateFilter,
|
||||
TemplateGlobal,
|
||||
@@ -78,7 +79,6 @@ import {
|
||||
import { createDryRunner } from '../scaffolder/dryrun';
|
||||
import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker';
|
||||
import { findTemplate, getEntityBaseUrl, getWorkingDirectory } from './helpers';
|
||||
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionRuleParams,
|
||||
@@ -491,7 +491,7 @@ export async function createRouter(
|
||||
|
||||
if (permissions) {
|
||||
const authorizationResponse = (
|
||||
await permissions?.authorizeConditional(
|
||||
await permissions.authorizeConditional(
|
||||
[{ permission: actionReadPermission }],
|
||||
{ credentials: credentials },
|
||||
)
|
||||
@@ -520,7 +520,7 @@ export async function createRouter(
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
if (permissions) {
|
||||
const authorizationResponse = (
|
||||
await permissions?.authorizeConditional(
|
||||
await permissions.authorizeConditional(
|
||||
[{ permission: taskCreatePermission }],
|
||||
{ credentials: credentials },
|
||||
)
|
||||
@@ -611,7 +611,7 @@ export async function createRouter(
|
||||
|
||||
if (permissions) {
|
||||
const authorizationResponse = (
|
||||
await permissions?.authorizeConditional(
|
||||
await permissions.authorizeConditional(
|
||||
[{ permission: taskReadPermission }],
|
||||
{ credentials: credentials },
|
||||
)
|
||||
@@ -648,7 +648,7 @@ export async function createRouter(
|
||||
|
||||
if (permissions) {
|
||||
const authorizationResponse = (
|
||||
await permissions?.authorizeConditional(
|
||||
await permissions.authorizeConditional(
|
||||
[{ permission: taskReadPermission }],
|
||||
{ credentials: credentials },
|
||||
)
|
||||
@@ -672,7 +672,7 @@ export async function createRouter(
|
||||
const credentials = await httpAuth.credentials(req);
|
||||
|
||||
if (permissions) {
|
||||
const authorizationResponses = await permissions?.authorizeConditional(
|
||||
const authorizationResponses = await permissions.authorizeConditional(
|
||||
[
|
||||
{ permission: taskCancelPermission },
|
||||
{ permission: taskReadPermission },
|
||||
@@ -696,7 +696,7 @@ export async function createRouter(
|
||||
|
||||
if (permissions) {
|
||||
const authorizationResponse = (
|
||||
await permissions?.authorizeConditional(
|
||||
await permissions.authorizeConditional(
|
||||
[{ permission: taskReadPermission }],
|
||||
{ credentials: credentials },
|
||||
)
|
||||
@@ -758,7 +758,7 @@ export async function createRouter(
|
||||
|
||||
if (permissions) {
|
||||
const authorizationResponse = (
|
||||
await permissions?.authorizeConditional(
|
||||
await permissions.authorizeConditional(
|
||||
[{ permission: taskReadPermission }],
|
||||
{ credentials: credentials },
|
||||
)
|
||||
@@ -802,7 +802,7 @@ export async function createRouter(
|
||||
|
||||
if (permissions) {
|
||||
const authorizationResponse = (
|
||||
await permissions?.authorizeConditional(
|
||||
await permissions.authorizeConditional(
|
||||
[{ permission: taskCreatePermission }],
|
||||
{ credentials: credentials },
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-catalog-react": "workspace:^",
|
||||
"@backstage/plugin-permission-react": "workspace:^",
|
||||
"@backstage/plugin-scaffolder-common": "workspace:^",
|
||||
"@backstage/theme": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
@@ -87,13 +88,15 @@
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/plugin-catalog": "workspace:^",
|
||||
"@backstage/plugin-catalog-common": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/humanize-duration": "^3.18.1",
|
||||
"@types/luxon": "^3.0.0"
|
||||
"@types/luxon": "^3.0.0",
|
||||
"swr": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
starredEntitiesApiRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
MockPermissionApi,
|
||||
MockStorageApi,
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
@@ -28,6 +29,12 @@ import React from 'react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import {
|
||||
PermissionApi,
|
||||
permissionApiRef,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
describe('TemplateCard', () => {
|
||||
it('should render the card title', async () => {
|
||||
@@ -50,6 +57,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -79,6 +87,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -110,6 +119,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -139,6 +149,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -174,6 +185,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -213,6 +225,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -257,6 +270,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} additionalLinks={[]} />
|
||||
@@ -305,6 +319,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} additionalLinks={[]} />
|
||||
@@ -347,6 +362,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} />
|
||||
@@ -386,6 +402,7 @@ describe('TemplateCard', () => {
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} onSelected={mockOnSelected} />
|
||||
@@ -403,4 +420,44 @@ describe('TemplateCard', () => {
|
||||
|
||||
expect(mockOnSelected).toHaveBeenCalledWith(mockTemplate);
|
||||
});
|
||||
it('should not render the choose button when user has insufficient permissions', async () => {
|
||||
const mockTemplate: TemplateEntityV1beta3 = {
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: { name: 'bob', tags: ['cpp', 'react'] },
|
||||
spec: {
|
||||
steps: [],
|
||||
type: 'service',
|
||||
},
|
||||
};
|
||||
const mockOnSelected = jest.fn();
|
||||
const mockAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ result: AuthorizeResult.DENY }));
|
||||
// SWR used by the usePermission hook needs cache to be reset for each test
|
||||
const { queryByText } = await renderInTestApp(
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
starredEntitiesApiRef,
|
||||
new DefaultStarredEntitiesApi({
|
||||
storageApi: MockStorageApi.create(),
|
||||
}),
|
||||
],
|
||||
[permissionApiRef, new MockPermissionApi(mockAuthorize)],
|
||||
]}
|
||||
>
|
||||
<TemplateCard template={mockTemplate} onSelected={mockOnSelected} />
|
||||
</TestApiProvider>
|
||||
</SWRConfig>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:kind/:namespace/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(queryByText('Choose')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ import LanguageIcon from '@material-ui/icons/Language';
|
||||
import React from 'react';
|
||||
import { CardHeader } from './CardHeader';
|
||||
import { CardLink } from './CardLink';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
box: {
|
||||
@@ -103,6 +105,11 @@ export const TemplateCard = (props: TemplateCardProps) => {
|
||||
!!props.additionalLinks?.length || !!template.metadata.links?.length;
|
||||
const displayDefaultDivider = !hasTags && !hasLinks;
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
resourceRef: 'task',
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader template={template} />
|
||||
@@ -186,14 +193,16 @@ export const TemplateCard = (props: TemplateCardProps) => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => props.onSelected?.(template)}
|
||||
>
|
||||
Choose
|
||||
</Button>
|
||||
{canCreateTask ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => props.onSelected?.(template)}
|
||||
>
|
||||
Choose
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardActions>
|
||||
</Card>
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
MockStarredEntitiesApi,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import React from 'react';
|
||||
import { scaffolderApiRef, ScaffolderClient } from '../src';
|
||||
import { ScaffolderClient } from '../src';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import { ScaffolderPage } from '../src/plugin';
|
||||
import {
|
||||
discoveryApiRef,
|
||||
|
||||
@@ -43,7 +43,8 @@ import { useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
CodeSnippet,
|
||||
Content,
|
||||
ErrorPage,
|
||||
EmptyState,
|
||||
ErrorPanel,
|
||||
Header,
|
||||
MarkdownContent,
|
||||
Page,
|
||||
@@ -112,19 +113,9 @@ const ExamplesTable = (props: { examples: ActionExample[] }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ActionsPage = () => {
|
||||
const ActionPageContent = () => {
|
||||
const api = useApi(scaffolderApiRef);
|
||||
const navigate = useNavigate();
|
||||
const editorLink = useRouteRef(editRouteRef);
|
||||
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
|
||||
const createLink = useRouteRef(rootRouteRef);
|
||||
|
||||
const scaffolderPageContextMenuProps = {
|
||||
onEditorClicked: () => navigate(editorLink()),
|
||||
onActionsClicked: undefined,
|
||||
onTasksClicked: () => navigate(tasksLink()),
|
||||
onCreateClicked: () => navigate(createLink()),
|
||||
};
|
||||
const classes = useStyles();
|
||||
const { loading, value, error } = useAsync(async () => {
|
||||
return api.listActions();
|
||||
@@ -137,11 +128,14 @@ export const ActionsPage = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
statusMessage="Failed to load installed actions"
|
||||
status="500"
|
||||
stack={error.stack}
|
||||
/>
|
||||
<>
|
||||
<ErrorPanel error={error} />
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There are no actions installed or there was an issue communicating with backend."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,7 +276,7 @@ export const ActionsPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const items = value?.map(action => {
|
||||
return value?.map(action => {
|
||||
if (action.id.startsWith('legacy:')) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -336,6 +330,19 @@ export const ActionsPage = () => {
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
};
|
||||
export const ActionsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const editorLink = useRouteRef(editRouteRef);
|
||||
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
|
||||
const createLink = useRouteRef(rootRouteRef);
|
||||
|
||||
const scaffolderPageContextMenuProps = {
|
||||
onEditorClicked: () => navigate(editorLink()),
|
||||
onActionsClicked: undefined,
|
||||
onTasksClicked: () => navigate(tasksLink()),
|
||||
onCreateClicked: () => navigate(createLink()),
|
||||
};
|
||||
|
||||
return (
|
||||
<Page themeId="home">
|
||||
@@ -346,7 +353,9 @@ export const ActionsPage = () => {
|
||||
>
|
||||
<ScaffolderPageContextMenu {...scaffolderPageContextMenuProps} />
|
||||
</Header>
|
||||
<Content>{items}</Content>
|
||||
<Content>
|
||||
<ActionPageContent />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -77,7 +77,7 @@ const ListTaskPageContent = (props: MyTaskPageProps) => {
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="There is no Tasks or there was an issue communicating with backend."
|
||||
description="There are no tasks or there was an issue communicating with backend."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,12 @@ import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import React, { useState } from 'react';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
taskCancelPermission,
|
||||
taskReadPermission,
|
||||
taskCreatePermission,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
|
||||
type ContextMenuProps = {
|
||||
cancelEnabled?: boolean;
|
||||
@@ -69,6 +75,28 @@ export const ContextMenu = (props: ContextMenuProps) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Used dummy string value for `resourceRef` since `allowed` field will always return `false` if `resourceRef` is `undefined`
|
||||
const { allowed: canCancelTask } = usePermission({
|
||||
permission: taskCancelPermission,
|
||||
resourceRef: 'task',
|
||||
});
|
||||
|
||||
const { allowed: canReadTask } = usePermission({
|
||||
permission: taskReadPermission,
|
||||
resourceRef: 'task',
|
||||
});
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
resourceRef: 'task',
|
||||
});
|
||||
|
||||
// Cancel endpoint requires user to have both read and cancel permissions
|
||||
const cancelNotAllowed = !(canReadTask && canCancelTask);
|
||||
|
||||
// Start Over endpoint requires user to have both read (to grab parameters) and create (to create new task) permissions
|
||||
const canStartOver = canReadTask && canCreateTask;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
@@ -105,7 +133,11 @@ export const ContextMenu = (props: ContextMenuProps) => {
|
||||
primary={buttonBarVisible ? 'Hide Button Bar' : 'Show Button Bar'}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onStartOver}>
|
||||
<MenuItem
|
||||
onClick={onStartOver}
|
||||
disabled={cancelEnabled || !canStartOver}
|
||||
data-testid="start-over-task"
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Retry fontSize="small" />
|
||||
</ListItemIcon>
|
||||
@@ -113,7 +145,11 @@ export const ContextMenu = (props: ContextMenuProps) => {
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={cancel}
|
||||
disabled={!cancelEnabled || cancelStatus !== 'not-executed'}
|
||||
disabled={
|
||||
!cancelEnabled ||
|
||||
cancelStatus !== 'not-executed' ||
|
||||
cancelNotAllowed
|
||||
}
|
||||
data-testid="cancel-task"
|
||||
>
|
||||
<ListItemIcon>
|
||||
|
||||
@@ -16,10 +16,20 @@
|
||||
|
||||
import { OngoingTask } from './OngoingTask';
|
||||
import React from 'react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import {
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
MockPermissionApi,
|
||||
} from '@backstage/test-utils';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import { act, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import {
|
||||
PermissionApi,
|
||||
permissionApiRef,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { SWRConfig } from 'swr';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@@ -49,18 +59,29 @@ describe('OngoingTask', () => {
|
||||
getTask: jest.fn().mockImplementation(async () => {}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should trigger cancel api on "Cancel" click in context menu', async () => {
|
||||
const cancelOptionLabel = 'Cancel';
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>,
|
||||
const render = (permissionApi?: PermissionApi) => {
|
||||
// SWR used by the usePermission hook needs cache to be reset for each test
|
||||
return renderInTestApp(
|
||||
<SWRConfig value={{ provider: () => new Map() }}>
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[scaffolderApiRef, mockScaffolderApi],
|
||||
[permissionApiRef, permissionApi || new MockPermissionApi()],
|
||||
]}
|
||||
>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>
|
||||
</SWRConfig>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
};
|
||||
it('should trigger cancel api on "Cancel" click in context menu', async () => {
|
||||
const rendered = await render();
|
||||
const cancelOptionLabel = 'Cancel';
|
||||
const { getByTestId } = rendered;
|
||||
|
||||
await act(async () => {
|
||||
@@ -84,13 +105,9 @@ describe('OngoingTask', () => {
|
||||
});
|
||||
|
||||
it('should trigger cancel api on "Cancel" button click', async () => {
|
||||
const rendered = await render();
|
||||
const cancelOptionLabel = 'Cancel';
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
|
||||
const { getByTestId } = rendered;
|
||||
|
||||
await act(async () => {
|
||||
@@ -114,22 +131,12 @@ describe('OngoingTask', () => {
|
||||
});
|
||||
|
||||
it('should initially do not display logs', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
const rendered = await render();
|
||||
await expect(rendered.findByText('Show Logs')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle logs visibility', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<TestApiProvider apis={[[scaffolderApiRef, mockScaffolderApi]]}>
|
||||
<OngoingTask />
|
||||
</TestApiProvider>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
const rendered = await render();
|
||||
await act(async () => {
|
||||
const element = await rendered.findByText('Show Logs');
|
||||
fireEvent.click(element);
|
||||
@@ -137,4 +144,22 @@ describe('OngoingTask', () => {
|
||||
|
||||
await expect(rendered.findByText('Hide Logs')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have cancel and start over buttons be disabled without the proper permissions', async () => {
|
||||
const mockAuthorize = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ result: AuthorizeResult.DENY }));
|
||||
const permissionApi: PermissionApi = { authorize: mockAuthorize };
|
||||
const rendered = await render(permissionApi);
|
||||
|
||||
const { getByTestId } = rendered;
|
||||
expect(getByTestId('cancel-button')).toHaveClass('Mui-disabled');
|
||||
expect(getByTestId('start-over-button')).toHaveClass('Mui-disabled');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByTestId('menu-button'));
|
||||
});
|
||||
expect(getByTestId('cancel-task')).toHaveClass('Mui-disabled');
|
||||
expect(getByTestId('start-over-task')).toHaveClass('Mui-disabled');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,12 @@ import {
|
||||
TaskSteps,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { useAsync } from '@react-hookz/web';
|
||||
import { usePermission } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
taskCancelPermission,
|
||||
taskReadPermission,
|
||||
taskCreatePermission,
|
||||
} from '@backstage/plugin-scaffolder-common/alpha';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -81,6 +87,28 @@ export const OngoingTask = (props: {
|
||||
const [logsVisible, setLogVisibleState] = useState(false);
|
||||
const [buttonBarVisible, setButtonBarVisibleState] = useState(true);
|
||||
|
||||
// Used dummy string value for `resourceRef` since `allowed` field will always return `false` if `resourceRef` is `undefined`
|
||||
const { allowed: canCancelTask } = usePermission({
|
||||
permission: taskCancelPermission,
|
||||
resourceRef: 'task',
|
||||
});
|
||||
|
||||
const { allowed: canReadTask } = usePermission({
|
||||
permission: taskReadPermission,
|
||||
resourceRef: 'task',
|
||||
});
|
||||
|
||||
const { allowed: canCreateTask } = usePermission({
|
||||
permission: taskCreatePermission,
|
||||
resourceRef: 'task',
|
||||
});
|
||||
|
||||
// Cancel endpoint requires user to have both read and cancel permissions
|
||||
const cancelNotAllowed = !(canReadTask && canCancelTask);
|
||||
|
||||
// Start Over endpoint requires user to have both read (to grab parameters) and create (to create new task) permissions
|
||||
const canStartOver = canReadTask && canCreateTask;
|
||||
|
||||
useEffect(() => {
|
||||
if (taskStream.error) {
|
||||
setLogVisibleState(true);
|
||||
@@ -192,7 +220,11 @@ export const OngoingTask = (props: {
|
||||
<div className={classes.buttonBar}>
|
||||
<Button
|
||||
className={classes.cancelButton}
|
||||
disabled={!cancelEnabled || cancelStatus !== 'not-executed'}
|
||||
disabled={
|
||||
!cancelEnabled ||
|
||||
cancelStatus !== 'not-executed' ||
|
||||
cancelNotAllowed
|
||||
}
|
||||
onClick={triggerCancel}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
@@ -209,8 +241,9 @@ export const OngoingTask = (props: {
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={cancelEnabled}
|
||||
disabled={cancelEnabled || !canStartOver}
|
||||
onClick={startOver}
|
||||
data-testid="start-over-button"
|
||||
>
|
||||
Start Over
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user