[Cloud Security] [Misconfigurations] Test coverage for the Alerts workflow (#166788)

This commit is contained in:
Paulo Henrique 2023-09-25 09:33:13 -07:00 committed by GitHub
parent f9c35e4971
commit 778dbf26b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 468 additions and 10 deletions

View file

@ -0,0 +1,138 @@
/*
* 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 { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DetectionRuleCounter } from './detection_rule_counter';
import { TestProvider } from '../test/test_provider';
import { useFetchDetectionRulesByTags } from '../common/api/use_fetch_detection_rules_by_tags';
import { useFetchDetectionRulesAlertsStatus } from '../common/api/use_fetch_detection_rules_alerts_status';
import { RuleResponse } from '../common/types';
jest.mock('../common/api/use_fetch_detection_rules_by_tags', () => ({
useFetchDetectionRulesByTags: jest.fn(),
}));
jest.mock('../common/api/use_fetch_detection_rules_alerts_status', () => ({
useFetchDetectionRulesAlertsStatus: jest.fn(),
}));
describe('DetectionRuleCounter', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
it('should render loading skeleton when both rules and alerts are loading', () => {
(useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({
data: undefined,
isLoading: true,
});
(useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({
data: undefined,
isLoading: true,
});
const { getByTestId } = render(
<TestProvider>
<DetectionRuleCounter tags={['tag1', 'tag2']} createRuleFn={jest.fn()} />
</TestProvider>
);
const skeletonText = getByTestId('csp:detection-rule-counter-loading');
expect(skeletonText).toBeInTheDocument();
});
it('should render create rule link when no rules exist', () => {
(useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({
data: { total: 0 },
isLoading: false,
});
(useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
isFetching: false,
});
const { getByText, getByTestId } = render(
<TestProvider>
<DetectionRuleCounter tags={['tag1', 'tag2']} createRuleFn={jest.fn()} />
</TestProvider>
);
const createRuleLink = getByTestId('csp:findings-flyout-create-detection-rule-link');
expect(createRuleLink).toBeInTheDocument();
expect(getByText('Create a detection rule')).toBeInTheDocument();
});
it('should render alert and rule count when rules exist', () => {
(useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({
data: { total: 5 },
isLoading: false,
});
(useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({
data: { total: 10 },
isLoading: false,
isFetching: false,
});
const { getByText, getByTestId } = render(
<TestProvider>
<DetectionRuleCounter tags={['tag1', 'tag2']} createRuleFn={jest.fn()} />
</TestProvider>
);
const alertCountLink = getByTestId('csp:findings-flyout-alert-count');
const ruleCountLink = getByTestId('csp:findings-flyout-detection-rule-count');
expect(alertCountLink).toBeInTheDocument();
expect(getByText(/10 alerts/i)).toBeInTheDocument();
expect(ruleCountLink).toBeInTheDocument();
expect(getByText(/5 detection rules/i)).toBeInTheDocument();
});
it('should show loading spinner when creating a rule', async () => {
(useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({
data: { total: 0 },
isLoading: false,
});
(useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
isFetching: false,
});
const createRuleFn = jest.fn(() => Promise.resolve({} as RuleResponse));
const { getByTestId, queryByTestId } = render(
<TestProvider>
<DetectionRuleCounter tags={['tag1', 'tag2']} createRuleFn={createRuleFn} />
</TestProvider>
);
// Trigger createDetectionRuleOnClick
const createRuleLink = getByTestId('csp:findings-flyout-create-detection-rule-link');
userEvent.click(createRuleLink);
const loadingSpinner = getByTestId('csp:findings-flyout-detection-rule-counter-loading');
expect(loadingSpinner).toBeInTheDocument();
(useFetchDetectionRulesByTags as jest.Mock).mockReturnValue({
data: { total: 1 },
isLoading: false,
});
(useFetchDetectionRulesAlertsStatus as jest.Mock).mockReturnValue({
data: { total: 0 },
isLoading: false,
isFetching: false,
});
// Wait for the loading spinner to disappear
await waitFor(() => {
expect(queryByTestId('csp:findings-flyout-detection-rule-counter-loading')).toBeNull();
});
});
});

View file

@ -68,7 +68,12 @@ export const DetectionRuleCounter = ({ tags, createRuleFn }: DetectionRuleCounte
}, [createRuleFn, http, notifications, queryClient]);
return (
<EuiSkeletonText lines={1} size="m" isLoading={ruleIsLoading || alertsIsLoading}>
<EuiSkeletonText
data-test-subj="csp:detection-rule-counter-loading"
lines={1}
size="m"
isLoading={ruleIsLoading || alertsIsLoading}
>
{rulesData?.total === 0 ? (
<>
<EuiText size="s">
@ -78,11 +83,17 @@ export const DetectionRuleCounter = ({ tags, createRuleFn }: DetectionRuleCounte
id="xpack.csp.findingsFlyout.alerts.creatingRule"
defaultMessage="Creating detection rule"
/>{' '}
<EuiLoadingSpinner size="s" />
<EuiLoadingSpinner
size="s"
data-test-subj="csp:findings-flyout-detection-rule-counter-loading"
/>
</>
) : (
<>
<EuiLink onClick={createDetectionRuleOnClick}>
<EuiLink
onClick={createDetectionRuleOnClick}
data-test-subj="csp:findings-flyout-create-detection-rule-link"
>
<FormattedMessage
id="xpack.csp.findingsFlyout.alerts.createRuleAction"
defaultMessage="Create a detection rule"
@ -98,7 +109,7 @@ export const DetectionRuleCounter = ({ tags, createRuleFn }: DetectionRuleCounte
</>
) : (
<>
<EuiLink onClick={alertsPageNavigation}>
<EuiLink onClick={alertsPageNavigation} data-test-subj="csp:findings-flyout-alert-count">
<FormattedMessage
id="xpack.csp.findingsFlyout.alerts.alertCount"
defaultMessage="{alertCount, plural, one {# alert} other {# alerts}}"
@ -109,7 +120,10 @@ export const DetectionRuleCounter = ({ tags, createRuleFn }: DetectionRuleCounte
id="xpack.csp.findingsFlyout.alerts.detectedBy"
defaultMessage="detected by"
/>{' '}
<EuiLink onClick={rulePageNavigation}>
<EuiLink
onClick={rulePageNavigation}
data-test-subj="csp:findings-flyout-detection-rule-count"
>
<FormattedMessage
id="xpack.csp.findingsFlyout.alerts.detectionRuleCount"
defaultMessage="{ruleCount, plural, one {# detection rule} other {# detection rules}}"

View file

@ -40,10 +40,11 @@ export const showSuccessToast = (
toastLifeTimeMs: 10000,
color: 'success',
iconType: '',
'data-test-subj': 'csp:toast-success',
text: toMountPoint(
<div>
<EuiText size="m">
<strong>{ruleResponse.name}</strong>
<strong data-test-subj="csp:toast-success-title">{ruleResponse.name}</strong>
{` `}
<FormattedMessage
id="xpack.csp.flyout.ruleCreatedToastTitle"
@ -58,7 +59,11 @@ export const showSuccessToast = (
</EuiText>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton size="s" href={http.basePath.prepend(RULE_PAGE_PATH + ruleResponse.id)}>
<EuiButton
data-test-subj="csp:toast-success-link"
size="s"
href={http.basePath.prepend(RULE_PAGE_PATH + ruleResponse.id)}
>
<FormattedMessage
id="xpack.csp.flyout.ruleCreatedToastViewRuleButton"
defaultMessage="View rule"

View file

@ -55,14 +55,20 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider
refresh: true,
}),
]),
add: async <T>(findingsMock: T[]) => {
add: async <
T extends {
'@timestamp'?: string;
}
>(
findingsMock: T[]
) => {
await Promise.all([
...findingsMock.map((finding) =>
es.index({
index: FINDINGS_INDEX,
body: {
...finding,
'@timestamp': new Date().toISOString(),
'@timestamp': finding['@timestamp'] ?? new Date().toISOString(),
},
refresh: true,
})
@ -72,7 +78,7 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider
index: FINDINGS_LATEST_INDEX,
body: {
...finding,
'@timestamp': new Date().toISOString(),
'@timestamp': finding['@timestamp'] ?? new Date().toISOString(),
},
refresh: true,
})
@ -81,6 +87,20 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider
},
};
const detectionRuleApi = {
remove: async () => {
await supertest
.post('/api/detection_engine/rules/_bulk_action?dry_run=false')
.set('kbn-xsrf', 'true')
.set(ELASTIC_HTTP_VERSION_HEADER, '2023-10-31')
.send({
action: 'delete',
query: '',
})
.expect(200);
},
};
const distributionBar = {
filterBy: async (type: 'passed' | 'failed') =>
testSubjects.click(type === 'failed' ? 'distribution_bar_failed' : 'distribution_bar_passed'),
@ -203,6 +223,12 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider
await nonStaleElement.click();
}
},
async openFlyoutAt(rowIndex: number) {
const table = await this.getElement();
const flyoutButton = await table.findAllByTestSubject('findings_table_expand_column');
await flyoutButton[rowIndex].click();
},
});
const navigateToLatestFindingsPage = async () => {
@ -247,6 +273,41 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider
const notInstalledVulnerabilities = createNotInstalledObject('cnvm-integration-not-installed');
const notInstalledCSP = createNotInstalledObject('cloud_posture_page_package_not_installed');
const createFlyoutObject = (tableTestSubject: string) => ({
async getElement() {
return await testSubjects.find(tableTestSubject);
},
async clickTakeActionButton() {
const element = await this.getElement();
const button = await element.findByCssSelector('[data-test-subj="csp:take_action"] button');
await button.click();
return button;
},
async clickTakeActionCreateRuleButton() {
await this.clickTakeActionButton();
const button = await testSubjects.find('csp:create_rule');
await button.click();
return button;
},
async getVisibleText(testSubj: string) {
const element = await this.getElement();
return await (await element.findByTestSubject(testSubj)).getVisibleText();
},
});
const misconfigurationsFlyout = createFlyoutObject('findings_flyout');
const toastMessage = async (testSubj = 'csp:toast-success') => ({
async getElement() {
return await testSubjects.find(testSubj);
},
async clickToastMessageLink(linkTestSubj = 'csp:toast-success-link') {
const element = await this.getElement();
const link = await element.findByTestSubject(linkTestSubj);
await link.click();
},
});
return {
navigateToLatestFindingsPage,
navigateToVulnerabilities,
@ -259,5 +320,8 @@ export function FindingsPageProvider({ getService, getPageObjects }: FtrProvider
index,
waitForPluginInitialized,
distributionBar,
misconfigurationsFlyout,
toastMessage,
detectionRuleApi,
};
}

View file

@ -0,0 +1,236 @@
/*
* 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 expect from '@kbn/expect';
import Chance from 'chance';
import type { FtrProviderContext } from '../ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getPageObjects, getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const retry = getService('retry');
const pageObjects = getPageObjects(['common', 'findings', 'header']);
const chance = new Chance();
// We need to use a dataset for the tests to run
const data = [
{
resource: { id: chance.guid(), name: `kubelet`, sub_type: 'lower case sub type' },
result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' },
rule: {
tags: ['CIS', 'CIS K8S'],
rationale: 'rationale steps for rule 1.1',
references: '1. https://elastic.co/rules/1.1',
name: 'Upper case rule name',
section: 'Upper case section',
benchmark: {
rule_number: '1.1',
id: 'cis_k8s',
posture_type: 'kspm',
name: 'CIS Kubernetes V1.23',
version: 'v1.0.0',
remediation: 'remediation guide',
},
type: 'process',
},
cluster_id: 'Upper case cluster id',
},
{
'@timestamp': '2023-09-10T14:01:00.000Z',
resource: { id: chance.guid(), name: `Pod`, sub_type: 'Upper case sub type' },
result: { evaluation: chance.integer() % 2 === 0 ? 'passed' : 'failed' },
rule: {
tags: ['CIS', 'CIS K8S'],
rationale: 'rationale steps',
references: '1. https://elastic.co',
name: 'lower case rule name',
section: 'Another upper case section',
benchmark: {
rule_number: '1.2',
id: 'cis_k8s',
posture_type: 'kspm',
name: 'CIS Kubernetes V1.23',
version: 'v1.0.0',
remediation: 'remediation guide',
},
type: 'process',
},
cluster_id: 'Another Upper case cluster id',
},
{
'@timestamp': '2023-09-10T14:02:00.000Z',
resource: { id: chance.guid(), name: `process`, sub_type: 'another lower case type' },
result: { evaluation: 'passed' },
rule: {
tags: ['CIS', 'CIS K8S'],
rationale: 'rationale steps',
references: '1. https://elastic.co',
name: 'Another upper case rule name',
section: 'lower case section',
benchmark: {
rule_number: '1.3',
id: 'cis_k8s',
posture_type: 'kspm',
name: 'CIS Kubernetes V1.23',
version: 'v1.0.0',
remediation: 'remediation guide',
},
type: 'process',
},
cluster_id: 'lower case cluster id',
},
{
'@timestamp': '2023-09-10T14:03:00.000Z',
resource: { id: chance.guid(), name: `process`, sub_type: 'Upper case type again' },
result: { evaluation: 'failed' },
rule: {
tags: ['CIS', 'CIS K8S'],
rationale: 'rationale steps',
references: '1. https://elastic.co',
name: 'some lower case rule name',
section: 'another lower case section',
benchmark: {
rule_number: '1.4',
id: 'cis_k8s',
posture_type: 'kspm',
name: 'CIS Kubernetes V1.23',
version: 'v1.0.0',
remediation: 'remediation guide',
},
type: 'process',
},
cluster_id: 'another lower case cluster id',
},
];
const ruleName1 = data[0].rule.name;
describe('Findings Page - Alerts', function () {
this.tags(['cloud_security_posture_findings_alerts']);
let findings: typeof pageObjects.findings;
let latestFindingsTable: typeof findings.latestFindingsTable;
let misconfigurationsFlyout: typeof findings.misconfigurationsFlyout;
before(async () => {
findings = pageObjects.findings;
latestFindingsTable = findings.latestFindingsTable;
misconfigurationsFlyout = findings.misconfigurationsFlyout;
// Before we start any test we must wait for cloud_security_posture plugin to complete its initialization
await findings.waitForPluginInitialized();
// Prepare mocked findings
await findings.index.remove();
await findings.index.add(data);
});
after(async () => {
await findings.index.remove();
await findings.detectionRuleApi.remove();
});
beforeEach(async () => {
await findings.detectionRuleApi.remove();
await findings.navigateToLatestFindingsPage();
await retry.waitFor(
'Findings table to be loaded',
async () => (await latestFindingsTable.getRowsCount()) === data.length
);
pageObjects.header.waitUntilLoadingHasFinished();
});
describe('Create detection rule', () => {
it('Creates a detection rule from the Take Action button and navigates to rule page', async () => {
await latestFindingsTable.openFlyoutAt(0);
await misconfigurationsFlyout.clickTakeActionCreateRuleButton();
expect(
await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-alert-count')
).to.be('0 alerts');
expect(
await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-detection-rule-count')
).to.be('1 detection rule');
const toastMessage = await (await findings.toastMessage()).getElement();
expect(toastMessage).to.be.ok();
const toastMessageTitle = await toastMessage.findByTestSubject('csp:toast-success-title');
expect(await toastMessageTitle.getVisibleText()).to.be(ruleName1);
await (await findings.toastMessage()).clickToastMessageLink();
const rulePageTitle = await testSubjects.find('header-page-title');
expect(await rulePageTitle.getVisibleText()).to.be(ruleName1);
});
it('Creates a detection rule from the Alerts section and navigates to rule page', async () => {
await latestFindingsTable.openFlyoutAt(0);
const flyout = await misconfigurationsFlyout.getElement();
await (
await flyout.findByTestSubject('csp:findings-flyout-create-detection-rule-link')
).click();
expect(
await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-alert-count')
).to.be('0 alerts');
expect(
await misconfigurationsFlyout.getVisibleText('csp:findings-flyout-detection-rule-count')
).to.be('1 detection rule');
const toastMessage = await (await findings.toastMessage()).getElement();
expect(toastMessage).to.be.ok();
const toastMessageTitle = await toastMessage.findByTestSubject('csp:toast-success-title');
expect(await toastMessageTitle.getVisibleText()).to.be(ruleName1);
await (await findings.toastMessage()).clickToastMessageLink();
const rulePageTitle = await testSubjects.find('header-page-title');
expect(await rulePageTitle.getVisibleText()).to.be(ruleName1);
});
});
describe('Rule details', () => {
it('The rule page contains the expected matching data', async () => {
await latestFindingsTable.openFlyoutAt(0);
await misconfigurationsFlyout.clickTakeActionCreateRuleButton();
await (await findings.toastMessage()).clickToastMessageLink();
const rulePageDescription = await testSubjects.find(
'stepAboutRuleDetailsToggleDescriptionText'
);
expect(await rulePageDescription.getVisibleText()).to.be(data[0].rule.rationale);
const severity = await testSubjects.find('severity');
expect(await severity.getVisibleText()).to.be('Low');
const referenceUrls = await testSubjects.find('urlsDescriptionReferenceLinkItem');
expect(await referenceUrls.getVisibleText()).to.contain('https://elastic.co/rules/1.1');
});
});
describe('Navigation', () => {
it('Clicking on count of Rules should navigate to the rules page with benchmark tags as a filter', async () => {
await latestFindingsTable.openFlyoutAt(0);
await misconfigurationsFlyout.clickTakeActionCreateRuleButton();
const flyout = await misconfigurationsFlyout.getElement();
await (await flyout.findByTestSubject('csp:findings-flyout-detection-rule-count')).click();
expect(await (await testSubjects.find('ruleName')).getVisibleText()).to.be(ruleName1);
});
it('Clicking on count of Alerts should navigate to the alerts page', async () => {
await latestFindingsTable.openFlyoutAt(0);
await misconfigurationsFlyout.clickTakeActionCreateRuleButton();
const flyout = await misconfigurationsFlyout.getElement();
await (await flyout.findByTestSubject('csp:findings-flyout-alert-count')).click();
expect(await (await testSubjects.find('header-page-title')).getVisibleText()).to.be(
'Alerts'
);
});
});
});
}

View file

@ -12,6 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('Cloud Security Posture', function () {
loadTestFile(require.resolve('./findings_onboarding'));
loadTestFile(require.resolve('./findings'));
loadTestFile(require.resolve('./findings_alerts'));
loadTestFile(require.resolve('./compliance_dashboard'));
});
}