[EDR Workflows] Workflow Insights - Cypress (#204562)

This PR adds Cypress test coverage for the Defend Insights component and
enables RBAC and tier validation tests. It should be merged after the
feature flag is enabled - https://github.com/elastic/kibana/pull/204242

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Konrad Szwarc 2025-01-31 14:43:08 +01:00 committed by GitHub
parent f19636ea2a
commit ac67d91021
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 405 additions and 13 deletions

View file

@ -0,0 +1,167 @@
/*
* 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 {
createBedrockAIConnector,
deleteConnectors,
expectDefendInsightsApiToBeCalled,
expectPostDefendInsightsApiToBeCalled,
expectWorkflowInsightsApiToBeCalled,
interceptGetDefendInsightsApiCall,
interceptGetWorkflowInsightsApiCall,
interceptPostDefendInsightsApiCall,
setConnectorIdInLocalStorage,
stubDefendInsightsApiResponse,
stubPutWorkflowInsightsApiResponse,
stubWorkflowInsightsApiResponse,
validateUserGotRedirectedToEndpointDetails,
validateUserGotRedirectedToTrustedApps,
} from '../../tasks/insights';
import { loadEndpointDetailsFlyout, workflowInsightsSelectors } from '../../screens/insights';
import { indexEndpointHosts, type CyIndexEndpointHosts } from '../../tasks/index_endpoint_hosts';
import { login } from '../../tasks/login';
const {
addConnectorButtonExists,
insightsComponentExists,
chooseConnectorButtonExistsWithLabel,
selectConnector,
clickScanButton,
insightsResultExists,
insightsEmptyResultsCalloutDoesNotExist,
clickInsightsResultRemediationButton,
scanButtonShouldBe,
clickTrustedAppFormSubmissionButton,
} = workflowInsightsSelectors;
describe(
'Workflow Insights',
{
tags: [
'@ess',
'@serverless',
// skipped on MKI since feature flags are not supported there
'@skipInServerlessMKI',
],
env: {
ftrConfig: {
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify(['defendInsights'])}`,
],
},
},
},
() => {
const connectorName = 'TEST-CONNECTOR';
let loadedEndpoint: CyIndexEndpointHosts;
let endpointId: string;
// Since the endpoint is used only for displaying details flyout, we can use the same endpoint for all tests
before(() => {
indexEndpointHosts({ count: 1 }).then((indexedEndpoint) => {
loadedEndpoint = indexedEndpoint;
endpointId = indexedEndpoint.data.hosts[0].agent.id;
});
});
after(() => {
if (loadedEndpoint) {
loadedEndpoint.cleanup();
}
});
beforeEach(() => {
login();
});
it('should render Insights section on endpoint flyout with option to define connectors', () => {
loadEndpointDetailsFlyout(endpointId);
insightsComponentExists();
addConnectorButtonExists();
});
describe('Workflow Insights first visit', () => {
let connectorId: string | undefined;
beforeEach(() => {
createBedrockAIConnector(connectorName).then((response) => {
connectorId = response.body.id;
});
});
afterEach(() => {
deleteConnectors();
connectorId = undefined;
});
it('should properly initialize workflow insights for the first time', () => {
interceptGetWorkflowInsightsApiCall();
interceptGetDefendInsightsApiCall();
loadEndpointDetailsFlyout(endpointId);
expectWorkflowInsightsApiToBeCalled();
expectDefendInsightsApiToBeCalled();
chooseConnectorButtonExistsWithLabel('Select a connector');
selectConnector(connectorId);
chooseConnectorButtonExistsWithLabel(connectorName);
scanButtonShouldBe('enabled');
});
});
describe('Workflow Insights consequent visit', () => {
beforeEach(() => {
createBedrockAIConnector(connectorName).then(setConnectorIdInLocalStorage);
});
afterEach(() => {
deleteConnectors();
});
it('should properly initialize workflow insights with a connector already defined', () => {
loadEndpointDetailsFlyout(endpointId);
chooseConnectorButtonExistsWithLabel(connectorName);
scanButtonShouldBe('enabled');
});
it('should disable Scan button if there is an ongoing scan', () => {
stubDefendInsightsApiResponse();
loadEndpointDetailsFlyout(endpointId);
scanButtonShouldBe('disabled');
});
it('should trigger insight generation on Scan button click', () => {
interceptPostDefendInsightsApiCall();
interceptGetWorkflowInsightsApiCall();
interceptGetDefendInsightsApiCall();
loadEndpointDetailsFlyout(endpointId);
clickScanButton();
expectPostDefendInsightsApiToBeCalled();
expectWorkflowInsightsApiToBeCalled();
expectDefendInsightsApiToBeCalled();
});
it('should render existing Insights', () => {
stubWorkflowInsightsApiResponse(endpointId);
loadEndpointDetailsFlyout(endpointId);
insightsResultExists();
insightsEmptyResultsCalloutDoesNotExist();
clickInsightsResultRemediationButton();
validateUserGotRedirectedToTrustedApps();
stubPutWorkflowInsightsApiResponse();
clickTrustedAppFormSubmissionButton();
validateUserGotRedirectedToEndpointDetails(endpointId);
});
});
}
);

View file

@ -15,8 +15,7 @@ import {
import { closeAllToasts } from '../../tasks/toasts';
import { login, ROLE } from '../../tasks/login';
// Unskip when defendInsights assistant feature is enabled by default
describe.skip(
describe(
'When defining a kibana role for Endpoint security access',
{
env: {

View file

@ -9,7 +9,7 @@ import { fetchRunningDefendInsights, fetchWorkflowInsights } from '../../../../t
import { login, ROLE } from '../../../../tasks/login';
// Unskip when defendInsights assistant feature is enabled by default
describe.skip(
describe(
'Workflow Insights APIs',
{
tags: ['@serverless', '@skipInServerlessMKI'], // remove @skipInServerlessMKI once changes are merged

View file

@ -14,7 +14,7 @@ import {
import { login, ROLE } from '../../../../tasks/login';
// Unskip when defendInsights assistant feature is enabled by default
describe.skip(
describe(
'Workflow Insights APIs',
{
tags: ['@serverless', '@skipInServerlessMKI'], // remove @skipInServerlessMKI once changes are merged

View file

@ -12,8 +12,7 @@ import { login, ROLE } from '../../../../tasks/login';
const { insightsComponentExists, addConnectorButtonExists } = workflowInsightsSelectors;
// Unskip when defendInsights assistant feature is enabled by default
describe.skip(
describe(
'Endpoint details',
{
tags: [

View file

@ -12,8 +12,7 @@ import { login, ROLE } from '../../../../tasks/login';
const { insightsComponentDoesntExist } = workflowInsightsSelectors;
// Unskip when defendInsights assistant feature is enabled by default
describe.skip(
describe(
'Endpoint details',
{
tags: [

View file

@ -14,7 +14,25 @@ export const loadEndpointDetailsFlyout = (endpointId: string) =>
export const workflowInsightsSelectors = {
insightsComponentExists: () => cy.getByTestSubj('endpointDetailsInsightsWrapper').should('exist'),
addConnectorButtonExists: () => cy.getByTestSubj('addNewConnectorButton').should('exist'),
chooseConnectorButtonExistsWithLabel: (label: string) =>
cy.getByTestSubj('connector-selector').contains(label),
selectConnector: (connectorId?: string) => {
cy.getByTestSubj('connector-selector').click();
if (connectorId) return cy.getByTestSubj(connectorId).click();
},
selectScanButton: () => cy.getByTestSubj('workflowInsightsScanButton'),
scanButtonShouldBe: (state: 'enabled' | 'disabled') =>
workflowInsightsSelectors.selectScanButton().should(`be.${state}`),
clickScanButton: () => workflowInsightsSelectors.selectScanButton().click(),
insightsResultExists: (index = 0) =>
cy.getByTestSubj(`workflowInsightsResult-${index}`).should('exist'),
clickInsightsResultRemediationButton: (index = 0) =>
cy.getByTestSubj(`workflowInsightsResult-${index}-remediation`).click(),
insightsEmptyResultsCalloutDoesNotExist: () =>
cy.getByTestSubj('workflowInsightsEmptyResultsCallout').should('not.exist'),
clickTrustedAppFormSubmissionButton: () =>
cy.getByTestSubj('trustedAppsListPage-flyout-submitButton').click(),
insightsComponentDoesntExist: () =>
cy.getByTestSubj('endpointDetailsInsightsWrapper').should('not.exist'),
addConnectorButtonExists: () => cy.getByTestSubj('addNewConnectorButton').should('exist'),
};

View file

@ -4,15 +4,211 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
AllConnectorsResponse,
ConnectorResponse,
} from '@kbn/actions-plugin/common/routes/connector/response';
import { DEFEND_INSIGHTS } from '@kbn/elastic-assistant-common';
import { ActionType } from '../../../../common/endpoint/types/workflow_insights';
import { request } from './common';
import { ActionType } from '../../../../common/endpoint/types/workflow_insights';
import {
WORKFLOW_INSIGHTS_ROUTE,
WORKFLOW_INSIGHTS_UPDATE_ROUTE,
} from '../../../../common/endpoint/constants';
const INTERNAL_CLOUD_CONNECTORS = ['Elastic-Cloud-SMTP'];
export const createBedrockAIConnector = (connectorName?: string) =>
request<ConnectorResponse>({
method: 'POST',
url: '/api/actions/connector',
body: {
connector_type_id: '.bedrock',
secrets: {
accessKey: '123',
secret: '123',
},
config: {
apiUrl: 'https://bedrock.com',
},
name: connectorName || 'Bedrock cypress test e2e connector',
},
});
export const getConnectors = () =>
request<AllConnectorsResponse[]>({
method: 'GET',
url: 'api/actions/connectors',
});
export const deleteConnectors = () => {
getConnectors().then(($response) => {
if ($response.body.length > 0) {
const ids = $response.body.map((connector) => {
return connector.id;
});
ids.forEach((id) => {
if (!INTERNAL_CLOUD_CONNECTORS.includes(id)) {
request({
method: 'DELETE',
url: `api/actions/connector/${id}`,
});
}
});
}
});
};
export const setConnectorIdInLocalStorage = (res: { body: { id: string } }) => {
window.localStorage.setItem(
`elasticAssistantDefault.defendInsights.default.connectorId`,
`"${res.body.id}"`
);
};
export const validateUserGotRedirectedToTrustedApps = () => {
cy.url().should('include', '/app/security/administration/trusted_apps');
};
export const validateUserGotRedirectedToEndpointDetails = (endpointId: string) => {
cy.url().should(
'include',
`/app/security/administration/endpoints?selected_endpoint=${endpointId}&show=details`
);
};
export const interceptGetWorkflowInsightsApiCall = () => {
cy.intercept({ method: 'GET', url: '**/internal/api/endpoint/workflow_insights**' }).as(
'getWorkflowInsights'
);
};
export const expectWorkflowInsightsApiToBeCalled = () => {
cy.wait('@getWorkflowInsights', { timeout: 30 * 1000 });
};
export const interceptGetDefendInsightsApiCall = () => {
cy.intercept({ method: 'GET', url: '**/internal/elastic_assistant/defend_insights**' }).as(
'getDefendInsights'
);
};
export const expectDefendInsightsApiToBeCalled = () => {
cy.wait('@getDefendInsights', { timeout: 30 * 1000 });
};
export const interceptPostDefendInsightsApiCall = () => {
cy.intercept({ method: 'POST', url: '**/internal/elastic_assistant/defend_insights**' }).as(
'createInsights'
);
};
export const expectPostDefendInsightsApiToBeCalled = () => {
cy.wait('@createInsights', { timeout: 30 * 1000 });
};
export const stubPutWorkflowInsightsApiResponse = () => {
cy.intercept('PUT', '**/internal/api/endpoint/workflow_insights/**', (req) => {
req.continue((res) => {
return res.send(200, {
status: 'ok',
});
});
});
};
export const stubDefendInsightsApiResponse = () => {
cy.intercept('GET', '**/internal/elastic_assistant/defend_insights?status=running**', (req) => {
req.continue((res) => {
return res.send(200, {
data: [
{
timestamp: '2024-12-16T13:44:52.633Z',
id: 'd95561cb-1f75-4a6c-8be4-cb7529ddd5e0',
backingIndex:
'.ds-.kibana-elastic-ai-assistant-defend-insights-default-2024.12.16-000001',
createdAt: '2024-12-16T13:44:52.633Z',
updatedAt: '2024-12-16T13:44:52.633Z',
lastViewedAt: '2024-12-16T13:44:53.866Z',
users: [
{
id: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
name: 'elastic',
},
],
namespace: 'default',
status: 'running',
apiConfig: {
connectorId: 'db760d65-6722-4646-955f-fbdc9851df86',
actionTypeId: '.bedrock',
},
endpointIds: ['33581c4f-bef1-4162-9809-4c208e2e1991'],
insightType: 'incompatible_antivirus',
insights: [],
generationIntervals: [],
averageIntervalMs: 0,
},
],
});
});
});
};
export const stubWorkflowInsightsApiResponse = (endpointId: string) => {
cy.intercept('GET', '**/internal/api/endpoint/workflow_insights**', (req) => {
req.continue((res) => {
return res.send(200, [
{
remediation: {
exception_list_items: [
{
entries: [
{
field: 'process.executable.caseless',
type: 'match',
value: '/usr/bin/clamscan',
operator: 'included',
},
],
list_id: 'endpoint_trusted_apps',
name: 'ClamAV',
os_types: ['linux'],
description: 'Suggested by Security Workflow Insights',
tags: ['policy:all'],
},
],
},
metadata: {
notes: {
llm_model: '',
},
},
'@timestamp': '2024-12-16T13:45:03.055Z',
action: {
type: 'refreshed',
timestamp: '2024-12-16T13:45:03.055Z',
},
source: {
data_range_end: '2024-12-17T13:45:03.055Z',
id: 'db760d65-6722-4646-955f-fbdc9851df86',
type: 'llm-connector',
data_range_start: '2024-12-16T13:45:03.055Z',
},
message: 'Incompatible antiviruses detected',
category: 'endpoint',
type: 'incompatible_antivirus',
value: 'ClamAV',
target: {
ids: [endpointId],
type: 'endpoint',
},
id: 'CMm3z5MBPx3JiizjFx5g',
},
]);
});
});
};
export const triggerRunningDefendInsights = () => {
return request({
method: 'POST',

View file

@ -36,5 +36,6 @@
"@kbn/spaces-plugin",
"@kbn/test-suites-xpack/security_solution_cypress/cypress",
"@kbn/elastic-assistant-common",
"@kbn/actions-plugin",
]
}

View file

@ -109,7 +109,11 @@ export const WorkflowInsightsResults = ({
const insights = useMemo(() => {
if (showEmptyResultsCallout) {
return (
<CustomEuiCallOut onDismiss={hideEmptyStateCallout} color={'success'}>
<CustomEuiCallOut
onDismiss={hideEmptyStateCallout}
color={'success'}
data-test-subj={'workflowInsightsEmptyResultsCallout'}
>
{WORKFLOW_INSIGHTS.issues.emptyResults}
</CustomEuiCallOut>
);
@ -120,7 +124,13 @@ export const WorkflowInsightsResults = ({
WORKFLOW_INSIGHTS.issues.remediationButton;
return (
<EuiPanel paddingSize="m" hasShadow={false} hasBorder key={index}>
<EuiPanel
paddingSize="m"
hasShadow={false}
hasBorder
key={index}
data-test-subj={`workflowInsightsResult-${index}`}
>
<EuiFlexGroup alignItems={'center'} gutterSize={'m'}>
<EuiFlexItem grow={false}>
<EuiIcon type="warning" size="l" color="warning" />
@ -148,6 +158,7 @@ export const WorkflowInsightsResults = ({
position={'top'}
>
<EuiButtonIcon
data-test-subj={`workflowInsightsResult-${index}-remediation`}
isDisabled={!canWriteTrustedApplications}
aria-label={ariaLabel}
iconType="popout"

View file

@ -84,6 +84,7 @@ export const WorkflowInsightsScanSection = ({
const button = (
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="workflowInsightsScanButton"
size="s"
isLoading={isScanButtonDisabled}
isDisabled={!canWriteWorkflowInsights}

View file

@ -33,6 +33,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
// API Keys is enabled at the top level
'xpack.security.enabled=true',
'http.host=0.0.0.0',
'xpack.ml.enabled=false',
],
},