[Cases] RBAC Bugs (#101325)

* Adding feature flag for auth

* Hiding SOs and adding consumer field

* First pass at adding security changes

* Consumer as the app's plugin ID

* Create addConsumerToSO migration helper

* Fix mapping's SO consumer

* Add test for CasesActions

* Declare hidden types on SO client

* Restructure integration tests

* Init spaces_only integration tests

* Implementing the cases security string

* Adding security plugin tests for cases

* Rough concept for authorization class

* Adding comments

* Fix merge

* Get requiredPrivileges for classes

* Check privillages

* Ensure that all classes are available

* Success if hasAllRequested is true

* Failure if hasAllRequested is false

* Adding schema updates for feature plugin

* Seperate basic from trial

* Enable SIR on integration tests

* Starting the plumbing for authorization in plugin

* Unit tests working

* Move find route logic to case client

* Create integration test helper functions

* Adding auth to create call

* Create getClassFilter helper

* Add class attribute to find request

* Create getFindAuthorizationFilter

* Ensure savedObject is authorized in find method

* Include fields for authorization

* Combine authorization filter with cases & subcases filter

* Fix isAuthorized flag

* Fix merge issue

* Create/delete spaces & users before and after tests

* Add more user and roles

* [Cases] Convert filters from strings to KueryNode (#95288)

* [Cases] RBAC: Rename class to scope (#95535)

* [Cases][RBAC] Rename scope to owner (#96035)

* [Cases] RBAC: Create & Find integration tests (#95511)

* [Cases] Cases client enchantment (#95923)

* [Cases] Authorization and Client Audit Logger (#95477)

* Starting audit logger

* Finishing auth audit logger

* Fixing tests and types

* Adding audit event creator

* Renaming class to scope

* Adding audit logger messages to create and find

* Adding comments and fixing import issue

* Fixing type errors

* Fixing tests and adding username to message

* Addressing PR feedback

* Removing unneccessary log and generating id

* Fixing module issue and remove expect.anything

* [Cases] Migrate sub cases routes to a client (#96461)

* Adding sub cases client

* Move sub case routes to case client

* Throw when attempting to access the sub cases client

* Fixing throw and removing user ans soclients

* [Cases] RBAC: Migrate routes' unit tests to integration tests (#96374)

Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>

* [Cases] Move remaining HTTP functionality to client (#96507)

* Moving deletes and find for attachments

* Moving rest of comment apis

* Migrating configuration routes to client

* Finished moving routes, starting utils refactor

* Refactoring utilites and fixing integration tests

* Addressing PR feedback

* Fixing mocks and types

* Fixing integration tests

* Renaming status_stats

* Fixing test type errors

* Adding plugins to kibana.json

* Adding cases to required plugin

* [Cases] Refactoring authorization (#97483)

* Refactoring authorization

* Wrapping auth calls in helper for try catch

* Reverting name change

* Hardcoding the saved object types

* Switching ensure to owner array

* [Cases] Add authorization to configuration & cases routes (#97228)

* [Cases] Attachments RBAC (#97756)

* Starting rbac for comments

* Adding authorization to rest of comment apis

* Starting the comment rbac tests

* Fixing some of the rbac tests

* Adding some integration tests

* Starting patch tests

* Working tests for comments

* Working tests

* Fixing some tests

* Fixing type issues from pulling in master

* Fixing connector tests that only work in trial license

* Attempting to fix cypress

* Mock return of array for configure

* Fixing cypress test

* Cleaning up

* Addressing PR comments

* Reducing operations

* [Cases] Add RBAC to remaining Cases APIs (#98762)

* Starting rbac for comments

* Adding authorization to rest of comment apis

* Starting the comment rbac tests

* Fixing some of the rbac tests

* Adding some integration tests

* Starting patch tests

* Working tests for comments

* Working tests

* Fixing some tests

* Fixing type issues from pulling in master

* Fixing connector tests that only work in trial license

* Attempting to fix cypress

* Mock return of array for configure

* Fixing cypress test

* Cleaning up

* Working case update tests

* Addressing PR comments

* Reducing operations

* Working rbac push case tests

* Starting stats apis

* Working status tests

* User action tests and fixing migration errors

* Fixing type errors

* including error in message

* Addressing pr feedback

* Fixing some type errors

* [Cases] Add space only tests (#99409)

* Starting spaces tests

* Finishing space only tests

* Refactoring createCaseWithConnector

* Fixing spelling

* Addressing PR feedback and creating alert tests

* Fixing mocks

* [Cases] Add security only tests (#99679)

* Starting spaces tests

* Finishing space only tests

* Refactoring createCaseWithConnector

* Fixing spelling

* Addressing PR feedback and creating alert tests

* Fixing mocks

* Starting security only tests

* Adding remainder security only tests

* Using helper objects

* Fixing type error for null space

* Renaming utility variables

* Refactoring users and roles for security only tests

* Adding sub feature

* [Cases] Cleaning up the services and TODOs (#99723)

* Cleaning up the service intialization

* Fixing type errors

* Adding comments for the api

* Working test for cases client

* Fix type error

* Adding generated docs

* Adding more docs and cleaning up types

* Cleaning up readme

* More clean up and links

* Changing some file names

* Renaming docs

* Integration tests for cases privs and fixes (#100038)

* [Cases] RBAC on UI (#99478)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

* Fixing case ids by alert id route call

* [Cases] Fixing UI feature permissions and adding UI tests (#100074)

* Integration tests for cases privs and fixes

* Fixing ui cases permissions and adding tests

* Adding test for collection failure and fixing jest

* Renaming variables

* Fixing type error

* Adding some comments

* Validate cases features

* Fix new schema

* Adding owner param for the status stats

* Fix get case status tests

* Adjusting permissions text and fixing status

* Address PR feedback

* Adding top level feature back

* Fixing feature privileges

* Renaming

* Removing uneeded else

* Fixing tests and adding cases merge tests

* [Cases][Security Solution] Basic license security solution API tests (#100925)

* Cleaning up the fixture plugins

* Adding basic feature test

* renaming to unsecuredSavedObjectsClient (#101215)

* [Cases] RBAC Refactoring audit logging (#100952)

* Refactoring audit logging

* Adding unit tests for authorization classes

* Addressing feedback and adding util tests

* return undefined on empty array

* fixing eslint

* conditional rendering the recently created cases

* Remove unnecessary Array.from

* Cleaning up overview page for permissions

* Fixing log message for attachments

* hiding add to cases button

* Disable the Cases app from the global nav

* Hide the add to cases button from detections

* Fixing merge

* Making progress on removing icons

* Hding edit icons on detail view

* Trying to get connector error msg tests working

* Removing test

* Disable error callouts

* Fixing spacing and removing cases tab one no read

* Adding read only badge

* Cleaning up and adding badge

* Wrapping in use effect

* Default toasting permissions errors

* Removing actions icon on comments

* Addressing feedback

* Fixing type

Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jonathan Buttner 2021-06-22 13:52:03 -04:00 committed by GitHub
parent 3da2ac8927
commit c5e8df02c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 1119 additions and 497 deletions

View file

@ -14,7 +14,7 @@ import { TestProviders } from '../../common/mock';
import { CommentRequest, CommentType, SECURITY_SOLUTION_OWNER } from '../../../common';
import { usePostComment } from '../../containers/use_post_comment';
import { AddComment, AddCommentRefObject } from '.';
import { AddComment, AddCommentProps, AddCommentRefObject } from '.';
import { CasesTimelineIntegrationProvider } from '../timeline_context';
import { timelineIntegrationMock } from '../__mock__/timeline';
@ -25,10 +25,9 @@ const onCommentSaving = jest.fn();
const onCommentPosted = jest.fn();
const postComment = jest.fn();
const addCommentProps = {
const addCommentProps: AddCommentProps = {
caseId: '1234',
disabled: false,
insertQuote: null,
userCanCrud: true,
onCommentSaving,
onCommentPosted,
showLoading: false,
@ -94,11 +93,11 @@ describe('AddComment ', () => {
).toBeTruthy();
});
it('should disable submit button when disabled prop passed', () => {
it('should disable submit button when isLoading is true', () => {
usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true }));
const wrapper = mount(
<TestProviders>
<AddComment {...{ ...addCommentProps, disabled: true }} />
<AddComment {...addCommentProps} />
</TestProviders>
);
@ -107,12 +106,23 @@ describe('AddComment ', () => {
).toBeTruthy();
});
it('should hide the component when the user does not have crud permissions', () => {
usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true }));
const wrapper = mount(
<TestProviders>
<AddComment {...{ ...addCommentProps, userCanCrud: false }} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeFalsy();
});
it('should insert a quote', async () => {
const sampleQuote = 'what a cool quote';
const ref = React.createRef<AddCommentRefObject>();
const wrapper = mount(
<TestProviders>
<AddComment {...{ ...addCommentProps }} ref={ref} />
<AddComment {...addCommentProps} ref={ref} />
</TestProviders>
);
@ -143,7 +153,7 @@ describe('AddComment ', () => {
const wrapper = mount(
<TestProviders>
<CasesTimelineIntegrationProvider timelineIntegration={mockTimelineIntegration}>
<AddComment {...{ ...addCommentProps }} />
<AddComment {...addCommentProps} />
</CasesTimelineIntegrationProvider>
</TestProviders>
);

View file

@ -33,9 +33,9 @@ export interface AddCommentRefObject {
addQuote: (quote: string) => void;
}
interface AddCommentProps {
export interface AddCommentProps {
caseId: string;
disabled?: boolean;
userCanCrud?: boolean;
onCommentSaving?: () => void;
onCommentPosted: (newCase: Case) => void;
showLoading?: boolean;
@ -45,7 +45,7 @@ interface AddCommentProps {
export const AddComment = React.memo(
forwardRef<AddCommentRefObject, AddCommentProps>(
(
{ caseId, disabled, onCommentPosted, onCommentSaving, showLoading = true, subCaseId },
{ caseId, userCanCrud, onCommentPosted, onCommentSaving, showLoading = true, subCaseId },
ref
) => {
const owner = useOwnerContext();
@ -91,31 +91,33 @@ export const AddComment = React.memo(
return (
<span id="add-comment-permLink">
{isLoading && showLoading && <MySpinner data-test-subj="loading-spinner" size="xl" />}
<Form form={form}>
<UseField
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
idAria: 'caseComment',
isDisabled: isLoading,
dataTestSubj: 'add-comment',
placeholder: i18n.ADD_COMMENT_HELP_TEXT,
bottomRightContent: (
<EuiButton
data-test-subj="submit-comment"
iconType="plusInCircle"
isDisabled={isLoading || disabled}
isLoading={isLoading}
onClick={onSubmit}
size="s"
>
{i18n.ADD_COMMENT}
</EuiButton>
),
}}
/>
<InsertTimeline fieldName="comment" />
</Form>
{userCanCrud && (
<Form form={form}>
<UseField
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
idAria: 'caseComment',
isDisabled: isLoading,
dataTestSubj: 'add-comment',
placeholder: i18n.ADD_COMMENT_HELP_TEXT,
bottomRightContent: (
<EuiButton
data-test-subj="submit-comment"
iconType="plusInCircle"
isDisabled={isLoading}
isLoading={isLoading}
onClick={onSubmit}
size="s"
>
{i18n.ADD_COMMENT}
</EuiButton>
),
}}
/>
<InsertTimeline fieldName="comment" />
</Form>
)}
</span>
);
}

View file

@ -52,17 +52,27 @@ export const CasesTableHeader: FunctionComponent<Props> = ({
wrap={true}
data-test-subj="all-cases-header"
>
<FlexItemDivider grow={false}>
<Count refresh={refresh} />
</FlexItemDivider>
<EuiFlexItem grow={false}>
<NavButtons
actionsErrors={actionsErrors}
configureCasesNavigation={configureCasesNavigation}
createCaseNavigation={createCaseNavigation}
userCanCrud={userCanCrud}
/>
</EuiFlexItem>
{userCanCrud ? (
<>
<FlexItemDivider grow={false}>
<Count refresh={refresh} />
</FlexItemDivider>
<EuiFlexItem grow={false}>
<NavButtons
actionsErrors={actionsErrors}
configureCasesNavigation={configureCasesNavigation}
createCaseNavigation={createCaseNavigation}
/>
</EuiFlexItem>
</>
) : (
// doesn't include the horizontal bar that divides the buttons and other padding since we don't have any buttons
// to the right
<EuiFlexItem>
<Count refresh={refresh} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</CaseHeaderPage>
);

View file

@ -17,7 +17,6 @@ interface OwnProps {
actionsErrors: ErrorMessage[];
configureCasesNavigation: CasesNavigation;
createCaseNavigation: CasesNavigation;
userCanCrud: boolean;
}
type Props = OwnProps;
@ -26,14 +25,13 @@ export const NavButtons: FunctionComponent<Props> = ({
actionsErrors,
configureCasesNavigation,
createCaseNavigation,
userCanCrud,
}) => (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<ConfigureCaseButton
configureCasesNavigation={configureCasesNavigation}
label={i18n.CONFIGURE_CASES_BUTTON}
isDisabled={!isEmpty(actionsErrors) || !userCanCrud}
isDisabled={!isEmpty(actionsErrors)}
showToolTip={!isEmpty(actionsErrors)}
msgTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].description : <></>}
titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''}
@ -41,7 +39,6 @@ export const NavButtons: FunctionComponent<Props> = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LinkButton
isDisabled={!userCanCrud}
fill
onClick={createCaseNavigation.onClick}
href={createCaseNavigation.href}

View file

@ -121,19 +121,21 @@ export const CasesTable: FunctionComponent<CasesTableProps> = ({
<EuiEmptyPrompt
title={<h3>{i18n.NO_CASES}</h3>}
titleSize="xs"
body={i18n.NO_CASES_BODY}
body={userCanCrud ? i18n.NO_CASES_BODY : i18n.NO_CASES_BODY_READ_ONLY}
actions={
<LinkButton
isDisabled={!userCanCrud}
fill
size="s"
onClick={goToCreateCase}
href={createCaseNavigation.href}
iconType="plusInCircle"
data-test-subj="cases-table-add-case"
>
{i18n.ADD_NEW_CASE}
</LinkButton>
userCanCrud && (
<LinkButton
isDisabled={!userCanCrud}
fill
size="s"
onClick={goToCreateCase}
href={createCaseNavigation.href}
iconType="plusInCircle"
data-test-subj="cases-table-add-case"
>
{i18n.ADD_NEW_CASE}
</LinkButton>
)
}
/>
}

View file

@ -12,11 +12,19 @@ export * from '../../common/translations';
export const NO_CASES = i18n.translate('xpack.cases.caseTable.noCases.title', {
defaultMessage: 'No Cases',
});
export const NO_CASES_BODY = i18n.translate('xpack.cases.caseTable.noCases.body', {
defaultMessage:
'There are no cases to display. Please create a new case or change your filter settings above.',
});
export const NO_CASES_BODY_READ_ONLY = i18n.translate(
'xpack.cases.caseTable.noCases.readonly.body',
{
defaultMessage: 'There are no cases to display. Please change your filter settings above.',
}
);
export const ADD_NEW_CASE = i18n.translate('xpack.cases.caseTable.addNewCase', {
defaultMessage: 'Add New Case',
});

View file

@ -5,18 +5,7 @@
* 2.0.
*/
import React from 'react';
import md5 from 'md5';
import * as i18n from './translations';
import { ErrorMessage } from './types';
export const permissionsReadOnlyErrorMessage: ErrorMessage = {
id: 'read-only-privileges-error',
title: i18n.READ_ONLY_FEATURE_TITLE,
description: <>{i18n.READ_ONLY_FEATURE_MSG}</>,
errorType: 'warning',
};
export const createCalloutId = (ids: string[], delimiter: string = '|'): string =>
md5(ids.join(delimiter));

View file

@ -7,15 +7,6 @@
import { i18n } from '@kbn/i18n';
export const READ_ONLY_FEATURE_TITLE = i18n.translate('xpack.cases.readOnlyFeatureTitle', {
defaultMessage: 'You cannot open new or update existing cases',
});
export const READ_ONLY_FEATURE_MSG = i18n.translate('xpack.cases.readOnlyFeatureDescription', {
defaultMessage:
'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.',
});
export const DISMISS_CALLOUT = i18n.translate('xpack.cases.dismissErrorsPushServiceCallOutTitle', {
defaultMessage: 'Dismiss',
});

View file

@ -19,14 +19,12 @@ interface CaseViewActions {
allCasesNavigation: CasesNavigation;
caseData: Case;
currentExternalIncident: CaseService | null;
disabled?: boolean;
}
const ActionsComponent: React.FC<CaseViewActions> = ({
allCasesNavigation,
caseData,
currentExternalIncident,
disabled = false,
}) => {
// Delete case
const {
@ -39,7 +37,6 @@ const ActionsComponent: React.FC<CaseViewActions> = ({
const propertyActions = useMemo(
() => [
{
disabled,
iconType: 'trash',
label: i18n.DELETE_CASE(),
onClick: handleToggleModal,
@ -54,7 +51,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({
]
: []),
],
[disabled, handleToggleModal, currentExternalIncident]
[handleToggleModal, currentExternalIncident]
);
if (isDeleted) {

View file

@ -26,6 +26,7 @@ describe('CaseActionBar', () => {
onRefresh,
onUpdateField,
currentExternalIncident: null,
userCanCrud: true,
};
beforeEach(() => {

View file

@ -40,7 +40,7 @@ interface CaseActionBarProps {
allCasesNavigation: CasesNavigation;
caseData: Case;
currentExternalIncident: CaseService | null;
disabled?: boolean;
userCanCrud: boolean;
disableAlerting: boolean;
isLoading: boolean;
onRefresh: () => void;
@ -50,8 +50,8 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
allCasesNavigation,
caseData,
currentExternalIncident,
disabled = false,
disableAlerting,
userCanCrud,
isLoading,
onRefresh,
onUpdateField,
@ -87,7 +87,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
<EuiDescriptionListDescription>
<StatusContextMenu
currentStatus={caseData.status}
disabled={disabled || isLoading}
disabled={!userCanCrud || isLoading}
onStatusChanged={onStatusChanged}
/>
</EuiDescriptionListDescription>
@ -108,7 +108,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
<EuiFlexItem grow={false}>
<EuiDescriptionList compressed>
<EuiFlexGroup gutterSize="l" alignItems="center">
{!disableAlerting && (
{userCanCrud && !disableAlerting && (
<EuiFlexItem>
<EuiDescriptionListTitle>
<EuiFlexGroup component="span" alignItems="center" gutterSize="xs">
@ -122,7 +122,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<SyncAlertsSwitch
disabled={disabled || isLoading}
disabled={isLoading}
isSynced={caseData.settings.syncAlerts}
onSwitchChange={onSyncAlertsChanged}
/>
@ -134,14 +134,15 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
{i18n.CASE_REFRESH}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj="case-view-actions">
<Actions
allCasesNavigation={allCasesNavigation}
caseData={caseData}
currentExternalIncident={currentExternalIncident}
disabled={disabled}
/>
</EuiFlexItem>
{userCanCrud && (
<EuiFlexItem grow={false} data-test-subj="case-view-actions">
<Actions
allCasesNavigation={allCasesNavigation}
caseData={caseData}
currentExternalIncident={currentExternalIncident}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiDescriptionList>
</EuiFlexItem>

View file

@ -230,7 +230,9 @@ export const CaseComponent = React.memo<CaseComponentProps>(
[updateCase, fetchCaseUserActions, caseId, subCaseId]
);
const { loading: isLoadingConnectors, connectors } = useConnectors();
const { loading: isLoadingConnectors, connectors, permissionsError } = useConnectors({
toastPermissionsErrors: false,
});
const [connectorName, isValidConnector] = useMemo(() => {
const connector = connectors.find((c) => c.id === caseData.connector.id);
@ -363,7 +365,7 @@ export const CaseComponent = React.memo<CaseComponentProps>(
allCasesNavigation={allCasesNavigation}
caseData={caseData}
currentExternalIncident={currentExternalIncident}
disabled={!userCanCrud}
userCanCrud={userCanCrud}
disableAlerting={ruleDetailsNavigation == null}
isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')}
onRefresh={handleRefresh}
@ -406,7 +408,7 @@ export const CaseComponent = React.memo<CaseComponentProps>(
useFetchAlertData={useFetchAlertData}
userCanCrud={userCanCrud}
/>
{(caseData.type !== CaseType.collection || hasDataToPush) && (
{(caseData.type !== CaseType.collection || hasDataToPush) && userCanCrud && (
<>
<MyEuiHorizontalRule
margin="s"
@ -418,7 +420,6 @@ export const CaseComponent = React.memo<CaseComponentProps>(
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
disabled={!userCanCrud}
isLoading={isLoading && updateKey === 'status'}
/>
</EuiFlexItem>
@ -450,16 +451,15 @@ export const CaseComponent = React.memo<CaseComponentProps>(
/>
<TagList
data-test-subj="case-view-tag-list"
disabled={!userCanCrud}
userCanCrud={userCanCrud}
tags={caseData.tags}
onSubmit={onSubmitTags}
isLoading={isLoading && updateKey === 'tags'}
owner={[caseData.owner]}
/>
<EditConnector
caseFields={caseData.connector.fields}
connectors={connectors}
disabled={!userCanCrud}
userCanCrud={userCanCrud}
hideConnectorServiceNowSir={
subCaseId != null || caseData.type === CaseType.collection
}
@ -467,6 +467,7 @@ export const CaseComponent = React.memo<CaseComponentProps>(
onSubmit={onSubmitConnector}
selectedConnector={caseData.connector.id}
userActions={caseUserActions}
permissionsError={permissionsError}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -9,7 +9,7 @@ import React from 'react';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { EditConnector } from './index';
import { EditConnector, EditConnectorProps } from './index';
import { getFormMock, useFormMock } from '../__mock__/form';
import { TestProviders } from '../../common/mock';
import { connectorsMock } from '../../containers/configure/mock';
@ -21,9 +21,9 @@ jest.mock('../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const onSubmit = jest.fn();
const defaultProps = {
const defaultProps: EditConnectorProps = {
connectors: connectorsMock,
disabled: false,
userCanCrud: true,
isLoading: false,
onSubmit,
selectedConnector: 'none',
@ -144,4 +144,53 @@ describe('EditConnector ', () => {
expect(wrapper.find(`[data-test-subj="connector-loading"]`).last().exists()).toBeTruthy()
);
});
it('does not allow the connector to be edited when the user does not have write permissions', async () => {
const props = { ...defaultProps, userCanCrud: false };
const wrapper = mount(
<TestProviders>
<EditConnector {...props} />
</TestProviders>
);
await waitFor(() =>
expect(wrapper.find(`[data-test-subj="connector-edit"]`).exists()).toBeFalsy()
);
});
it('displays the permissions error message when one is provided', async () => {
const props = { ...defaultProps, permissionsError: 'error message' };
const wrapper = mount(
<TestProviders>
<EditConnector {...props} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists()
).toBeTruthy();
expect(
wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists()
).toBeFalsy();
});
});
it('displays the default none connector message', async () => {
const props = { ...defaultProps };
const wrapper = mount(
<TestProviders>
<EditConnector {...props} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists()
).toBeFalsy();
expect(
wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists()
).toBeTruthy();
});
});
});

View file

@ -30,7 +30,7 @@ import { schema } from './schema';
import { getConnectorFieldsFromUserActions } from './helpers';
import * as i18n from './translations';
interface EditConnectorProps {
export interface EditConnectorProps {
caseFields: ConnectorTypeFields['fields'];
connectors: ActionConnector[];
isLoading: boolean;
@ -42,8 +42,9 @@ interface EditConnectorProps {
) => void;
selectedConnector: string;
userActions: CaseUserActions[];
disabled?: boolean;
userCanCrud?: boolean;
hideConnectorServiceNowSir?: boolean;
permissionsError?: string;
}
const MyFlexGroup = styled(EuiFlexGroup)`
@ -104,12 +105,13 @@ export const EditConnector = React.memo(
({
caseFields,
connectors,
disabled = false,
userCanCrud = true,
hideConnectorServiceNowSir = false,
isLoading,
onSubmit,
selectedConnector,
userActions,
permissionsError,
}: EditConnectorProps) => {
const { form } = useForm({
defaultValue: { connectorId: selectedConnector },
@ -203,6 +205,18 @@ export const EditConnector = React.memo(
});
}, [dispatch]);
/**
* if this evaluates to true it means that the connector was likely deleted because the case connector was set to something
* other than none but we don't find it in the list of connectors returned from the actions plugin
*/
const connectorFromCaseMissing = currentConnector == null && selectedConnector !== 'none';
/**
* True if the chosen connector from the form was the "none" connector or no connector was in the case. The
* currentConnector will be null initially and after the form initializes if the case connector is "none"
*/
const connectorUndefinedOrNone = currentConnector == null || currentConnector?.id === 'none';
return (
<EuiText>
<MyFlexGroup alignItems="center" gutterSize="xs" justifyContent="spaceBetween">
@ -210,11 +224,10 @@ export const EditConnector = React.memo(
<h4>{i18n.CONNECTORS}</h4>
</EuiFlexItem>
{isLoading && <EuiLoadingSpinner data-test-subj="connector-loading" />}
{!isLoading && !editConnector && (
{!isLoading && !editConnector && userCanCrud && (
<EuiFlexItem data-test-subj="connector-edit" grow={false}>
<EuiButtonIcon
data-test-subj="connector-edit-button"
isDisabled={disabled}
aria-label={i18n.EDIT_CONNECTOR_ARIA}
iconType={'pencil'}
onClick={onEditClick}
@ -235,7 +248,7 @@ export const EditConnector = React.memo(
connectors,
dataTestSubj: 'caseConnectors',
defaultValue: selectedConnector,
disabled,
disabled: !userCanCrud,
hideConnectorServiceNowSir,
idAria: 'caseConnectors',
isEdit: editConnector,
@ -248,13 +261,21 @@ export const EditConnector = React.memo(
</Form>
</DisappearingFlexItem>
<EuiFlexItem data-test-subj="edit-connector-fields-form-flex-item">
{(currentConnector == null || currentConnector?.id === 'none') && // Connector is none or not defined.
!(currentConnector === null && selectedConnector !== 'none') && // Connector has not been deleted.
!editConnector && (
<EuiText size="s">
{!editConnector && permissionsError ? (
<EuiText data-test-subj="edit-connector-permissions-error-msg" size="s">
<span>{permissionsError}</span>
</EuiText>
) : (
// if we're not editing the connectors and the connector specified in the case was found and the connector
// is undefined or explicitly set to none
!editConnector &&
!connectorFromCaseMissing &&
connectorUndefinedOrNone && (
<EuiText data-test-subj="edit-connector-no-connectors-msg" size="s">
<span>{i18n.NO_CONNECTOR}</span>
</EuiText>
)}
)
)}
<ConnectorFieldsForm
connector={currentConnector}
fields={fields}

View file

@ -32,6 +32,7 @@ const defaultProps = {
href: 'create-details-href',
onClick: jest.fn(),
},
hasWritePermissions: true,
maxCasesToShow: 10,
owner: [SECURITY_SOLUTION_OWNER],
};

View file

@ -21,6 +21,7 @@ export interface RecentCasesProps extends Owner {
allCasesNavigation: CasesNavigation;
caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
createCaseNavigation: CasesNavigation;
hasWritePermissions: boolean;
maxCasesToShow: number;
}
@ -29,6 +30,7 @@ const RecentCasesComponent = ({
caseDetailsNavigation,
createCaseNavigation,
maxCasesToShow,
hasWritePermissions,
}: Omit<RecentCasesProps, 'owner'>) => {
const currentUser = useCurrentUser();
const [recentCasesFilterBy, setRecentCasesFilterBy] = useState<RecentCasesFilterMode>(
@ -77,6 +79,7 @@ const RecentCasesComponent = ({
createCaseNavigation={createCaseNavigation}
filterOptions={recentCasesFilterOptions}
maxCasesToShow={maxCasesToShow}
hasWritePermissions={hasWritePermissions}
/>
<EuiHorizontalRule margin="s" />
<EuiText size="xs">

View file

@ -16,11 +16,22 @@ describe('RecentCases', () => {
const createCaseHref = '/create';
const wrapper = mount(
<TestProviders>
<NoCases createCaseHref={createCaseHref} />
<NoCases createCaseHref={createCaseHref} hasWritePermissions />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).first().prop('href')).toEqual(
createCaseHref
);
});
it('displays a message without a link to create a case when the user does not have write permissions', () => {
const createCaseHref = '/create';
const wrapper = mount(
<TestProviders>
<NoCases createCaseHref={createCaseHref} hasWritePermissions={false} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).exists()).toBeFalsy();
expect(wrapper.find(`[data-test-subj="no-cases-readonly"]`).exists()).toBeTruthy();
});
});

View file

@ -10,16 +10,26 @@ import React from 'react';
import { EuiLink } from '@elastic/eui';
import * as i18n from '../translations';
const NoCasesComponent = ({ createCaseHref }: { createCaseHref: string }) => (
<>
<span>{i18n.NO_CASES}</span>
<EuiLink
data-test-subj="no-cases-create-case"
href={createCaseHref}
>{` ${i18n.START_A_NEW_CASE}`}</EuiLink>
{'!'}
</>
);
const NoCasesComponent = ({
createCaseHref,
hasWritePermissions,
}: {
createCaseHref: string;
hasWritePermissions: boolean;
}) => {
return hasWritePermissions ? (
<>
<span>{i18n.NO_CASES}</span>
<EuiLink
data-test-subj="no-cases-create-case"
href={createCaseHref}
>{` ${i18n.START_A_NEW_CASE}`}</EuiLink>
{'!'}
</>
) : (
<span data-test-subj="no-cases-readonly">{i18n.NO_CASES_READ_ONLY}</span>
);
};
NoCasesComponent.displayName = 'NoCasesComponent';

View file

@ -31,6 +31,7 @@ export interface RecentCasesProps {
caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
createCaseNavigation: CasesNavigation;
maxCasesToShow: number;
hasWritePermissions: boolean;
}
const usePrevious = (value: Partial<FilterOptions>) => {
@ -45,6 +46,7 @@ export const RecentCasesComp = ({
createCaseNavigation,
filterOptions,
maxCasesToShow,
hasWritePermissions,
}: RecentCasesProps) => {
const previousFilterOptions = usePrevious(filterOptions);
const { data, loading, setFilters } = useGetCases({
@ -65,7 +67,7 @@ export const RecentCasesComp = ({
return isLoadingCases ? (
<LoadingPlaceholders lines={2} placeholders={3} />
) : !isLoadingCases && data.cases.length === 0 ? (
<NoCases createCaseHref={createCaseNavigation.href} />
<NoCases createCaseHref={createCaseNavigation.href} hasWritePermissions={hasWritePermissions} />
) : (
<>
{data.cases.map((c, i) => (

View file

@ -22,6 +22,10 @@ export const NO_CASES = i18n.translate('xpack.cases.recentCases.noCasesMessage',
defaultMessage: 'No cases have been created yet. Put your detective hat on and',
});
export const NO_CASES_READ_ONLY = i18n.translate('xpack.cases.recentCases.noCasesMessageReadOnly', {
defaultMessage: 'No cases have been created yet.',
});
export const RECENT_CASES = i18n.translate('xpack.cases.recentCases.recentCasesSidebarTitle', {
defaultMessage: 'Recent cases',
});

View file

@ -13,7 +13,6 @@ import { statuses } from './config';
interface Props {
status: CaseStatuses;
disabled: boolean;
isLoading: boolean;
onStatusChanged: (status: CaseStatuses) => void;
}
@ -21,12 +20,7 @@ interface Props {
// Rotate over the statuses. open -> in-progress -> closes -> open...
const getNextItem = (item: number) => (item + 1) % caseStatuses.length;
const StatusActionButtonComponent: React.FC<Props> = ({
status,
onStatusChanged,
disabled,
isLoading,
}) => {
const StatusActionButtonComponent: React.FC<Props> = ({ status, onStatusChanged, isLoading }) => {
const indexOfCurrentStatus = useMemo(
() => caseStatuses.findIndex((caseStatus) => caseStatus === status),
[status]
@ -41,7 +35,6 @@ const StatusActionButtonComponent: React.FC<Props> = ({
<EuiButton
data-test-subj="case-view-status-action-button"
iconType={statuses[caseStatuses[nextStatusIndex]].icon}
isDisabled={disabled}
isLoading={isLoading}
onClick={onClick}
>

View file

@ -42,17 +42,14 @@ describe('Stats', () => {
).toBe(false);
});
it('it renders with the pop over disabled when initialized disabled', async () => {
it('renders without the arrow and is not clickable when initialized disabled', async () => {
const wrapper = mount(
<Status disabled={true} type={CaseStatuses.open} withArrow={true} onClick={onClick} />
);
expect(
wrapper
.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`)
.first()
.prop('disabled')
).toBe(true);
wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).exists()
).toBeFalsy();
});
it('it calls onClick when pressing the badge', async () => {

View file

@ -29,18 +29,18 @@ const StatusComponent: React.FC<Props> = ({
const props = useMemo(
() => ({
color: type === StatusAll ? allCaseStatus[StatusAll].color : statuses[type].color,
...(withArrow ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}),
// if we are disabled, don't show the arrow and don't allow the user to click
...(withArrow && !disabled ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}),
...(!disabled ? { iconOnClick: onClick } : { iconOnClick: noop }),
}),
[withArrow, type]
[disabled, onClick, withArrow, type]
);
return (
<EuiBadge
{...props}
iconOnClick={onClick}
iconOnClickAriaLabel={i18n.STATUS_ICON_ARIA}
data-test-subj={`status-badge-${type}`}
isDisabled={disabled}
>
{type === StatusAll ? allCaseStatus[StatusAll].label : statuses[type].label}
</EuiBadge>

View file

@ -8,13 +8,12 @@
import React from 'react';
import { mount } from 'enzyme';
import { TagList } from '.';
import { TagList, TagListProps } from '.';
import { getFormMock } from '../__mock__/form';
import { TestProviders } from '../../common/mock';
import { waitFor } from '@testing-library/react';
import { useForm } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form';
import { useGetTags } from '../../containers/use_get_tags';
import { SECURITY_SOLUTION_OWNER } from '../../../common';
jest.mock('../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form');
jest.mock('../../containers/use_get_tags');
@ -33,12 +32,11 @@ jest.mock('@elastic/eui', () => {
};
});
const onSubmit = jest.fn();
const defaultProps = {
disabled: false,
const defaultProps: TagListProps = {
userCanCrud: true,
isLoading: false,
onSubmit,
tags: [],
owner: [SECURITY_SOLUTION_OWNER],
};
describe('TagList ', () => {
@ -110,15 +108,13 @@ describe('TagList ', () => {
expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy();
});
it('Renders disabled button', () => {
const props = { ...defaultProps, disabled: true };
it('does not render when the user does not have write permissions', () => {
const props = { ...defaultProps, userCanCrud: false };
const wrapper = mount(
<TestProviders>
<TagList {...props} />
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().prop('disabled')
).toBeTruthy();
expect(wrapper.find(`[data-test-subj="tag-list-edit"]`).exists()).toBeFalsy();
});
});

View file

@ -27,12 +27,11 @@ import { Tags } from './tags';
const CommonUseField = getUseField({ component: Field });
interface TagListProps {
disabled?: boolean;
export interface TagListProps {
userCanCrud?: boolean;
isLoading: boolean;
onSubmit: (a: string[]) => void;
tags: string[];
owner: string[];
}
const MyFlexGroup = styled(EuiFlexGroup)`
@ -45,7 +44,7 @@ const MyFlexGroup = styled(EuiFlexGroup)`
`;
export const TagList = React.memo(
({ disabled = false, isLoading, onSubmit, tags, owner }: TagListProps) => {
({ userCanCrud = true, isLoading, onSubmit, tags }: TagListProps) => {
const initialState = { tags };
const { form } = useForm({
defaultValue: initialState,
@ -86,11 +85,10 @@ export const TagList = React.memo(
<h4>{i18n.TAGS}</h4>
</EuiFlexItem>
{isLoading && <EuiLoadingSpinner data-test-subj="tag-list-loading" />}
{!isLoading && (
{!isLoading && userCanCrud && (
<EuiFlexItem data-test-subj="tag-list-edit" grow={false}>
<EuiButtonIcon
data-test-subj="tag-list-edit-button"
isDisabled={disabled}
aria-label={i18n.EDIT_TAGS_ARIA}
iconType={'pencil'}
onClick={setIsEditTags.bind(null, true)}

View file

@ -268,4 +268,157 @@ describe('usePushToService', () => {
expect(errorsMsg[0].id).toEqual('closed-case-push-error');
});
});
describe('user does not have write permissions', () => {
const noWriteProps = { ...defaultArgs, userCanCrud: false };
it('does not display a message when user does not have a premium license', async () => {
(useGetActionLicense as jest.Mock).mockImplementation(() => ({
isLoading: false,
actionLicense: {
...actionLicense,
enabledInLicense: false,
},
}));
await act(async () => {
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
() => usePushToService(noWriteProps),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
}
);
await waitForNextUpdate();
expect(result.current.pushCallouts).toBeNull();
});
});
it('does not display a message when user does not have case enabled in config', async () => {
(useGetActionLicense as jest.Mock).mockImplementation(() => ({
isLoading: false,
actionLicense: {
...actionLicense,
enabledInConfig: false,
},
}));
await act(async () => {
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
() => usePushToService(noWriteProps),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
}
);
await waitForNextUpdate();
expect(result.current.pushCallouts).toBeNull();
});
});
it('does not display a message when user does not have any connector configured', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
() =>
usePushToService({
...noWriteProps,
connectors: [],
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
}),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
}
);
await waitForNextUpdate();
expect(result.current.pushCallouts).toBeNull();
});
});
it('does not display a message when user does have a connector but is configured to none', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
() =>
usePushToService({
...noWriteProps,
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
}),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
}
);
await waitForNextUpdate();
expect(result.current.pushCallouts).toBeNull();
});
});
it('does not display a message when connector is deleted', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
() =>
usePushToService({
...noWriteProps,
connector: {
id: 'not-exist',
name: 'not-exist',
type: ConnectorTypes.none,
fields: null,
},
isValidConnector: false,
}),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
}
);
await waitForNextUpdate();
expect(result.current.pushCallouts).toBeNull();
});
});
it('does not display a message when connector is deleted with empty connectors', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
() =>
usePushToService({
...noWriteProps,
connectors: [],
connector: {
id: 'not-exist',
name: 'not-exist',
type: ConnectorTypes.none,
fields: null,
},
isValidConnector: false,
}),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
}
);
await waitForNextUpdate();
expect(result.current.pushCallouts).toBeNull();
});
});
it('does not display a message when case is closed', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<UsePushToService, ReturnUsePushToService>(
() =>
usePushToService({
...noWriteProps,
caseStatus: CaseStatuses.closed,
}),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
}
);
await waitForNextUpdate();
expect(result.current.pushCallouts).toBeNull();
});
});
});
});

View file

@ -67,9 +67,17 @@ export const usePushToService = ({
const errorsMsg = useMemo(() => {
let errors: ErrorMessage[] = [];
// these message require that the user do some sort of write action as a result of the message, readonly users won't
// be able to perform such an action so let's not display the error to the user in that situation
if (!userCanCrud) {
return errors;
}
if (actionLicense != null && !actionLicense.enabledInLicense) {
errors = [...errors, getLicenseError()];
}
if (connectors.length === 0 && connector.id === 'none' && !loadingLicense) {
errors = [
...errors,
@ -136,12 +144,13 @@ export const usePushToService = ({
},
];
}
if (actionLicense != null && !actionLicense.enabledInConfig) {
errors = [...errors, getKibanaConfigError()];
}
return errors;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionLicense, caseStatus, connectors.length, connector, loadingLicense]);
}, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, userCanCrud]);
const pushToServiceButton = useMemo(
() => (

View file

@ -241,7 +241,7 @@ export const UserActionTree = React.memo(
() => (
<AddComment
caseId={caseId}
disabled={!userCanCrud}
userCanCrud={userCanCrud}
ref={addCommentRef}
onCommentPosted={handleUpdate}
onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_ID)}
@ -288,10 +288,10 @@ export const UserActionTree = React.memo(
id={DESCRIPTION_ID}
editLabel={i18n.EDIT_DESCRIPTION}
quoteLabel={i18n.QUOTE}
disabled={!userCanCrud}
isLoading={isLoadingDescription}
onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)}
onQuote={handleManageQuote.bind(null, caseData.description)}
userCanCrud={userCanCrud}
/>
),
}),
@ -363,10 +363,10 @@ export const UserActionTree = React.memo(
id={comment.id}
editLabel={i18n.EDIT_COMMENT}
quoteLabel={i18n.QUOTE}
disabled={!userCanCrud}
isLoading={isLoadingIds.includes(comment.id)}
onEdit={handleManageMarkdownEditId.bind(null, comment.id)}
onQuote={handleManageQuote.bind(null, comment.comment)}
userCanCrud={userCanCrud}
/>
),
},
@ -571,19 +571,24 @@ export const UserActionTree = React.memo(
]
);
const bottomActions = [
{
username: (
<UserActionUsername username={currentUser?.username} fullName={currentUser?.fullName} />
),
'data-test-subj': 'add-comment',
timelineIcon: (
<UserActionAvatar username={currentUser?.username} fullName={currentUser?.fullName} />
),
className: 'isEdit',
children: MarkdownNewComment,
},
];
const bottomActions = userCanCrud
? [
{
username: (
<UserActionUsername
username={currentUser?.username}
fullName={currentUser?.fullName}
/>
),
'data-test-subj': 'add-comment',
timelineIcon: (
<UserActionAvatar username={currentUser?.username} fullName={currentUser?.fullName} />
),
className: 'isEdit',
children: MarkdownNewComment,
},
]
: [];
const comments = [...userActions, ...bottomActions];

View file

@ -7,7 +7,10 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { UserActionContentToolbar } from './user_action_content_toolbar';
import {
UserActionContentToolbar,
UserActionContentToolbarProps,
} from './user_action_content_toolbar';
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
@ -28,12 +31,12 @@ jest.mock('../../common/lib/kibana', () => ({
}),
}));
const props = {
const props: UserActionContentToolbarProps = {
getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('case-detail-url-with-comment-id-1'),
id: '1',
editLabel: 'edit',
quoteLabel: 'quote',
disabled: false,
userCanCrud: true,
isLoading: false,
onEdit: jest.fn(),
onQuote: jest.fn(),

View file

@ -11,15 +11,15 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { UserActionCopyLink } from './user_action_copy_link';
import { UserActionPropertyActions } from './user_action_property_actions';
interface UserActionContentToolbarProps {
export interface UserActionContentToolbarProps {
id: string;
getCaseDetailHrefWithCommentId: (commentId: string) => string;
editLabel: string;
quoteLabel: string;
disabled: boolean;
isLoading: boolean;
onEdit: (id: string) => void;
onQuote: (id: string) => void;
userCanCrud: boolean;
}
const UserActionContentToolbarComponent = ({
@ -27,26 +27,27 @@ const UserActionContentToolbarComponent = ({
getCaseDetailHrefWithCommentId,
editLabel,
quoteLabel,
disabled,
isLoading,
onEdit,
onQuote,
userCanCrud,
}: UserActionContentToolbarProps) => (
<EuiFlexGroup>
<EuiFlexItem>
<UserActionCopyLink id={id} getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} />
</EuiFlexItem>
<EuiFlexItem>
<UserActionPropertyActions
id={id}
editLabel={editLabel}
quoteLabel={quoteLabel}
disabled={disabled}
isLoading={isLoading}
onEdit={onEdit}
onQuote={onQuote}
/>
</EuiFlexItem>
{userCanCrud && (
<EuiFlexItem>
<UserActionPropertyActions
id={id}
editLabel={editLabel}
quoteLabel={quoteLabel}
isLoading={isLoading}
onEdit={onEdit}
onQuote={onQuote}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
);

View file

@ -14,7 +14,6 @@ interface UserActionPropertyActionsProps {
id: string;
editLabel: string;
quoteLabel: string;
disabled: boolean;
isLoading: boolean;
onEdit: (id: string) => void;
onQuote: (id: string) => void;
@ -24,7 +23,6 @@ const UserActionPropertyActionsComponent = ({
id,
editLabel,
quoteLabel,
disabled,
isLoading,
onEdit,
onQuote,
@ -35,19 +33,17 @@ const UserActionPropertyActionsComponent = ({
const propertyActions = useMemo(
() => [
{
disabled,
iconType: 'pencil',
label: editLabel,
onClick: onEditClick,
},
{
disabled,
iconType: 'quote',
label: quoteLabel,
onClick: onQuoteClick,
},
],
[disabled, editLabel, quoteLabel, onEditClick, onQuoteClick]
[editLabel, quoteLabel, onEditClick, onQuoteClick]
);
return (
<>

View file

@ -12,3 +12,11 @@ export * from '../translations';
export const SUCCESS_CONFIGURE = i18n.translate('xpack.cases.configure.successSaveToast', {
defaultMessage: 'Saved external connection settings',
});
export const READ_PERMISSIONS_ERROR_MSG = i18n.translate(
'xpack.cases.configure.readPermissionsErrorDescription',
{
defaultMessage:
'You do not have permissions to view connectors. If you would like to view the connectors associated with this case, contact your Kibana administrator.',
}
);

View file

@ -7,26 +7,40 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import * as i18n from '../translations';
import { fetchConnectors } from './api';
import { ActionConnector } from './types';
import { useToasts } from '../../common/lib/kibana';
import * as i18n from './translations';
interface ConnectorsState {
loading: boolean;
connectors: ActionConnector[];
permissionsError?: string;
}
export interface UseConnectorsResponse {
loading: boolean;
connectors: ActionConnector[];
refetchConnectors: () => void;
permissionsError?: string;
}
export const useConnectors = (): UseConnectorsResponse => {
/**
* Retrieves the configured case connectors
*
* @param toastPermissionsErrors boolean controlling whether 403 and 401 errors should be displayed in a toast error
*/
export const useConnectors = ({
toastPermissionsErrors = true,
}: {
toastPermissionsErrors?: boolean;
} = {}): UseConnectorsResponse => {
const toasts = useToasts();
const [state, setState] = useState<{
loading: boolean;
connectors: ActionConnector[];
}>({
const [state, setState] = useState<ConnectorsState>({
loading: true,
connectors: [],
});
const isCancelledRef = useRef(false);
const abortCtrlRef = useRef(new AbortController());
@ -49,15 +63,26 @@ export const useConnectors = (): UseConnectorsResponse => {
}
} catch (error) {
if (!isCancelledRef.current) {
let permissionsError: string | undefined;
if (error.name !== 'AbortError') {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{ title: i18n.ERROR_TITLE }
);
// if the error was related to permissions then let's return a boilerplate error message describing the problem
if (error.body?.statusCode === 403 || error.body?.statusCode === 401) {
permissionsError = i18n.READ_PERMISSIONS_ERROR_MSG;
}
// if the error was not permissions related then toast it
// if it was permissions related (permissionsError was defined) and the caller wants to toast, then create a toast
if (permissionsError === undefined || toastPermissionsErrors) {
toasts.addError(
error.body && error.body.message ? new Error(error.body.message) : error,
{ title: i18n.ERROR_TITLE }
);
}
}
setState({
loading: false,
connectors: [],
permissionsError,
});
}
}
@ -77,5 +102,6 @@ export const useConnectors = (): UseConnectorsResponse => {
loading: state.loading,
connectors: state.connectors,
refetchConnectors,
permissionsError: state.permissionsError,
};
};

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CasesUiStart } from './types';
const createStartContract = (): jest.Mocked<CasesUiStart> => ({
getAllCases: jest.fn(),
getAllCasesSelectorModal: jest.fn(),
getCaseView: jest.fn(),
getConfigureCases: jest.fn(),
getCreateCase: jest.fn(),
getRecentCases: jest.fn(),
});
export const casesPluginMock = {
createStartContract,
};

View file

@ -143,7 +143,7 @@ describe('audit_logger', () => {
// for reference: https://github.com/facebook/jest/issues/9409#issuecomment-629272237
// This loops through all operation keys
it.each(Array.from(Object.keys(Operations)))(
it.each(Object.keys(Operations))(
`creates the correct audit event for operation: "%s" without an error or entity`,
(operationKey) => {
// forcing the cast here because using a string throws a type error
@ -156,7 +156,7 @@ describe('audit_logger', () => {
);
// This loops through all operation keys
it.each(Array.from(Object.keys(Operations)))(
it.each(Object.keys(Operations))(
`creates the correct audit event for operation: "%s" with an error but no entity`,
(operationKey) => {
// forcing the cast here because using a string throws a type error
@ -170,7 +170,7 @@ describe('audit_logger', () => {
);
// This loops through all operation keys
it.each(Array.from(Object.keys(Operations)))(
it.each(Object.keys(Operations))(
`creates the correct audit event for operation: "%s" with an error and entity`,
(operationKey) => {
// forcing the cast here because using a string throws a type error
@ -188,7 +188,7 @@ describe('audit_logger', () => {
);
// This loops through all operation keys
it.each(Array.from(Object.keys(Operations)))(
it.each(Object.keys(Operations))(
`creates the correct audit event for operation: "%s" without an error but with an entity`,
(operationKey) => {
// forcing the cast here because using a string throws a type error

View file

@ -72,7 +72,7 @@ export class CasePlugin {
this.clientFactory = new CasesClientFactory(this.log);
}
public async setup(core: CoreSetup, plugins: PluginsSetup) {
public setup(core: CoreSetup, plugins: PluginsSetup) {
const config = createConfig(this.initializerContext);
if (!config.enabled) {

View file

@ -5,18 +5,7 @@
* 2.0.
*/
import React from 'react';
import md5 from 'md5';
import * as i18n from './translations';
import { ErrorMessage } from './types';
export const permissionsReadOnlyErrorMessage: ErrorMessage = {
id: 'read-only-privileges-error',
title: i18n.READ_ONLY_FEATURE_TITLE,
description: <>{i18n.READ_ONLY_FEATURE_MSG}</>,
errorType: 'warning',
};
export const createCalloutId = (ids: string[], delimiter: string = '|'): string =>
md5(ids.join(delimiter));

View file

@ -7,21 +7,6 @@
import { i18n } from '@kbn/i18n';
export const READ_ONLY_FEATURE_TITLE = i18n.translate(
'xpack.observability.cases.readOnlyFeatureTitle',
{
defaultMessage: 'You cannot open new or update existing cases',
}
);
export const READ_ONLY_FEATURE_MSG = i18n.translate(
'xpack.observability.cases.readOnlyFeatureDescription',
{
defaultMessage:
'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.',
}
);
export const DISMISS_CALLOUT = i18n.translate(
'xpack.observability.cases.dismissErrorsPushServiceCallOutTitle',
{

View file

@ -201,3 +201,17 @@ export const CONNECTORS = i18n.translate('xpack.observability.cases.caseView.con
export const EDIT_CONNECTOR = i18n.translate('xpack.observability.cases.caseView.editConnector', {
defaultMessage: 'Change external incident management system',
});
export const READ_ONLY_BADGE_TEXT = i18n.translate(
'xpack.observability.cases.badge.readOnly.text',
{
defaultMessage: 'Read only',
}
);
export const READ_ONLY_BADGE_TOOLTIP = i18n.translate(
'xpack.observability.cases.badge.readOnly.tooltip',
{
defaultMessage: 'Unable to create or edit cases',
}
);

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useEffect } from 'react';
import * as i18n from '../components/app/cases/translations';
import { useGetUserCasesPermissions } from '../hooks/use_get_user_cases_permissions';
import { useKibana } from '../utils/kibana_react';
/**
* This component places a read-only icon badge in the header if user only has read permissions
*/
export function useReadonlyHeader() {
const userPermissions = useGetUserCasesPermissions();
const chrome = useKibana().services.chrome;
// if the user is read only then display the glasses badge in the global navigation header
const setBadge = useCallback(() => {
if (userPermissions != null && !userPermissions.crud && userPermissions.read) {
chrome.setBadge({
text: i18n.READ_ONLY_BADGE_TEXT,
tooltip: i18n.READ_ONLY_BADGE_TOOLTIP,
iconType: 'glasses',
});
}
}, [chrome, userPermissions]);
useEffect(() => {
setBadge();
// remove the icon after the component unmounts
return () => {
chrome.setBadge();
};
}, [setBadge, chrome]);
}

View file

@ -10,35 +10,28 @@ import React from 'react';
import { AllCases } from '../../components/app/cases/all_cases';
import * as i18n from '../../components/app/cases/translations';
import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../../components/app/cases/callout';
import { CaseFeatureNoPermissions } from './feature_no_permissions';
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useReadonlyHeader } from '../../hooks/use_readonly_header';
import { casesBreadcrumbs } from './links';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
export const AllCasesPage = React.memo(() => {
const userPermissions = useGetUserCasesPermissions();
const { ObservabilityPageTemplate } = usePluginContext();
useReadonlyHeader();
useBreadcrumbs([casesBreadcrumbs.cases]);
return userPermissions == null || userPermissions?.read ? (
<>
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={permissionsReadOnlyErrorMessage.title}
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
/>
)}
<ObservabilityPageTemplate
pageHeader={{
pageTitle: <>{i18n.PAGE_TITLE}</>,
}}
>
<AllCases userCanCrud={userPermissions?.crud ?? false} />
</ObservabilityPageTemplate>
</>
<ObservabilityPageTemplate
pageHeader={{
pageTitle: <>{i18n.PAGE_TITLE}</>,
}}
>
<AllCases userCanCrud={userPermissions?.crud ?? false} />
</ObservabilityPageTemplate>
) : (
<CaseFeatureNoPermissions />
);

View file

@ -5,45 +5,35 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { CaseView } from '../../components/app/cases/case_view';
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
import { useKibana } from '../../utils/kibana_react';
import { CASES_APP_ID } from '../../components/app/cases/constants';
import { CaseCallOut, permissionsReadOnlyErrorMessage } from '../../components/app/cases/callout';
import { useReadonlyHeader } from '../../hooks/use_readonly_header';
export const CaseDetailsPage = React.memo(() => {
const {
application: { getUrlForApp, navigateToUrl },
} = useKibana().services;
const casesUrl = getUrlForApp(CASES_APP_ID);
const userPermissions = useGetUserCasesPermissions();
const { detailName: caseId, subCaseId } = useParams<{
detailName?: string;
subCaseId?: string;
}>();
useReadonlyHeader();
const casesUrl = getUrlForApp(CASES_APP_ID);
if (userPermissions != null && !userPermissions.read) {
navigateToUrl(casesUrl);
return null;
}
useEffect(() => {
if (userPermissions != null && !userPermissions.read) {
navigateToUrl(casesUrl);
}
}, [casesUrl, navigateToUrl, userPermissions]);
return caseId != null ? (
<>
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={permissionsReadOnlyErrorMessage.title}
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
/>
)}
<CaseView
caseId={caseId}
subCaseId={subCaseId}
userCanCrud={userPermissions?.crud ?? false}
/>
</>
<CaseView caseId={caseId} subCaseId={subCaseId} userCanCrud={userPermissions?.crud ?? false} />
) : null;
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import styled from 'styled-components';
import { EuiButtonEmpty } from '@elastic/eui';
@ -38,10 +38,12 @@ function ConfigureCasesPageComponent() {
const { formatUrl } = useFormatUrl(CASES_APP_ID);
const href = formatUrl(getCaseUrl());
useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.configure]);
if (userPermissions != null && !userPermissions.read) {
navigateToUrl(casesUrl);
return null;
}
useEffect(() => {
if (userPermissions != null && !userPermissions.read) {
navigateToUrl(casesUrl);
}
}, [casesUrl, userPermissions, navigateToUrl]);
return (
<ObservabilityPageTemplate

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import styled from 'styled-components';
import * as i18n from '../../components/app/cases/translations';
@ -40,10 +40,12 @@ export const CreateCasePage = React.memo(() => {
const { formatUrl } = useFormatUrl(CASES_APP_ID);
const href = formatUrl(getCaseUrl());
useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.create]);
if (userPermissions != null && !userPermissions.crud) {
navigateToUrl(casesUrl);
return null;
}
useEffect(() => {
if (userPermissions != null && !userPermissions.crud) {
navigateToUrl(casesUrl);
}
}, [casesUrl, navigateToUrl, userPermissions]);
return (
<ObservabilityPageTemplate

View file

@ -70,6 +70,9 @@ export enum SecurityPageName {
administration = 'administration',
}
/**
* The ID of the cases plugin
*/
export const CASES_APP_ID = `${APP_ID}:${SecurityPageName.case}`;
export const APP_OVERVIEW_PATH = `${APP_PATH}/overview`;

View file

@ -44,7 +44,7 @@ describe('Alerts timeline', () => {
});
it('should not allow user with read only privileges to attach alerts to cases', () => {
cy.get(ATTACH_ALERT_TO_CASE_BUTTON).first().should('be.disabled');
cy.get(ATTACH_ALERT_TO_CASE_BUTTON).should('not.exist');
});
});

View file

@ -5,18 +5,7 @@
* 2.0.
*/
import React from 'react';
import md5 from 'md5';
import * as i18n from './translations';
import { ErrorMessage } from './types';
export const permissionsReadOnlyErrorMessage: ErrorMessage = {
id: 'read-only-privileges-error',
title: i18n.READ_ONLY_FEATURE_TITLE,
description: <>{i18n.READ_ONLY_FEATURE_MSG}</>,
errorType: 'warning',
};
export const createCalloutId = (ids: string[], delimiter: string = '|'): string =>
md5(ids.join(delimiter));

View file

@ -7,21 +7,6 @@
import { i18n } from '@kbn/i18n';
export const READ_ONLY_FEATURE_TITLE = i18n.translate(
'xpack.securitySolution.cases.readOnlyFeatureTitle',
{
defaultMessage: 'You cannot open new or update existing cases',
}
);
export const READ_ONLY_FEATURE_MSG = i18n.translate(
'xpack.securitySolution.cases.readOnlyFeatureDescription',
{
defaultMessage:
'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.',
}
);
export const DISMISS_CALLOUT = i18n.translate(
'xpack.securitySolution.cases.dismissErrorsPushServiceCallOutTitle',
{

View file

@ -200,7 +200,7 @@ describe('AddToCaseAction', () => {
).toBeTruthy();
});
it('disabled when user does not have crud permissions', () => {
it('hides the icon when user does not have crud permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: true,
@ -212,8 +212,6 @@ describe('AddToCaseAction', () => {
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('isDisabled')
).toBeTruthy();
expect(wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).exists()).toBeFalsy();
});
});

View file

@ -208,19 +208,21 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
return (
<>
<ActionIconItem>
<EuiPopover
id="attachAlertToCasePanel"
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
repositionOnScroll
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
</ActionIconItem>
{userCanCrud && (
<ActionIconItem>
<EuiPopover
id="attachAlertToCasePanel"
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
repositionOnScroll
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
</ActionIconItem>
)}
{isCreateCaseFlyoutOpen && (
<CreateCaseFlyout
afterCaseCreated={attachAlertToCase}

View file

@ -12,7 +12,6 @@ import { useGetUserCasesPermissions } from '../../common/lib/kibana';
import { SpyRoute } from '../../common/utils/route/spy_routes';
import { AllCases } from '../components/all_cases';
import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
import { CaseFeatureNoPermissions } from './feature_no_permissions';
import { SecurityPageName } from '../../app/types';
@ -22,12 +21,6 @@ export const CasesPage = React.memo(() => {
return userPermissions == null || userPermissions?.read ? (
<>
<WrapperPage>
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={permissionsReadOnlyErrorMessage.title}
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
/>
)}
<AllCases userCanCrud={userPermissions?.crud ?? false} />
</WrapperPage>
<SpyRoute pageName={SecurityPageName.case} />

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { SecurityPageName } from '../../app/types';
@ -16,7 +16,6 @@ import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana';
import { getCaseUrl } from '../../common/components/link_to';
import { navTabs } from '../../app/home/home_navigations';
import { CaseView } from '../components/case_view';
import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
import { CASES_APP_ID } from '../../../common/constants';
export const CaseDetailsPage = React.memo(() => {
@ -30,20 +29,15 @@ export const CaseDetailsPage = React.memo(() => {
}>();
const search = useGetUrlSearch(navTabs.case);
if (userPermissions != null && !userPermissions.read) {
navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) });
return null;
}
useEffect(() => {
if (userPermissions != null && !userPermissions.read) {
navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) });
}
}, [navigateToApp, userPermissions, search]);
return caseId != null ? (
<>
<WrapperPage noPadding>
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
<CaseCallOut
title={permissionsReadOnlyErrorMessage.title}
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
/>
)}
<CaseView
caseId={caseId}
subCaseId={subCaseId}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
import styled from 'styled-components';
import { SecurityPageName } from '../../app/types';
@ -37,10 +37,13 @@ const ConfigureCasesPageComponent: React.FC = () => {
[search]
);
if (userPermissions != null && !userPermissions.read) {
navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) });
return null;
}
useEffect(() => {
if (userPermissions != null && !userPermissions.read) {
navigateToApp(CASES_APP_ID, {
path: getCaseUrl(search),
});
}
}, [navigateToApp, userPermissions, search]);
const HeaderWrapper = styled.div`
padding-top: ${({ theme }) => theme.eui.paddingSizes.l};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useEffect, useMemo } from 'react';
import { SecurityPageName } from '../../app/types';
import { getCaseUrl } from '../../common/components/link_to';
@ -25,6 +25,7 @@ export const CreateCasePage = React.memo(() => {
const {
application: { navigateToApp },
} = useKibana().services;
const backOptions = useMemo(
() => ({
href: getCaseUrl(search),
@ -34,12 +35,13 @@ export const CreateCasePage = React.memo(() => {
[search]
);
if (userPermissions != null && !userPermissions.crud) {
navigateToApp(CASES_APP_ID, {
path: getCaseUrl(search),
});
return null;
}
useEffect(() => {
if (userPermissions != null && !userPermissions.crud) {
navigateToApp(CASES_APP_ID, {
path: getCaseUrl(search),
});
}
}, [userPermissions, navigateToApp, search]);
return (
<>

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { BrowserRouter as Router } from 'react-router-dom';
import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana';
import { TestProviders } from '../../common/mock';
import { Case } from '.';
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
jest.mock('../../common/lib/kibana');
const mockedSetBadge = jest.fn();
describe('CaseContainerComponent', () => {
beforeEach(() => {
jest.clearAllMocks();
useKibanaMock().services.chrome.setBadge = mockedSetBadge;
});
it('does not display the readonly glasses badge when the user has write permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: true,
read: false,
});
mount(
<Router>
<TestProviders>
<Case />
</TestProviders>
</Router>
);
expect(mockedSetBadge).not.toBeCalled();
});
it('does not display the readonly glasses badge when the user has neither write nor read permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: false,
});
mount(
<Router>
<TestProviders>
<Case />
</TestProviders>
</Router>
);
expect(mockedSetBadge).not.toBeCalled();
});
it('does not display the readonly glasses badge when the user has null permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue(null);
mount(
<Router>
<TestProviders>
<Case />
</TestProviders>
</Router>
);
expect(mockedSetBadge).not.toBeCalled();
});
it('displays the readonly glasses badge read permissions but not write', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: true,
});
mount(
<Router>
<TestProviders>
<Case />
</TestProviders>
</Router>
);
expect(mockedSetBadge).toBeCalledTimes(1);
});
});

View file

@ -5,13 +5,15 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import * as i18n from './translations';
import { CaseDetailsPage } from './case_details';
import { CasesPage } from './case';
import { CreateCasePage } from './create_case';
import { ConfigureCasesPage } from './configure_cases';
import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana';
const casesPagePath = '';
const caseDetailsPagePath = `${casesPagePath}/:detailName`;
@ -21,30 +23,51 @@ const subCaseDetailsPagePathWithCommentId = `${subCaseDetailsPagePath}/:commentI
const createCasePagePath = `${casesPagePath}/create`;
const configureCasesPagePath = `${casesPagePath}/configure`;
const CaseContainerComponent: React.FC = () => (
<Switch>
<Route path={createCasePagePath}>
<CreateCasePage />
</Route>
<Route path={configureCasesPagePath}>
<ConfigureCasesPage />
</Route>
<Route exact path={subCaseDetailsPagePathWithCommentId}>
<CaseDetailsPage />
</Route>
<Route exact path={caseDetailsPagePathWithCommentId}>
<CaseDetailsPage />
</Route>
<Route exact path={subCaseDetailsPagePath}>
<CaseDetailsPage />
</Route>
<Route path={caseDetailsPagePath}>
<CaseDetailsPage />
</Route>
<Route strict exact path={casesPagePath}>
<CasesPage />
</Route>
</Switch>
);
const CaseContainerComponent: React.FC = () => {
const userPermissions = useGetUserCasesPermissions();
const chrome = useKibana().services.chrome;
useEffect(() => {
// if the user is read only then display the glasses badge in the global navigation header
if (userPermissions != null && !userPermissions.crud && userPermissions.read) {
chrome.setBadge({
text: i18n.READ_ONLY_BADGE_TEXT,
tooltip: i18n.READ_ONLY_BADGE_TOOLTIP,
iconType: 'glasses',
});
}
// remove the icon after the component unmounts
return () => {
chrome.setBadge();
};
}, [userPermissions, chrome]);
return (
<Switch>
<Route path={createCasePagePath}>
<CreateCasePage />
</Route>
<Route path={configureCasesPagePath}>
<ConfigureCasesPage />
</Route>
<Route exact path={subCaseDetailsPagePathWithCommentId}>
<CaseDetailsPage />
</Route>
<Route exact path={caseDetailsPagePathWithCommentId}>
<CaseDetailsPage />
</Route>
<Route exact path={subCaseDetailsPagePath}>
<CaseDetailsPage />
</Route>
<Route path={caseDetailsPagePath}>
<CaseDetailsPage />
</Route>
<Route strict exact path={casesPagePath}>
<CasesPage />
</Route>
</Switch>
);
};
export const Case = React.memo(CaseContainerComponent);

View file

@ -157,3 +157,24 @@ export const GO_TO_DOCUMENTATION = i18n.translate(
export const CONNECTORS = i18n.translate('xpack.securitySolution.cases.caseView.connectors', {
defaultMessage: 'External Incident Management System',
});
export const EDIT_CONNECTOR = i18n.translate(
'xpack.securitySolution.cases.caseView.editConnector',
{
defaultMessage: 'Change external incident management system',
}
);
export const READ_ONLY_BADGE_TEXT = i18n.translate(
'xpack.securitySolution.cases.badge.readOnly.text',
{
defaultMessage: 'Read only',
}
);
export const READ_ONLY_BADGE_TOOLTIP = i18n.translate(
'xpack.securitySolution.cases.badge.readOnly.tooltip',
{
defaultMessage: 'Unable to create or edit cases',
}
);

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { useGetUserCasesPermissions } from '../../../common/lib/kibana';
import { TestProviders } from '../../../common/mock';
import { HeaderGlobal } from '.';
jest.mock('../../../common/lib/kibana');
describe('HeaderGlobal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('does not display the cases tab when the user does not have read permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: false,
});
const wrapper = mount(
<TestProviders>
<HeaderGlobal />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeFalsy();
});
it('displays the cases tab when the user has read permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: true,
read: true,
});
const wrapper = mount(
<TestProviders>
<HeaderGlobal />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeTruthy();
});
});

View file

@ -19,7 +19,7 @@ import { MlPopover } from '../ml_popover/ml_popover';
import { SiemNavigation } from '../navigation';
import * as i18n from './translations';
import { useGetUrlSearch } from '../navigation/use_get_url_search';
import { useKibana } from '../../lib/kibana';
import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana';
import { APP_ID, ADD_DATA_PATH, APP_DETECTIONS_PATH } from '../../../../common/constants';
import { useGlobalHeaderPortal } from '../../hooks/use_global_header_portal';
import { LinkAnchor } from '../links';
@ -91,6 +91,18 @@ export const HeaderGlobal = React.memo(
},
[navigateToApp, search]
);
const hasCasesReadPermissions = useGetUserCasesPermissions()?.read;
// build a list of tabs to exclude
const tabsToExclude = new Set<string>([
...(hideDetectionEngine ? [SecurityPageName.detections] : []),
...(!hasCasesReadPermissions ? [SecurityPageName.case] : []),
]);
// include the tab if it is not in the set of excluded ones
const tabsToDisplay = pickBy((_, key) => !tabsToExclude.has(key), navTabs);
return (
<Wrapper ref={ref} $isFixed={isFixed}>
<WrapperContent $globalFullScreen={globalFullScreen ?? timelineFullScreen}>
@ -109,14 +121,7 @@ export const HeaderGlobal = React.memo(
</FlexItem>
<FlexItem component="nav">
<SiemNavigation
display="condensed"
navTabs={
hideDetectionEngine
? pickBy((_, key) => key !== SecurityPageName.detections, navTabs)
: navTabs
}
/>
<SiemNavigation display="condensed" navTabs={tabsToDisplay} />
</FlexItem>
</EuiFlexGroup>
</FlexItem>

View file

@ -13,7 +13,7 @@ import {
getCreateCaseUrl,
} from '../../../common/components/link_to/redirect_to_case';
import { useFormatUrl } from '../../../common/components/link_to';
import { useKibana } from '../../../common/lib/kibana';
import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana';
import { APP_ID, CASES_APP_ID } from '../../../../common/constants';
import { SecurityPageName } from '../../../app/types';
import { AllCasesNavProps } from '../../../cases/components/all_cases';
@ -26,6 +26,8 @@ const RecentCasesComponent = () => {
application: { navigateToApp },
} = useKibana().services;
const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false;
return casesUi.getRecentCases({
allCasesNavigation: {
href: formatUrl(getCaseUrl()),
@ -60,6 +62,7 @@ const RecentCasesComponent = () => {
});
},
},
hasWritePermissions,
maxCasesToShow: MAX_CASES_TO_SHOW,
owner: [APP_ID],
});

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import { TestProviders } from '../../../common/mock';
import { Sidebar } from './sidebar';
import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana';
import { casesPluginMock } from '../../../../../cases/public/mocks';
import { CasesUiStart } from '../../../../../cases/public';
jest.mock('../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.MockedFunction<typeof useKibana>;
describe('Sidebar', () => {
let casesMock: jest.Mocked<CasesUiStart>;
beforeEach(() => {
casesMock = casesPluginMock.createStartContract();
casesMock.getRecentCases.mockImplementation(() => <>{'test'}</>);
useKibanaMock.mockReturnValue(({
services: {
cases: casesMock,
application: {
// these are needed by the RecentCases component if it is rendered.
navigateToApp: jest.fn(),
getUrlForApp: jest.fn(() => ''),
},
},
} as unknown) as ReturnType<typeof useKibana>);
});
it('does not render the recently created cases section when the user does not have read permissions', async () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: false,
});
await waitFor(() =>
mount(
<TestProviders>
<Sidebar recentTimelinesFilterBy={'favorites'} setRecentTimelinesFilterBy={() => {}} />
</TestProviders>
)
);
expect(casesMock.getRecentCases).not.toHaveBeenCalled();
});
it('does render the recently created cases section when the user has read permissions', async () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: true,
});
await waitFor(() =>
mount(
<TestProviders>
<Sidebar recentTimelinesFilterBy={'favorites'} setRecentTimelinesFilterBy={() => {}} />
</TestProviders>
)
);
expect(casesMock.getRecentCases).toHaveBeenCalled();
});
});

View file

@ -18,6 +18,7 @@ import { SidebarHeader } from '../../../common/components/sidebar_header';
import * as i18n from '../../pages/translations';
import { RecentCases } from '../recent_cases';
import { useGetUserCasesPermissions } from '../../../common/lib/kibana';
const SidebarFlexGroup = styled(EuiFlexGroup)`
width: 305px;
@ -46,13 +47,20 @@ export const Sidebar = React.memo<{
[recentTimelinesFilterBy, setRecentTimelinesFilterBy]
);
// only render the recently created cases view if the user has at least read permissions
const hasCasesReadPermissions = useGetUserCasesPermissions()?.read;
return (
<SidebarFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<RecentCases />
</EuiFlexItem>
{hasCasesReadPermissions && (
<>
<EuiFlexItem grow={false}>
<RecentCases />
</EuiFlexItem>
<Spacer />
<Spacer />
</>
)}
<EuiFlexItem grow={false}>
<SidebarHeader title={i18n.RECENT_TIMELINES}>{recentTimelinesFilters}</SidebarHeader>

View file

@ -7,7 +7,7 @@
import React from 'react';
import { useKibana } from '../../../../common/lib/kibana';
import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana';
import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock';
import { TimelineId } from '../../../../../common/types/timeline';
import { useTimelineKpis } from '../../../containers/kpis';
@ -57,7 +57,7 @@ const defaultMocks = {
loading: false,
selectedPatterns: mockIndexNames,
};
describe('Timeline KPIs', () => {
describe('header', () => {
const mount = useMountAppended();
beforeEach(() => {
@ -75,86 +75,124 @@ describe('Timeline KPIs', () => {
jest.clearAllMocks();
});
describe('when the data is not loading and the response contains data', () => {
describe('AddToCaseButton', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]);
});
it('renders the component, labels and values succesfully', async () => {
it('renders the button when the user has write permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: true,
read: false,
});
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true);
// label
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('Processes')
);
// value
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('1')
expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeTruthy();
});
it('does not render the button when the user does not have write permissions', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
crud: false,
read: false,
});
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeFalsy();
});
});
describe('when the data is loading', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]);
describe('Timeline KPIs', () => {
describe('when the data is not loading and the response contains data', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]);
});
it('renders the component, labels and values successfully', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true);
// label
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('Processes')
);
// value
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('1')
);
});
});
it('renders a loading indicator for values', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('--')
);
});
});
describe('when the response is null and timeline is blank', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([false, null]);
describe('when the data is loading', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]);
});
it('renders a loading indicator for values', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('--')
);
});
});
it('renders labels and the default empty string', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('Processes')
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining(getEmptyValue())
);
});
});
describe('when the response is null and timeline is blank', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([false, null]);
});
it('renders labels and the default empty string', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={TimelineId.test} />
</TestProviders>
);
describe('when the response contains numbers larger than one thousand', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('Processes')
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining(getEmptyValue())
);
});
});
it('formats the numbers correctly', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('1k')
);
expect(wrapper.find('[data-test-subj="siem-timeline-user-kpi"]').first().text()).toEqual(
expect.stringContaining('1m')
);
expect(wrapper.find('[data-test-subj="siem-timeline-source-ip-kpi"]').first().text()).toEqual(
expect.stringContaining('1b')
);
expect(wrapper.find('[data-test-subj="siem-timeline-host-kpi"]').first().text()).toEqual(
expect.stringContaining('999')
);
describe('when the response contains numbers larger than one thousand', () => {
beforeEach(() => {
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]);
});
it('formats the numbers correctly', async () => {
const wrapper = mount(
<TestProviders>
<FlyoutHeader timelineId={TimelineId.test} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
expect.stringContaining('1k')
);
expect(wrapper.find('[data-test-subj="siem-timeline-user-kpi"]').first().text()).toEqual(
expect.stringContaining('1m')
);
expect(
wrapper.find('[data-test-subj="siem-timeline-source-ip-kpi"]').first().text()
).toEqual(expect.stringContaining('1b'));
expect(wrapper.find('[data-test-subj="siem-timeline-host-kpi"]').first().text()).toEqual(
expect.stringContaining('999')
);
});
});
});
});

View file

@ -35,7 +35,7 @@ import { TimerangeInput } from '../../../../../common/search_strategy';
import { AddToCaseButton } from '../add_to_case_button';
import { AddTimelineButton } from '../add_timeline_button';
import { SaveTimelineButton } from '../../timeline/header/save_timeline_button';
import { useKibana } from '../../../../common/lib/kibana';
import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana';
import { InspectButton } from '../../../../common/components/inspect';
import { useTimelineKpis } from '../../../containers/kpis';
import { esQuery } from '../../../../../../../../src/plugins/data/public';
@ -319,6 +319,8 @@ const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => {
filterQuery: combinedQueries?.filterQuery ?? '',
});
const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false;
return (
<StyledTimelineHeader alignItems="center" gutterSize="s">
<EuiFlexItem>
@ -350,9 +352,11 @@ const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => {
<EuiFlexItem grow={false}>
<AddToFavoritesButton timelineId={timelineId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddToCaseButton timelineId={timelineId} />
</EuiFlexItem>
{hasWritePermissions && (
<EuiFlexItem grow={false}>
<AddToCaseButton timelineId={timelineId} />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
</StyledTimelineHeader>

View file

@ -129,17 +129,23 @@ export interface PluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PluginStart {}
const securitySubPlugins = [
const casesSubPlugin = `${APP_ID}:${SecurityPageName.case}`;
/**
* Don't include cases here so that the sub feature can govern whether Cases is enabled in the navigation
*/
const securitySubPluginsNoCases = [
APP_ID,
`${APP_ID}:${SecurityPageName.overview}`,
`${APP_ID}:${SecurityPageName.detections}`,
`${APP_ID}:${SecurityPageName.hosts}`,
`${APP_ID}:${SecurityPageName.network}`,
`${APP_ID}:${SecurityPageName.timelines}`,
`${APP_ID}:${SecurityPageName.case}`,
`${APP_ID}:${SecurityPageName.administration}`,
];
const allSecuritySubPlugins = [...securitySubPluginsNoCases, casesSubPlugin];
export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, StartPlugins> {
private readonly logger: Logger;
private readonly config: ConfigType;
@ -305,7 +311,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
}),
order: 1100,
category: DEFAULT_APP_CATEGORIES.security,
app: [...securitySubPlugins, 'kibana'],
app: [...allSecuritySubPlugins, 'kibana'],
catalogue: ['securitySolution'],
management: {
insightsAndAlerting: ['triggersActions'],
@ -320,6 +326,9 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
groupType: 'mutually_exclusive',
privileges: [
{
// if the user is granted access to the cases feature than the global nav will show the cases
// sub plugin within the security solution navigation
app: [casesSubPlugin],
id: 'cases_all',
includeIn: 'all',
name: 'All',
@ -335,6 +344,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
},
},
{
app: [casesSubPlugin],
id: 'cases_read',
includeIn: 'read',
name: 'Read',
@ -356,7 +366,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
],
privileges: {
all: {
app: [...securitySubPlugins, 'kibana'],
app: [...securitySubPluginsNoCases, 'kibana'],
catalogue: ['securitySolution'],
api: ['securitySolution', 'lists-all', 'lists-read'],
savedObject: {
@ -377,7 +387,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
ui: ['show', 'crud'],
},
read: {
app: [...securitySubPlugins, 'kibana'],
app: [...securitySubPluginsNoCases, 'kibana'],
catalogue: ['securitySolution'],
api: ['securitySolution', 'lists-read'],
savedObject: {

View file

@ -138,11 +138,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it(`landing page shows disabled "Create new case" button`, async () => {
await PageObjects.common.navigateToActualUrl('observabilityCases');
await PageObjects.observability.expectCreateCaseButtonDisabled();
await PageObjects.observability.expectCreateCaseButtonMissing();
});
it(`shows read-only callout`, async () => {
await PageObjects.observability.expectReadOnlyCallout();
it(`shows read-only glasses badge`, async () => {
await PageObjects.observability.expectReadOnlyGlassesBadge();
});
it(`does not allow a case to be created`, async () => {
@ -151,7 +151,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
});
// expect redirection to observability cases landing
await PageObjects.observability.expectCreateCaseButtonDisabled();
await PageObjects.observability.expectCreateCaseButtonMissing();
});
it(`does not allow a case to be edited`, async () => {
@ -162,7 +162,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
shouldUseHashForSubUrl: false,
}
);
await PageObjects.observability.expectAddCommentButtonDisabled();
await PageObjects.observability.expectAddCommentButtonMissing();
});
});

View file

@ -20,14 +20,12 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro
expect(disabledAttr).to.be(null);
},
async expectCreateCaseButtonDisabled() {
const button = await testSubjects.find('createNewCaseBtn', 20000);
const disabledAttr = await button.getAttribute('disabled');
expect(disabledAttr).to.be('true');
async expectCreateCaseButtonMissing() {
await testSubjects.missingOrFail('createNewCaseBtn');
},
async expectReadOnlyCallout() {
await testSubjects.existOrFail('case-callout-e41900b01c9ef0fa81dd6ff326083fb3');
async expectReadOnlyGlassesBadge() {
await testSubjects.existOrFail('headerBadge');
},
async expectNoReadOnlyCallout() {
@ -44,10 +42,8 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro
expect(disabledAttr).to.be(null);
},
async expectAddCommentButtonDisabled() {
const button = await testSubjects.find('submit-comment', 20000);
const disabledAttr = await button.getAttribute('disabled');
expect(disabledAttr).to.be('true');
async expectAddCommentButtonMissing() {
await testSubjects.missingOrFail('submit-comment');
},
async expectForbidden() {