mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
Show sub-feature privileges when using the Basic license (#142020)
* Augments /api/security/privileges with optional respectLicenseLevel parameter for use by the edit_role_page. Implements fix for 125289 - Show sub-feature privileges when using the Basic license * Changed EuiTooltip to EuiIconTip. * Updated unit tests for feature table expanded row to include new property checks. * Renamed property to improve readability and reduce confusion. Fixed state of switch checked in sub-feature customization. * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Fixed privilege get API default for 'respectLicenseLevel'. Updated privilege unit tests. * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Uodated test description to match property name. * Updated privilege API integration tests to include new 'respectLicenseLevel' optional parameter. * Replaced empty fragment with undefined. Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Thom Heymann <190132+thomheymann@users.noreply.github.com>
This commit is contained in:
parent
b6885f21c6
commit
fb632caa33
11 changed files with 638 additions and 100 deletions
|
@ -141,7 +141,7 @@ function usePrivileges(
|
|||
);
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
privilegesAPIClient.getAll({ includeActions: true }),
|
||||
privilegesAPIClient.getAll({ includeActions: true, respectLicenseLevel: false }),
|
||||
privilegesAPIClient.getBuiltIn(),
|
||||
]).then(
|
||||
([kibanaPrivileges, builtInESPrivileges]) =>
|
||||
|
|
|
@ -7,10 +7,11 @@
|
|||
|
||||
import './feature_table.scss';
|
||||
|
||||
import type { EuiAccordionProps, EuiButtonGroupOptionProps } from '@elastic/eui';
|
||||
import {
|
||||
EuiAccordion,
|
||||
EuiAccordionProps,
|
||||
EuiButtonGroup,
|
||||
EuiButtonGroupOptionProps,
|
||||
EuiCallOut,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -216,7 +217,6 @@ export class FeatureTable extends Component<Props, State> {
|
|||
extraAction: EuiAccordionProps['extraAction'],
|
||||
infoIcon: JSX.Element
|
||||
) => {
|
||||
const { canCustomizeSubFeaturePrivileges } = this.props;
|
||||
const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0;
|
||||
|
||||
return (
|
||||
|
@ -228,12 +228,8 @@ export class FeatureTable extends Component<Props, State> {
|
|||
data-test-subj="featurePrivilegeControls"
|
||||
buttonContent={buttonContent}
|
||||
extraAction={extraAction}
|
||||
forceState={
|
||||
canCustomizeSubFeaturePrivileges && hasSubFeaturePrivileges ? undefined : 'closed'
|
||||
}
|
||||
arrowDisplay={
|
||||
canCustomizeSubFeaturePrivileges && hasSubFeaturePrivileges ? 'left' : 'none'
|
||||
}
|
||||
forceState={hasSubFeaturePrivileges ? undefined : 'closed'}
|
||||
arrowDisplay={hasSubFeaturePrivileges ? 'left' : 'none'}
|
||||
onToggle={(isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
this.state.expandedPrivilegeControls.add(feature.id);
|
||||
|
@ -256,6 +252,9 @@ export class FeatureTable extends Component<Props, State> {
|
|||
this.props.role.kibana[this.props.privilegeIndex].feature[feature.id] ?? []
|
||||
}
|
||||
disabled={this.props.disabled}
|
||||
licenseAllowsSubFeatPrivCustomization={
|
||||
this.props.canCustomizeSubFeaturePrivileges
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</EuiAccordion>
|
||||
|
@ -335,14 +334,10 @@ export class FeatureTable extends Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
const { canCustomizeSubFeaturePrivileges } = this.props;
|
||||
const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0;
|
||||
|
||||
const showAccordionArrow = canCustomizeSubFeaturePrivileges && hasSubFeaturePrivileges;
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
{!showAccordionArrow && <EuiIcon type="empty" size="l" />}{' '}
|
||||
{!hasSubFeaturePrivileges && <EuiIcon type="empty" size="l" />}{' '}
|
||||
<FeatureTableCell feature={feature} />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -25,6 +25,75 @@ const createRole = (kibana: Role['kibana'] = []): Role => {
|
|||
};
|
||||
|
||||
describe('FeatureTableExpandedRow', () => {
|
||||
it('indicates sub-feature privileges are not customizable when licenseAllowsSubFeatPrivCustomization is false', () => {
|
||||
const role = createRole([
|
||||
{
|
||||
base: [],
|
||||
feature: {
|
||||
with_sub_features: ['minimal_read'],
|
||||
},
|
||||
spaces: ['foo'],
|
||||
},
|
||||
]);
|
||||
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
|
||||
|
||||
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
|
||||
|
||||
const feature = kibanaPrivileges.getSecuredFeature('with_sub_features');
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<FeatureTableExpandedRow
|
||||
feature={feature}
|
||||
privilegeIndex={0}
|
||||
privilegeCalculator={calculator}
|
||||
selectedFeaturePrivileges={['minimal_read']}
|
||||
onChange={jest.fn()}
|
||||
licenseAllowsSubFeatPrivCustomization={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('EuiSwitch[data-test-subj="customizeSubFeaturePrivileges"]').props()
|
||||
).toMatchObject({
|
||||
disabled: true,
|
||||
checked: false,
|
||||
});
|
||||
|
||||
expect(wrapper.find('EuiIconTip[data-test-subj="subFeaturesTip"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it('indicates sub-feature privileges can be customized when licenseAllowsSubFeatPrivCustomization is true', () => {
|
||||
const role = createRole([
|
||||
{
|
||||
base: [],
|
||||
feature: {
|
||||
with_sub_features: ['minimal_read'],
|
||||
},
|
||||
spaces: ['foo'],
|
||||
},
|
||||
]);
|
||||
const kibanaPrivileges = createKibanaPrivileges(kibanaFeatures);
|
||||
|
||||
const calculator = new PrivilegeFormCalculator(kibanaPrivileges, role);
|
||||
|
||||
const feature = kibanaPrivileges.getSecuredFeature('with_sub_features');
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<FeatureTableExpandedRow
|
||||
feature={feature}
|
||||
privilegeIndex={0}
|
||||
privilegeCalculator={calculator}
|
||||
selectedFeaturePrivileges={['none']}
|
||||
onChange={jest.fn()}
|
||||
licenseAllowsSubFeatPrivCustomization={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('EuiIconTip[data-test-subj="cannotCustomizeSubFeaturesTooltip"]').length
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('indicates sub-feature privileges are being customized if a minimal feature privilege is set', () => {
|
||||
const role = createRole([
|
||||
{
|
||||
|
@ -48,6 +117,7 @@ describe('FeatureTableExpandedRow', () => {
|
|||
privilegeCalculator={calculator}
|
||||
selectedFeaturePrivileges={['minimal_read']}
|
||||
onChange={jest.fn()}
|
||||
licenseAllowsSubFeatPrivCustomization={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -82,6 +152,7 @@ describe('FeatureTableExpandedRow', () => {
|
|||
privilegeCalculator={calculator}
|
||||
selectedFeaturePrivileges={['read']}
|
||||
onChange={jest.fn()}
|
||||
licenseAllowsSubFeatPrivCustomization={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -114,6 +185,7 @@ describe('FeatureTableExpandedRow', () => {
|
|||
privilegeCalculator={calculator}
|
||||
selectedFeaturePrivileges={['read']}
|
||||
onChange={jest.fn()}
|
||||
licenseAllowsSubFeatPrivCustomization={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -150,6 +222,7 @@ describe('FeatureTableExpandedRow', () => {
|
|||
privilegeCalculator={calculator}
|
||||
selectedFeaturePrivileges={['read']}
|
||||
onChange={onChange}
|
||||
licenseAllowsSubFeatPrivCustomization={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -189,6 +262,7 @@ describe('FeatureTableExpandedRow', () => {
|
|||
privilegeCalculator={calculator}
|
||||
selectedFeaturePrivileges={['minimal_read', 'cool_read', 'cool_toggle_2']}
|
||||
onChange={onChange}
|
||||
licenseAllowsSubFeatPrivCustomization={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -6,9 +6,10 @@
|
|||
*/
|
||||
|
||||
import type { EuiSwitchEvent } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSwitch } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiSwitch } from '@elastic/eui';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import type { SecuredFeature } from '../../../../model';
|
||||
|
@ -21,6 +22,7 @@ interface Props {
|
|||
privilegeIndex: number;
|
||||
selectedFeaturePrivileges: string[];
|
||||
disabled?: boolean;
|
||||
licenseAllowsSubFeatPrivCustomization: boolean;
|
||||
onChange: (featureId: string, featurePrivileges: string[]) => void;
|
||||
}
|
||||
|
||||
|
@ -31,11 +33,13 @@ export const FeatureTableExpandedRow = ({
|
|||
privilegeCalculator,
|
||||
selectedFeaturePrivileges,
|
||||
disabled,
|
||||
licenseAllowsSubFeatPrivCustomization,
|
||||
}: Props) => {
|
||||
const [isCustomizing, setIsCustomizing] = useState(() => {
|
||||
return feature
|
||||
.getMinimalFeaturePrivileges()
|
||||
.some((p) => selectedFeaturePrivileges.includes(p.id));
|
||||
return (
|
||||
licenseAllowsSubFeatPrivCustomization &&
|
||||
feature.getMinimalFeaturePrivileges().some((p) => selectedFeaturePrivileges.includes(p.id))
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -43,10 +47,13 @@ export const FeatureTableExpandedRow = ({
|
|||
.getMinimalFeaturePrivileges()
|
||||
.some((p) => selectedFeaturePrivileges.includes(p.id));
|
||||
|
||||
if (!hasMinimalFeaturePrivilegeSelected && isCustomizing) {
|
||||
if (
|
||||
(!licenseAllowsSubFeatPrivCustomization || !hasMinimalFeaturePrivilegeSelected) &&
|
||||
isCustomizing
|
||||
) {
|
||||
setIsCustomizing(false);
|
||||
}
|
||||
}, [feature, isCustomizing, selectedFeaturePrivileges]);
|
||||
}, [feature, isCustomizing, selectedFeaturePrivileges, licenseAllowsSubFeatPrivCustomization]);
|
||||
|
||||
const onCustomizeSubFeatureChange = (e: EuiSwitchEvent) => {
|
||||
onChange(
|
||||
|
@ -63,21 +70,44 @@ export const FeatureTableExpandedRow = ({
|
|||
return (
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<EuiSwitch
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRole.featureTable.customizeSubFeaturePrivilegesSwitchLabel"
|
||||
defaultMessage="Customize sub-feature privileges"
|
||||
<div>
|
||||
<EuiSwitch
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="xpack.security.management.editRole.featureTable.customizeSubFeaturePrivilegesSwitchLabel"
|
||||
defaultMessage="Customize sub-feature privileges"
|
||||
/>
|
||||
}
|
||||
checked={isCustomizing}
|
||||
onChange={onCustomizeSubFeatureChange}
|
||||
data-test-subj="customizeSubFeaturePrivileges"
|
||||
disabled={
|
||||
disabled ||
|
||||
!licenseAllowsSubFeatPrivCustomization ||
|
||||
!privilegeCalculator.canCustomizeSubFeaturePrivileges(feature.id, privilegeIndex)
|
||||
}
|
||||
/>
|
||||
{licenseAllowsSubFeatPrivCustomization ? undefined : (
|
||||
<EuiIconTip
|
||||
data-test-subj="subFeaturesTip"
|
||||
position="right"
|
||||
aria-label="sub-feature-information-tip"
|
||||
size="m"
|
||||
type="iInCircle"
|
||||
color="subdued"
|
||||
iconProps={{
|
||||
className: 'eui-alignTop',
|
||||
}}
|
||||
content={i18n.translate(
|
||||
'xpack.security.management.editRole.featureTable.cannotCustomizeSubFeaturesTooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'Customization of sub-feature privileges is a subscription feature.',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
}
|
||||
checked={isCustomizing}
|
||||
onChange={onCustomizeSubFeatureChange}
|
||||
data-test-subj="customizeSubFeaturePrivileges"
|
||||
disabled={
|
||||
disabled ||
|
||||
!privilegeCalculator.canCustomizeSubFeaturePrivileges(feature.id, privilegeIndex)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EuiFlexItem>
|
||||
{feature.getSubFeatures().map((subFeature) => {
|
||||
return (
|
||||
|
|
|
@ -547,6 +547,7 @@ export class PrivilegeSpaceForm extends Component<Props, State> {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
role,
|
||||
privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role),
|
||||
|
|
|
@ -12,9 +12,19 @@ import type { BuiltinESPrivileges, RawKibanaPrivileges } from '../../../common/m
|
|||
export class PrivilegesAPIClient {
|
||||
constructor(private readonly http: HttpStart) {}
|
||||
|
||||
async getAll({ includeActions }: { includeActions: boolean }) {
|
||||
/*
|
||||
* respectLicenseLevel is an internal optional parameter soley for getting all sub-feature
|
||||
* privilieges to use in the UI. It is not meant for any other use.
|
||||
*/
|
||||
async getAll({
|
||||
includeActions,
|
||||
respectLicenseLevel = true,
|
||||
}: {
|
||||
includeActions: boolean;
|
||||
respectLicenseLevel: boolean;
|
||||
}) {
|
||||
return await this.http.get<RawKibanaPrivileges>('/api/security/privileges', {
|
||||
query: { includeActions },
|
||||
query: { includeActions, respectLicenseLevel },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1888,6 +1888,225 @@ describe('subFeatures', () => {
|
|||
actions.ui.get('foo', 'sub-feature-ui'),
|
||||
]);
|
||||
});
|
||||
|
||||
test(`should get the sub-feature privileges if 'respectLicenseLevel' is false`, () => {
|
||||
const features: KibanaFeature[] = [
|
||||
new KibanaFeature({
|
||||
id: 'foo',
|
||||
name: 'Foo KibanaFeature',
|
||||
app: [],
|
||||
category: { id: 'foo', label: 'foo' },
|
||||
privileges: {
|
||||
all: {
|
||||
savedObject: {
|
||||
all: [],
|
||||
read: [],
|
||||
},
|
||||
ui: ['foo'],
|
||||
},
|
||||
read: {
|
||||
savedObject: {
|
||||
all: [],
|
||||
read: [],
|
||||
},
|
||||
ui: ['foo'],
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'subFeature1',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'subFeaturePriv1',
|
||||
name: 'sub feature priv 1',
|
||||
includeIn: 'read',
|
||||
savedObject: {
|
||||
all: ['all-sub-feature-type'],
|
||||
read: ['read-sub-feature-type'],
|
||||
},
|
||||
ui: ['sub-feature-ui'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const mockFeaturesPlugin = featuresPluginMock.createSetup();
|
||||
mockFeaturesPlugin.getKibanaFeatures.mockReturnValue(features);
|
||||
const privileges = privilegesFactory(actions, mockFeaturesPlugin, mockLicenseServiceBasic);
|
||||
|
||||
const actual = privileges.get(false);
|
||||
expect(actual.features).toHaveProperty(`foo.subFeaturePriv1`);
|
||||
|
||||
expect(actual.features).toHaveProperty(`foo.all`, [
|
||||
actions.login,
|
||||
actions.version,
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
|
||||
actions.ui.get('foo', 'foo'),
|
||||
actions.ui.get('foo', 'sub-feature-ui'),
|
||||
]);
|
||||
|
||||
expect(actual.features).toHaveProperty(`foo.minimal_all`, [
|
||||
actions.login,
|
||||
actions.version,
|
||||
actions.ui.get('foo', 'foo'),
|
||||
]);
|
||||
|
||||
expect(actual.features).toHaveProperty(`foo.read`, [
|
||||
actions.login,
|
||||
actions.version,
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
|
||||
actions.ui.get('foo', 'foo'),
|
||||
actions.ui.get('foo', 'sub-feature-ui'),
|
||||
]);
|
||||
|
||||
expect(actual.features).toHaveProperty(`foo.minimal_read`, [
|
||||
actions.login,
|
||||
actions.version,
|
||||
actions.ui.get('foo', 'foo'),
|
||||
]);
|
||||
|
||||
expect(actual).toHaveProperty('global.all', [
|
||||
actions.login,
|
||||
actions.version,
|
||||
actions.api.get('decryptedTelemetry'),
|
||||
actions.api.get('features'),
|
||||
actions.api.get('taskManager'),
|
||||
actions.space.manage,
|
||||
actions.ui.get('spaces', 'manage'),
|
||||
actions.ui.get('management', 'kibana', 'spaces'),
|
||||
actions.ui.get('catalogue', 'spaces'),
|
||||
actions.ui.get('enterpriseSearch', 'all'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
|
||||
actions.ui.get('foo', 'foo'),
|
||||
actions.ui.get('foo', 'sub-feature-ui'),
|
||||
]);
|
||||
expect(actual).toHaveProperty('global.read', [
|
||||
actions.login,
|
||||
actions.version,
|
||||
actions.api.get('decryptedTelemetry'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
|
||||
actions.ui.get('foo', 'foo'),
|
||||
actions.ui.get('foo', 'sub-feature-ui'),
|
||||
]);
|
||||
|
||||
expect(actual).toHaveProperty('space.all', [
|
||||
actions.login,
|
||||
actions.version,
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
|
||||
actions.ui.get('foo', 'foo'),
|
||||
actions.ui.get('foo', 'sub-feature-ui'),
|
||||
]);
|
||||
expect(actual).toHaveProperty('space.read', [
|
||||
actions.login,
|
||||
actions.version,
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'close_point_in_time'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_create'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
|
||||
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'get'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'find'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'open_point_in_time'),
|
||||
actions.savedObject.get('read-sub-feature-type', 'close_point_in_time'),
|
||||
actions.ui.get('foo', 'foo'),
|
||||
actions.ui.get('foo', 'sub-feature-ui'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when license allows subfeatures, but not a specific sub feature`, () => {
|
||||
|
|
|
@ -18,7 +18,7 @@ import type { Actions } from '../actions';
|
|||
import { featurePrivilegeBuilderFactory } from './feature_privilege_builder';
|
||||
|
||||
export interface PrivilegesService {
|
||||
get(): RawKibanaPrivileges;
|
||||
get(respectLicenseLevel?: boolean): RawKibanaPrivileges;
|
||||
}
|
||||
|
||||
export function privilegesFactory(
|
||||
|
@ -29,7 +29,7 @@ export function privilegesFactory(
|
|||
const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions);
|
||||
|
||||
return {
|
||||
get() {
|
||||
get(respectLicenseLevel: boolean = true) {
|
||||
const features = featuresService.getKibanaFeatures();
|
||||
const { allowSubFeaturePrivileges } = licenseService.getFeatures();
|
||||
const { hasAtLeast: licenseHasAtLeast } = licenseService;
|
||||
|
@ -82,7 +82,10 @@ export function privilegesFactory(
|
|||
];
|
||||
}
|
||||
|
||||
if (allowSubFeaturePrivileges && feature.subFeatures?.length > 0) {
|
||||
if (
|
||||
(!respectLicenseLevel || allowSubFeaturePrivileges) &&
|
||||
feature.subFeatures?.length > 0
|
||||
) {
|
||||
for (const subFeaturePrivilege of featuresService.subFeaturePrivilegeIterator(
|
||||
feature,
|
||||
licenseHasAtLeast
|
||||
|
|
|
@ -21,11 +21,15 @@ export function defineGetPrivilegesRoutes({ router, authz }: RouteDefinitionPara
|
|||
includeActions: schema.maybe(
|
||||
schema.oneOf([schema.literal('true'), schema.literal('false')])
|
||||
),
|
||||
respectLicenseLevel: schema.maybe(
|
||||
schema.oneOf([schema.literal('true'), schema.literal('false')])
|
||||
),
|
||||
}),
|
||||
},
|
||||
},
|
||||
createLicensedRouteHandler((context, request, response) => {
|
||||
const privileges = authz.privileges.get();
|
||||
const respectLicenseLevel = request.query.respectLicenseLevel !== 'false'; // if undefined resolve to true by default
|
||||
const privileges = authz.privileges.get(respectLicenseLevel);
|
||||
const includeActions = request.query.includeActions === 'true';
|
||||
const privilegesResponseBody = includeActions
|
||||
? privileges
|
||||
|
|
|
@ -14,70 +14,71 @@ import { FtrProviderContext } from '../../ftr_provider_context';
|
|||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
const expectedWithoutActions = {
|
||||
global: ['all', 'read'],
|
||||
space: ['all', 'read'],
|
||||
features: {
|
||||
graph: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
canvas: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
maps: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
generalCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
|
||||
observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
|
||||
fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
fleet: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
actions: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
stackAlerts: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
ml: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
siem: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
uptime: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
|
||||
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
apm: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
discover: [
|
||||
'all',
|
||||
'read',
|
||||
'minimal_all',
|
||||
'minimal_read',
|
||||
'url_create',
|
||||
'store_search_session',
|
||||
],
|
||||
visualize: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'],
|
||||
dashboard: [
|
||||
'all',
|
||||
'read',
|
||||
'minimal_all',
|
||||
'minimal_read',
|
||||
'url_create',
|
||||
'store_search_session',
|
||||
],
|
||||
dev_tools: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
osquery: [
|
||||
'all',
|
||||
'read',
|
||||
'minimal_all',
|
||||
'minimal_read',
|
||||
'live_queries_all',
|
||||
'live_queries_read',
|
||||
'run_saved_queries',
|
||||
'saved_queries_all',
|
||||
'saved_queries_read',
|
||||
'packs_all',
|
||||
'packs_read',
|
||||
],
|
||||
},
|
||||
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
|
||||
};
|
||||
|
||||
describe('Privileges', () => {
|
||||
describe('GET /api/security/privileges', () => {
|
||||
it('should return a privilege map with all known privileges, without actions', async () => {
|
||||
// If you're adding a privilege to the following, that's great!
|
||||
// If you're removing a privilege, this breaks backwards compatibility
|
||||
// Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
|
||||
const expected = {
|
||||
global: ['all', 'read'],
|
||||
space: ['all', 'read'],
|
||||
features: {
|
||||
graph: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
canvas: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
maps: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
generalCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
|
||||
observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
|
||||
fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
fleet: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
actions: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
stackAlerts: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
ml: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
siem: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
uptime: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
|
||||
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
apm: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
discover: [
|
||||
'all',
|
||||
'read',
|
||||
'minimal_all',
|
||||
'minimal_read',
|
||||
'url_create',
|
||||
'store_search_session',
|
||||
],
|
||||
visualize: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'],
|
||||
dashboard: [
|
||||
'all',
|
||||
'read',
|
||||
'minimal_all',
|
||||
'minimal_read',
|
||||
'url_create',
|
||||
'store_search_session',
|
||||
],
|
||||
dev_tools: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
osquery: [
|
||||
'all',
|
||||
'read',
|
||||
'minimal_all',
|
||||
'minimal_read',
|
||||
'live_queries_all',
|
||||
'live_queries_read',
|
||||
'run_saved_queries',
|
||||
'saved_queries_all',
|
||||
'saved_queries_read',
|
||||
'packs_all',
|
||||
'packs_read',
|
||||
],
|
||||
},
|
||||
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
|
||||
};
|
||||
|
||||
await supertest
|
||||
.get('/api/security/privileges')
|
||||
|
@ -89,7 +90,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
// supertest uses assert.deepStrictEqual.
|
||||
// expect.js doesn't help us here.
|
||||
// and lodash's isEqual doesn't know how to compare Sets.
|
||||
const success = isEqualWith(res.body, expected, (value, other, key) => {
|
||||
const success = isEqualWith(res.body, expectedWithoutActions, (value, other, key) => {
|
||||
if (Array.isArray(value) && Array.isArray(other)) {
|
||||
if (key === 'reserved') {
|
||||
// order does not matter for the reserved privilege set.
|
||||
|
@ -106,7 +107,9 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`Expected ${util.inspect(res.body)} to equal ${util.inspect(expected)}`
|
||||
`Expected ${util.inspect(res.body)} to equal ${util.inspect(
|
||||
expectedWithoutActions
|
||||
)}`
|
||||
);
|
||||
}
|
||||
})
|
||||
|
@ -178,5 +181,115 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// In this non-Basic case, results should be exactly the same as not supplying the respectLicenseLevel flag
|
||||
describe('GET /api/security/privileges?respectLicenseLevel=false', () => {
|
||||
it('should return a privilege map with all known privileges, without actions', async () => {
|
||||
// If you're adding a privilege to the following, that's great!
|
||||
// If you're removing a privilege, this breaks backwards compatibility
|
||||
// Roles are associated with these privileges, and we shouldn't be removing them in a minor version.
|
||||
|
||||
await supertest
|
||||
.get('/api/security/privileges?respectLicenseLevel=false')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send()
|
||||
.expect(200)
|
||||
.expect((res: any) => {
|
||||
// when comparing privileges, the order of the features doesn't matter (but the order of the privileges does)
|
||||
// supertest uses assert.deepStrictEqual.
|
||||
// expect.js doesn't help us here.
|
||||
// and lodash's isEqual doesn't know how to compare Sets.
|
||||
const success = isEqualWith(res.body, expectedWithoutActions, (value, other, key) => {
|
||||
if (Array.isArray(value) && Array.isArray(other)) {
|
||||
if (key === 'reserved') {
|
||||
// order does not matter for the reserved privilege set.
|
||||
return isEqual(value.sort(), other.sort());
|
||||
}
|
||||
// order matters for the rest, as the UI assumes they are returned in a descending order of permissiveness.
|
||||
return isEqual(value, other);
|
||||
}
|
||||
|
||||
// Lodash types aren't correct, `undefined` should be supported as a return value here and it
|
||||
// has special meaning.
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`Expected ${util.inspect(res.body)} to equal ${util.inspect(
|
||||
expectedWithoutActions
|
||||
)}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
// In this non-Basic case, results should be exactly the same as not supplying the respectLicenseLevel flag
|
||||
describe('GET /api/security/privileges?includeActions=true&respectLicenseLevel=false', () => {
|
||||
// The UI assumes that no wildcards are present when calculating the effective set of privileges.
|
||||
// If this changes, then the "privilege calculators" will need revisiting to account for these wildcards.
|
||||
it('should return a privilege map with actions which do not include wildcards', async () => {
|
||||
await supertest
|
||||
.get('/api/security/privileges?includeActions=true')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send()
|
||||
.expect(200)
|
||||
.expect((res: any) => {
|
||||
const { features, global, space, reserved } = res.body as RawKibanaPrivileges;
|
||||
expect(features).to.be.an('object');
|
||||
expect(global).to.be.an('object');
|
||||
expect(space).to.be.an('object');
|
||||
expect(reserved).to.be.an('object');
|
||||
|
||||
Object.entries(features).forEach(([featureId, featurePrivs]) => {
|
||||
Object.values(featurePrivs).forEach((actions) => {
|
||||
expect(actions).to.be.an('array');
|
||||
actions.forEach((action) => {
|
||||
expect(action).to.be.a('string');
|
||||
expect(action.indexOf('*')).to.eql(
|
||||
-1,
|
||||
`Feature ${featureId} with action ${action} cannot contain a wildcard`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(global).forEach(([privilegeId, actions]) => {
|
||||
expect(actions).to.be.an('array');
|
||||
actions.forEach((action) => {
|
||||
expect(action).to.be.a('string');
|
||||
expect(action.indexOf('*')).to.eql(
|
||||
-1,
|
||||
`Global privilege ${privilegeId} with action ${action} cannot contain a wildcard`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(space).forEach(([privilegeId, actions]) => {
|
||||
expect(actions).to.be.an('array');
|
||||
actions.forEach((action) => {
|
||||
expect(action).to.be.a('string');
|
||||
expect(action.indexOf('*')).to.eql(
|
||||
-1,
|
||||
`Space privilege ${privilegeId} with action ${action} cannot contain a wildcard`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(reserved).forEach(([privilegeId, actions]) => {
|
||||
expect(actions).to.be.an('array');
|
||||
actions.forEach((action) => {
|
||||
expect(action).to.be.a('string');
|
||||
expect(action.indexOf('*')).to.eql(
|
||||
-1,
|
||||
`Reserved privilege ${privilegeId} with action ${action} cannot contain a wildcard`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -79,6 +79,95 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
})
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should include sub-feature privileges when respectlicenseLevel is false', async () => {
|
||||
const expected = {
|
||||
global: ['all', 'read'],
|
||||
space: ['all', 'read'],
|
||||
features: {
|
||||
graph: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsTagging: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
canvas: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
maps: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
generalCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
|
||||
observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
|
||||
fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
fleet: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
actions: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
stackAlerts: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
ml: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
siem: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
uptime: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
securitySolutionCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'],
|
||||
infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
logs: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
apm: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
discover: [
|
||||
'all',
|
||||
'read',
|
||||
'minimal_all',
|
||||
'minimal_read',
|
||||
'url_create',
|
||||
'store_search_session',
|
||||
],
|
||||
visualize: ['all', 'read', 'minimal_all', 'minimal_read', 'url_create'],
|
||||
dashboard: [
|
||||
'all',
|
||||
'read',
|
||||
'minimal_all',
|
||||
'minimal_read',
|
||||
'url_create',
|
||||
'store_search_session',
|
||||
],
|
||||
dev_tools: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
advancedSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
indexPatterns: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
savedObjectsManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
|
||||
osquery: [
|
||||
'all',
|
||||
'read',
|
||||
'minimal_all',
|
||||
'minimal_read',
|
||||
'live_queries_all',
|
||||
'live_queries_read',
|
||||
'run_saved_queries',
|
||||
'saved_queries_all',
|
||||
'saved_queries_read',
|
||||
'packs_all',
|
||||
'packs_read',
|
||||
],
|
||||
},
|
||||
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
|
||||
};
|
||||
|
||||
await supertest
|
||||
.get('/api/security/privileges?respectLicenseLevel=false')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send()
|
||||
.expect(200)
|
||||
.expect((res: any) => {
|
||||
// when comparing privileges, the order of the privileges doesn't matter.
|
||||
// supertest uses assert.deepStrictEqual.
|
||||
// expect.js doesn't help us here.
|
||||
// and lodash's isEqual doesn't know how to compare Sets.
|
||||
const success = isEqualWith(res.body, expected, (value, other, key) => {
|
||||
if (Array.isArray(value) && Array.isArray(other)) {
|
||||
return isEqual(value.sort(), other.sort());
|
||||
}
|
||||
|
||||
// Lodash types aren't correct, `undefined` should be supported as a return value here and it
|
||||
// has special meaning.
|
||||
return undefined as any;
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
throw new Error(
|
||||
`Expected ${util.inspect(res.body)} to equal ${util.inspect(expected)}`
|
||||
);
|
||||
}
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue