[8.x] [Attack Discovery][Scheduling] UI: "Attack Discovery Scheduling" management (#12007) (#217917) (#218267)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Attack Discovery][Scheduling] UI: "Attack Discovery Scheduling"
management (#12007)
(#217917)](https://github.com/elastic/kibana/pull/217917)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Ievgen
Sorokopud","email":"ievgen.sorokopud@elastic.co"},"sourceCommit":{"committedDate":"2025-04-15T13:06:06Z","message":"[Attack
Discovery][Scheduling] UI: \"Attack Discovery Scheduling\" management
(#12007) (#217917)\n\n## Summary\n\nMain ticket
([Internal\nlink](https://github.com/elastic/security-team/issues/12007))\n\nThese
changes add the attack discovery schedules management
table.\n\n\nhttps://github.com/user-attachments/assets/619ad1d6-d919-4a8d-b743-6a73fbfbf318\n\n##
Key changes\n\n* UI side API handlers\n* Create schedule workflow\n*
Schedules table\n* Enable schedule from the table\n* Disable schedule
from the table\n* Delete schedule from the table\n* Pagination and
sorting in find schedules API\n\n## NOTES\n\nThe feature is hidden
behind the feature flag (in
`kibana.dev.yml`):\n\n```\nfeature_flags.overrides:\n
securitySolution.assistantAttackDiscoverySchedulingEnabled:
true\n```","sha":"10943319b29f8efddb4fa641cd150083de994f16","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:
SecuritySolution","Team:Security Generative
AI","backport:version","v9.1.0","v8.19.0"],"title":"[Attack
Discovery][Scheduling] UI: \"Attack Discovery Scheduling\" management
(#12007)","number":217917,"url":"https://github.com/elastic/kibana/pull/217917","mergeCommit":{"message":"[Attack
Discovery][Scheduling] UI: \"Attack Discovery Scheduling\" management
(#12007) (#217917)\n\n## Summary\n\nMain ticket
([Internal\nlink](https://github.com/elastic/security-team/issues/12007))\n\nThese
changes add the attack discovery schedules management
table.\n\n\nhttps://github.com/user-attachments/assets/619ad1d6-d919-4a8d-b743-6a73fbfbf318\n\n##
Key changes\n\n* UI side API handlers\n* Create schedule workflow\n*
Schedules table\n* Enable schedule from the table\n* Disable schedule
from the table\n* Delete schedule from the table\n* Pagination and
sorting in find schedules API\n\n## NOTES\n\nThe feature is hidden
behind the feature flag (in
`kibana.dev.yml`):\n\n```\nfeature_flags.overrides:\n
securitySolution.assistantAttackDiscoverySchedulingEnabled:
true\n```","sha":"10943319b29f8efddb4fa641cd150083de994f16"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/217917","number":217917,"mergeCommit":{"message":"[Attack
Discovery][Scheduling] UI: \"Attack Discovery Scheduling\" management
(#12007) (#217917)\n\n## Summary\n\nMain ticket
([Internal\nlink](https://github.com/elastic/security-team/issues/12007))\n\nThese
changes add the attack discovery schedules management
table.\n\n\nhttps://github.com/user-attachments/assets/619ad1d6-d919-4a8d-b743-6a73fbfbf318\n\n##
Key changes\n\n* UI side API handlers\n* Create schedule workflow\n*
Schedules table\n* Enable schedule from the table\n* Disable schedule
from the table\n* Delete schedule from the table\n* Pagination and
sorting in find schedules API\n\n## NOTES\n\nThe feature is hidden
behind the feature flag (in
`kibana.dev.yml`):\n\n```\nfeature_flags.overrides:\n
securitySolution.assistantAttackDiscoverySchedulingEnabled:
true\n```","sha":"10943319b29f8efddb4fa641cd150083de994f16"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

---------

Co-authored-by: Ievgen Sorokopud <ievgen.sorokopud@elastic.co>
This commit is contained in:
Kibana Machine 2025-04-15 18:18:09 +02:00 committed by GitHub
parent 6099de25ac
commit a657a38eb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 3216 additions and 88 deletions

View file

@ -75,11 +75,13 @@ const SecurityAttackDiscoveryAlertRequired = rt.type({
'kibana.alert.attack_discovery.api_config': schemaUnknown,
'kibana.alert.attack_discovery.details_markdown': schemaString,
'kibana.alert.attack_discovery.details_markdown_with_replacements': schemaString,
'kibana.alert.attack_discovery.replacements.uuid': schemaString,
'kibana.alert.attack_discovery.replacements.value': schemaString,
'kibana.alert.attack_discovery.summary_markdown': schemaString,
'kibana.alert.attack_discovery.summary_markdown_with_replacements': schemaString,
'kibana.alert.attack_discovery.title': schemaString,
'kibana.alert.attack_discovery.title_with_replacements': schemaString,
'kibana.alert.attack_discovery.users.id': schemaString,
'kibana.alert.attack_discovery.users.name': schemaString,
'kibana.alert.instance.id': schemaString,
'kibana.alert.rule.category': schemaString,
'kibana.alert.rule.consumer': schemaString,
@ -105,9 +107,10 @@ const SecurityAttackDiscoveryAlertOptional = rt.partial({
'kibana.alert.attack_discovery.mitre_attack_tactics': schemaStringArray,
'kibana.alert.attack_discovery.replacements': schemaUnknown,
'kibana.alert.attack_discovery.user.id': schemaString,
'kibana.alert.attack_discovery.user.name': schemaString,
'kibana.alert.attack_discovery.users': rt.array(
rt.partial({
name: schemaString,
id: schemaString,
})
),
'kibana.alert.case_ids': schemaStringArray,

View file

@ -8,6 +8,9 @@
export const ELASTIC_AI_ASSISTANT_URL = '/api/security_ai_assistant';
export const ELASTIC_AI_ASSISTANT_INTERNAL_URL = '/internal/elastic_assistant';
export const POST_ACTIONS_CONNECTOR_EXECUTE =
`${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/actions/connector/{connectorId}/_execute` as const;
export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL =
`${ELASTIC_AI_ASSISTANT_URL}/current_user/conversations` as const;
export const ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL_BY_ID =
@ -65,3 +68,15 @@ export const DEFEND_INSIGHTS_BY_ID = `${DEFEND_INSIGHTS}/{id}`;
export const ATTACK_DISCOVERY_SCHEDULES_ENABLED_FEATURE_FLAG =
'securitySolution.assistantAttackDiscoverySchedulingEnabled' as const;
export const ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID = 'attack-discovery' as const;
export const ATTACK_DISCOVERY = `${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/attack_discovery` as const;
export const ATTACK_DISCOVERY_BY_CONNECTOR_ID = `${ATTACK_DISCOVERY}/{connectorId}` as const;
export const ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID =
`${ATTACK_DISCOVERY}/cancel/{connectorId}` as const;
export const ATTACK_DISCOVERY_SCHEDULES = `${ATTACK_DISCOVERY}/schedules` as const;
export const ATTACK_DISCOVERY_SCHEDULES_BY_ID = `${ATTACK_DISCOVERY_SCHEDULES}/{id}` as const;
export const ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE =
`${ATTACK_DISCOVERY_SCHEDULES}/{id}/_enable` as const;
export const ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE =
`${ATTACK_DISCOVERY_SCHEDULES}/{id}/_disable` as const;
export const ATTACK_DISCOVERY_SCHEDULES_FIND = `${ATTACK_DISCOVERY_SCHEDULES}/_find` as const;

View file

@ -16,14 +16,26 @@
import { z } from '@kbn/zod';
import { NonEmptyString } from '../common_attributes.gen';
import { AttackDiscoverySchedule } from './schedules.gen';
export type FindAttackDiscoverySchedulesRequestQuery = z.infer<
typeof FindAttackDiscoverySchedulesRequestQuery
>;
export const FindAttackDiscoverySchedulesRequestQuery = z.object({
page: z.coerce.number().optional(),
perPage: z.coerce.number().optional(),
sortField: NonEmptyString.optional(),
sortDirection: z.enum(['asc', 'desc']).optional(),
});
export type FindAttackDiscoverySchedulesRequestQueryInput = z.input<
typeof FindAttackDiscoverySchedulesRequestQuery
>;
export type FindAttackDiscoverySchedulesResponse = z.infer<
typeof FindAttackDiscoverySchedulesResponse
>;
export const FindAttackDiscoverySchedulesResponse = z.object({
page: z.number(),
perPage: z.number(),
total: z.number(),
data: z.array(AttackDiscoverySchedule),
});

View file

@ -12,6 +12,30 @@ paths:
summary: Finds attack discovery schedules
tags:
- attack_discovery_schedule
parameters:
- name: page
in: query
required: false
schema:
type: number
- name: perPage
in: query
required: false
schema:
type: number
- name: sortField
in: query
required: false
schema:
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
- name: sortDirection
in: query
required: false
schema:
type: string
enum:
- asc
- desc
responses:
200:
description: Successful response
@ -25,10 +49,6 @@ paths:
- total
- data
properties:
page:
type: number
perPage:
type: number
total:
type: number
data:

View file

@ -31,7 +31,14 @@ export const AttackDiscoveryScheduleParams = z.object({
/**
* LLM API configuration.
*/
apiConfig: ApiConfig,
apiConfig: ApiConfig.merge(
z.object({
/**
* The name of the connector
*/
name: z.string(),
})
),
end: z.string().optional(),
filter: z.object({}).catchall(z.unknown()).optional(),
size: z.number(),

View file

@ -72,7 +72,15 @@ components:
type: string
apiConfig:
description: LLM API configuration.
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig'
allOf:
- $ref: '../conversations/common_attributes.schema.yaml#/components/schemas/ApiConfig'
- type: object
required:
- name
properties:
name:
description: The name of the connector
type: string
end:
type: string
filter:

View file

@ -10,18 +10,6 @@ export const PLUGIN_NAME = 'elasticAssistant';
export const BASE_PATH = '/internal/elastic_assistant';
export const POST_ACTIONS_CONNECTOR_EXECUTE = `${BASE_PATH}/actions/connector/{connectorId}/_execute`;
// Attack discovery
export const ATTACK_DISCOVERY = `${BASE_PATH}/attack_discovery`;
export const ATTACK_DISCOVERY_BY_CONNECTOR_ID = `${ATTACK_DISCOVERY}/{connectorId}`;
export const ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID = `${ATTACK_DISCOVERY}/cancel/{connectorId}`;
export const ATTACK_DISCOVERY_SCHEDULES = `${ATTACK_DISCOVERY}/schedules`;
export const ATTACK_DISCOVERY_SCHEDULES_BY_ID = `${ATTACK_DISCOVERY_SCHEDULES}/{id}`;
export const ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE = `${ATTACK_DISCOVERY_SCHEDULES}/{id}/_enable`;
export const ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE = `${ATTACK_DISCOVERY_SCHEDULES}/{id}/_disable`;
export const ATTACK_DISCOVERY_SCHEDULES_FIND = `${ATTACK_DISCOVERY_SCHEDULES}/_find`;
export const CONVERSATIONS_TABLE_MAX_PAGE_SIZE = 100;
export const ANONYMIZATION_FIELDS_TABLE_MAX_PAGE_SIZE = 100;
export const PROMPTS_TABLE_MAX_PAGE_SIZE = 100;

View file

@ -27,6 +27,7 @@ export const getAttackDiscoveryCreateScheduleMock = (
params: {
alertsIndexPattern: '.alerts-security.alerts-default',
apiConfig: {
name: 'Mock GPT-4o',
connectorId: 'gpt-4o',
actionTypeId: '.gen-ai',
},
@ -102,6 +103,7 @@ export const getAttackDiscoveryScheduleMock = (
params: {
alertsIndexPattern: '.alerts-security.alerts-default',
apiConfig: {
name: 'Mock GPT-4o',
connectorId: 'gpt-4o',
actionTypeId: '.gen-ai',
},

View file

@ -5,17 +5,7 @@
* 2.0.
*/
import { httpServerMock } from '@kbn/core/server/mocks';
import {
ATTACK_DISCOVERY,
ATTACK_DISCOVERY_BY_CONNECTOR_ID,
ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID,
ATTACK_DISCOVERY_SCHEDULES,
ATTACK_DISCOVERY_SCHEDULES_BY_ID,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE,
ATTACK_DISCOVERY_SCHEDULES_FIND,
CAPABILITIES,
} from '../../common/constants';
import { CAPABILITIES } from '../../common/constants';
import type {
CreateAttackDiscoverySchedulesRequestBody,
DefendInsightsGetRequestQuery,
@ -26,6 +16,14 @@ import type {
UpdateKnowledgeBaseEntryRequestParams,
} from '@kbn/elastic-assistant-common';
import {
ATTACK_DISCOVERY,
ATTACK_DISCOVERY_BY_CONNECTOR_ID,
ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID,
ATTACK_DISCOVERY_SCHEDULES,
ATTACK_DISCOVERY_SCHEDULES_BY_ID,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE,
ATTACK_DISCOVERY_SCHEDULES_FIND,
AttackDiscoveryPostRequestBody,
ConversationCreateProps,
ConversationUpdateProps,

View file

@ -24,12 +24,66 @@ describe('AttackDiscoveryScheduleDataClient', () => {
});
describe('findSchedules', () => {
it('should call `rulesClient.find` with the correct filter', async () => {
it('should call `rulesClient.find` with the correct rule type', async () => {
const scheduleDataClient = new AttackDiscoveryScheduleDataClient(scheduleDataClientParams);
await scheduleDataClient.findSchedules();
expect(scheduleDataClientParams.rulesClient.find).toHaveBeenCalledWith({
options: { filter: `alert.attributes.alertTypeId: attack-discovery` },
options: {
page: 1,
ruleTypeIds: ['attack-discovery'],
},
});
});
it('should call `rulesClient.find` with the correct `page`', async () => {
const scheduleDataClient = new AttackDiscoveryScheduleDataClient(scheduleDataClientParams);
await scheduleDataClient.findSchedules({ page: 10 });
expect(scheduleDataClientParams.rulesClient.find).toHaveBeenCalledWith({
options: {
page: 11,
ruleTypeIds: ['attack-discovery'],
},
});
});
it('should call `rulesClient.find` with the correct `perPage`', async () => {
const scheduleDataClient = new AttackDiscoveryScheduleDataClient(scheduleDataClientParams);
await scheduleDataClient.findSchedules({ perPage: 23 });
expect(scheduleDataClientParams.rulesClient.find).toHaveBeenCalledWith({
options: {
page: 1,
perPage: 23,
ruleTypeIds: ['attack-discovery'],
},
});
});
it('should call `rulesClient.find` with the correct `sortField`', async () => {
const scheduleDataClient = new AttackDiscoveryScheduleDataClient(scheduleDataClientParams);
await scheduleDataClient.findSchedules({ sort: { sortField: 'name' } });
expect(scheduleDataClientParams.rulesClient.find).toHaveBeenCalledWith({
options: {
page: 1,
sortField: 'name',
ruleTypeIds: ['attack-discovery'],
},
});
});
it('should call `rulesClient.find` with the correct `sortDirection`', async () => {
const scheduleDataClient = new AttackDiscoveryScheduleDataClient(scheduleDataClientParams);
await scheduleDataClient.findSchedules({ sort: { sortDirection: 'desc' } });
expect(scheduleDataClientParams.rulesClient.find).toHaveBeenCalledWith({
options: {
page: 1,
sortOrder: 'desc',
ruleTypeIds: ['attack-discovery'],
},
});
});
});

View file

@ -12,6 +12,7 @@ import {
ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID,
AttackDiscoveryScheduleParams,
} from '@kbn/elastic-assistant-common';
import { AttackDiscoveryScheduleFindOptions } from '../types';
/**
* Params for when creating AttackDiscoveryScheduleDataClient in Request Context Factory. Useful if needing to modify
@ -28,13 +29,19 @@ export interface AttackDiscoveryScheduleDataClientParams {
export class AttackDiscoveryScheduleDataClient {
constructor(public readonly options: AttackDiscoveryScheduleDataClientParams) {}
public findSchedules = async () => {
public findSchedules = async ({
page = 0,
perPage,
sort: sortParam = {},
}: AttackDiscoveryScheduleFindOptions = {}) => {
// TODO: add filtering
// TODO: add sorting
// TODO: add pagination
const rules = await this.options.rulesClient.find<AttackDiscoveryScheduleParams>({
options: {
filter: `alert.attributes.alertTypeId: ${ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID}`,
page: page + 1,
perPage,
sortField: sortParam.sortField,
sortOrder: sortParam.sortDirection,
ruleTypeIds: [ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID],
},
});
return rules;

View file

@ -31,6 +31,7 @@ import {
ALERT_ATTACK_DISCOVERY_USERS_ID,
ALERT_ATTACK_DISCOVERY_USERS_NAME,
ALERT_ATTACK_DISCOVERY_USER_ID,
ALERT_ATTACK_DISCOVERY_USER_NAME,
ALERT_RISK_SCORE,
} from './field_names';
@ -128,12 +129,12 @@ export const attackDiscoveryAlertFieldMap: FieldMap = {
[ALERT_ATTACK_DISCOVERY_REPLACEMENTS_VALUE]: {
type: 'keyword',
array: false,
required: false,
required: true,
},
[ALERT_ATTACK_DISCOVERY_REPLACEMENTS_UUID]: {
type: 'keyword',
array: false,
required: false,
required: true,
},
[ALERT_ATTACK_DISCOVERY_SUMMARY_MARKDOWN]: {
type: 'text',
@ -163,6 +164,13 @@ export const attackDiscoveryAlertFieldMap: FieldMap = {
array: false,
required: false,
},
[ALERT_ATTACK_DISCOVERY_USER_NAME]: {
// optional field for ad hock attack discoveries
type: 'keyword',
array: false,
required: false,
},
[ALERT_ATTACK_DISCOVERY_USERS]: {
type: 'nested',
array: true,
@ -171,11 +179,11 @@ export const attackDiscoveryAlertFieldMap: FieldMap = {
[ALERT_ATTACK_DISCOVERY_USERS_ID]: {
type: 'keyword',
array: false,
required: true,
required: false,
},
[ALERT_ATTACK_DISCOVERY_USERS_NAME]: {
type: 'keyword',
array: false,
required: false,
required: true,
},
} as const;

View file

@ -30,6 +30,7 @@ export const ALERT_ATTACK_DISCOVERY_TITLE = `${ALERT_ATTACK_DISCOVERY}.title` as
export const ALERT_ATTACK_DISCOVERY_TITLE_WITH_REPLACEMENTS =
`${ALERT_ATTACK_DISCOVERY}.title_with_replacements` as const;
export const ALERT_ATTACK_DISCOVERY_USER_ID = `${ALERT_ATTACK_DISCOVERY}.user.id` as const;
export const ALERT_ATTACK_DISCOVERY_USER_NAME = `${ALERT_ATTACK_DISCOVERY}.user.name` as const;
// Alert base fields

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { estypes } from '@elastic/elasticsearch';
import { RuleExecutorOptions, RuleType, RuleTypeState } from '@kbn/alerting-plugin/server';
import { SecurityAttackDiscoveryAlert } from '@kbn/alerts-as-data-utils';
import { AttackDiscoveryScheduleParams } from '@kbn/elastic-assistant-common';
@ -28,3 +29,14 @@ export type AttackDiscoveryScheduleType = RuleType<
never,
SecurityAttackDiscoveryAlert
>;
export interface AttackDiscoveryScheduleSort {
sortDirection?: estypes.SortOrder;
sortField?: string;
}
export interface AttackDiscoveryScheduleFindOptions {
page?: number;
perPage?: number;
sort?: AttackDiscoveryScheduleSort;
}

View file

@ -11,11 +11,11 @@ import {
AttackDiscoveryGetResponse,
API_VERSIONS,
AttackDiscoveryGetRequestParams,
ATTACK_DISCOVERY_BY_CONNECTOR_ID,
} from '@kbn/elastic-assistant-common';
import { transformError } from '@kbn/securitysolution-es-utils';
import { updateAttackDiscoveryLastViewedAt, getAttackDiscoveryStats } from '../helpers/helpers';
import { ATTACK_DISCOVERY_BY_CONNECTOR_ID } from '../../../../common/constants';
import { buildResponse } from '../../../lib/build_response';
import { ElasticAssistantRequestHandlerContext } from '../../../types';

View file

@ -11,11 +11,11 @@ import {
AttackDiscoveryCancelResponse,
API_VERSIONS,
AttackDiscoveryCancelRequestParams,
ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID,
} from '@kbn/elastic-assistant-common';
import { transformError } from '@kbn/securitysolution-es-utils';
import { updateAttackDiscoveryStatusToCanceled } from '../../helpers/helpers';
import { ATTACK_DISCOVERY_CANCEL_BY_CONNECTOR_ID } from '../../../../../common/constants';
import { buildResponse } from '../../../../lib/build_response';
import { ElasticAssistantRequestHandlerContext } from '../../../../types';

View file

@ -10,11 +10,11 @@ import {
AttackDiscoveryPostRequestBody,
AttackDiscoveryPostResponse,
API_VERSIONS,
ATTACK_DISCOVERY,
} from '@kbn/elastic-assistant-common';
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
import { transformError } from '@kbn/securitysolution-es-utils';
import { ATTACK_DISCOVERY } from '../../../../common/constants';
import { updateAttackDiscoveryStatusToRunning } from '../helpers/helpers';
import { buildResponse } from '../../../lib/build_response';
import { ElasticAssistantRequestHandlerContext } from '../../../types';

View file

@ -34,6 +34,7 @@ const mockApiConfig = {
connectorId: 'connector-id',
actionTypeId: '.bedrock',
model: 'model',
name: 'Test Bedrock',
provider: OpenAiProviderType.OpenAi,
};
const mockRequestBody: CreateAttackDiscoverySchedulesRequestBody = {

View file

@ -10,13 +10,13 @@ import { transformError } from '@kbn/securitysolution-es-utils';
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
API_VERSIONS,
ATTACK_DISCOVERY_SCHEDULES,
ATTACK_DISCOVERY_SCHEDULES_ALERT_TYPE_ID,
CreateAttackDiscoverySchedulesRequestBody,
CreateAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import { buildResponse } from '../../../lib/build_response';
import { ATTACK_DISCOVERY_SCHEDULES } from '../../../../common/constants';
import { ElasticAssistantRequestHandlerContext } from '../../../types';
import { convertAlertingRuleToSchedule } from './utils/convert_alerting_rule_to_schedule';
import { performChecks } from '../../helpers';

View file

@ -11,11 +11,11 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
API_VERSIONS,
ATTACK_DISCOVERY_SCHEDULES_BY_ID,
DeleteAttackDiscoverySchedulesRequestParams,
DeleteAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import { buildResponse } from '../../../lib/build_response';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID } from '../../../../common/constants';
import { ElasticAssistantRequestHandlerContext } from '../../../types';
import { performChecks } from '../../helpers';
import { isFeatureAvailable } from './utils/is_feature_available';

View file

@ -11,11 +11,11 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
API_VERSIONS,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE,
DisableAttackDiscoverySchedulesRequestParams,
DisableAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import { buildResponse } from '../../../lib/build_response';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE } from '../../../../common/constants';
import { ElasticAssistantRequestHandlerContext } from '../../../types';
import { performChecks } from '../../helpers';
import { isFeatureAvailable } from './utils/is_feature_available';

View file

@ -11,11 +11,11 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
API_VERSIONS,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE,
EnableAttackDiscoverySchedulesRequestParams,
EnableAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import { buildResponse } from '../../../lib/build_response';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE } from '../../../../common/constants';
import { ElasticAssistantRequestHandlerContext } from '../../../types';
import { performChecks } from '../../helpers';
import { isFeatureAvailable } from './utils/is_feature_available';

View file

@ -36,6 +36,7 @@ const mockApiConfig = {
connectorId: 'connector-id',
actionTypeId: '.bedrock',
model: 'model',
name: 'Test Bedrock',
provider: OpenAiProviderType.OpenAi,
};
const basicAttackDiscoveryScheduleMock = {
@ -75,8 +76,6 @@ describe('findAttackDiscoverySchedulesRoute', () => {
);
expect(response.status).toEqual(200);
expect(response.body).toEqual({
page: 1,
perPage: 20,
total: 1,
data: [expect.objectContaining(basicAttackDiscoveryScheduleMock)],
});

View file

@ -9,9 +9,13 @@ import type { IKibanaResponse, IRouter, Logger } from '@kbn/core/server';
import { transformError } from '@kbn/securitysolution-es-utils';
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import { API_VERSIONS, FindAttackDiscoverySchedulesResponse } from '@kbn/elastic-assistant-common';
import {
API_VERSIONS,
ATTACK_DISCOVERY_SCHEDULES_FIND,
FindAttackDiscoverySchedulesRequestQuery,
FindAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import { buildResponse } from '../../../lib/build_response';
import { ATTACK_DISCOVERY_SCHEDULES_FIND } from '../../../../common/constants';
import { ElasticAssistantRequestHandlerContext } from '../../../types';
import { convertAlertingRuleToSchedule } from './utils/convert_alerting_rule_to_schedule';
import { performChecks } from '../../helpers';
@ -34,6 +38,9 @@ export const findAttackDiscoverySchedulesRoute = (
{
version: API_VERSIONS.internal.v1,
validate: {
request: {
query: buildRouteValidationWithZod(FindAttackDiscoverySchedulesRequestQuery),
},
response: {
200: {
body: {
@ -78,12 +85,18 @@ export const findAttackDiscoverySchedulesRoute = (
});
}
const results = await dataClient.findSchedules();
const { page, perPage, total, data } = results;
const { page, perPage, sortField, sortDirection } = request.query;
const results = await dataClient.findSchedules({
page,
perPage,
sort: { sortField, sortDirection },
});
const { total, data } = results;
const schedules = data.map(convertAlertingRuleToSchedule);
return response.ok({ body: { page, perPage, total, data: schedules } });
return response.ok({ body: { total, data: schedules } });
} catch (err) {
logger.error(err);
const error = transformError(err);

View file

@ -33,6 +33,7 @@ const mockApiConfig = {
connectorId: 'connector-id',
actionTypeId: '.bedrock',
model: 'model',
name: 'Test Bedrock',
provider: OpenAiProviderType.OpenAi,
};
const basicAttackDiscoveryScheduleMock = {

View file

@ -11,11 +11,11 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
API_VERSIONS,
ATTACK_DISCOVERY_SCHEDULES_BY_ID,
GetAttackDiscoverySchedulesRequestParams,
GetAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import { buildResponse } from '../../../lib/build_response';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID } from '../../../../common/constants';
import { ElasticAssistantRequestHandlerContext } from '../../../types';
import { convertAlertingRuleToSchedule } from './utils/convert_alerting_rule_to_schedule';
import { performChecks } from '../../helpers';

View file

@ -34,6 +34,7 @@ const mockApiConfig = {
connectorId: 'connector-id',
actionTypeId: '.bedrock',
model: 'model',
name: 'Test Bedrock',
provider: OpenAiProviderType.OpenAi,
};
const mockRequestBody: UpdateAttackDiscoverySchedulesRequestBody = {

View file

@ -11,12 +11,12 @@ import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
API_VERSIONS,
ATTACK_DISCOVERY_SCHEDULES_BY_ID,
UpdateAttackDiscoverySchedulesRequestBody,
UpdateAttackDiscoverySchedulesRequestParams,
UpdateAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import { buildResponse } from '../../../lib/build_response';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID } from '../../../../common/constants';
import { ElasticAssistantRequestHandlerContext } from '../../../types';
import { convertAlertingRuleToSchedule } from './utils/convert_alerting_rule_to_schedule';
import { performChecks } from '../../helpers';

View file

@ -14,6 +14,7 @@ const mockApiConfig = {
connectorId: 'connector-id',
actionTypeId: '.bedrock',
model: 'model',
name: 'Test Bedrock',
provider: OpenAiProviderType.OpenAi,
};
const basicAttackDiscoveryScheduleMock = {

View file

@ -18,6 +18,7 @@ const mockApiConfig = {
connectorId: 'connector-id',
actionTypeId: '.bedrock',
model: 'model',
name: 'Test Bedrock',
provider: OpenAiProviderType.OpenAi,
};
const basicAttackDiscoveryScheduleMock = {

View file

@ -18,10 +18,10 @@ import {
Replacements,
pruneContentReferences,
ExecuteConnectorRequestQuery,
POST_ACTIONS_CONNECTOR_EXECUTE,
} from '@kbn/elastic-assistant-common';
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
import { INVOKE_ASSISTANT_ERROR_EVENT } from '../lib/telemetry/event_based_telemetry';
import { POST_ACTIONS_CONNECTOR_EXECUTE } from '../../common/constants';
import { buildResponse } from '../lib/build_response';
import { ElasticAssistantRequestHandlerContext, GetElser } from '../types';
import {

View file

@ -0,0 +1,61 @@
/*
* 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 type {
AttackDiscoverySchedule,
AttackDiscoveryScheduleCreateProps,
} from '@kbn/elastic-assistant-common';
export const mockAttackDiscoverySchedule: AttackDiscoverySchedule = {
id: 'ffaa0a8a-3c35-4166-9f73-70baac2b6b42',
name: 'Schedule - Sonnet 3.7 (Bedrock)',
createdBy: 'elastic',
updatedBy: 'elastic',
createdAt: '2025-04-09T08:51:04.697Z',
updatedAt: '2025-04-09T21:10:16.483Z',
enabled: true,
params: {
alertsIndexPattern: '.alerts-security.alerts-default',
apiConfig: {
connectorId: 'sonnet-3-7',
actionTypeId: '.bedrock',
name: 'Sonnet 3.7',
},
end: 'now',
size: 100,
start: 'now-24h',
},
schedule: {
interval: '10m',
},
actions: [],
lastExecution: {
date: '2025-04-09T21:01:08.276Z',
status: 'ok',
duration: 26,
},
};
export const mockCreateAttackDiscoverySchedule: AttackDiscoveryScheduleCreateProps = {
name: 'Schedule - Sonnet 3.7 (Bedrock)',
enabled: true,
params: {
alertsIndexPattern: '.alerts-security.alerts-default',
apiConfig: {
connectorId: 'sonnet-3-7',
actionTypeId: '.bedrock',
name: 'Sonnet 3.7',
},
end: 'now',
size: 100,
start: 'now-24h',
},
schedule: {
interval: '10m',
},
actions: [],
};

View file

@ -0,0 +1,75 @@
/*
* 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 type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
export const mockFindAttackDiscoverySchedules: {
schedules: AttackDiscoverySchedule[];
total: number;
} = {
total: 2,
schedules: [
{
id: 'ffaa0a8a-3c35-4166-9f73-70baac2b6b42',
name: 'Schedule - Sonnet 3.7 (Bedrock)',
createdBy: 'elastic',
updatedBy: 'elastic',
createdAt: '2025-04-09T08:51:04.697Z',
updatedAt: '2025-04-09T21:10:16.483Z',
enabled: true,
params: {
alertsIndexPattern: '.alerts-security.alerts-default',
apiConfig: {
connectorId: 'sonnet-3-7',
actionTypeId: '.bedrock',
name: 'Sonnet 3.7',
},
end: 'now',
size: 100,
start: 'now-24h',
},
schedule: {
interval: '10m',
},
actions: [],
lastExecution: {
date: '2025-04-09T21:01:08.276Z',
status: 'ok',
duration: 26,
},
},
{
id: '6826fb6f-de83-4e19-b9e4-15718bda02e6',
name: 'Schedule 2',
createdBy: 'elastic',
updatedBy: 'elastic',
createdAt: '2025-04-09T08:51:04.697Z',
updatedAt: '2025-04-09T21:10:16.483Z',
enabled: false,
params: {
alertsIndexPattern: '.alerts-security.alerts-default',
apiConfig: {
connectorId: 'gpt-4o',
actionTypeId: '.gen-ai',
name: 'Test GPT-4o',
},
end: 'now',
size: 120,
start: 'now-24h',
},
schedule: {
interval: '30m',
},
actions: [],
lastExecution: {
date: '2025-04-09T21:01:08.276Z',
status: 'ok',
duration: 26,
},
},
],
};

View file

@ -34,3 +34,10 @@ export const SCHEDULE_TAB_LABEL = i18n.translate(
defaultMessage: 'Schedule',
}
);
export const CREATE_NEW_SCHEDULE = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.createNewScheduleButton',
{
defaultMessage: 'Create new schedule',
}
);

View file

@ -0,0 +1,131 @@
/*
* 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 { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
import { useScheduleView } from './use_schedule_view';
import { useFindAttackDiscoverySchedules } from '../schedule/logic/use_find_schedules';
import { useKibana } from '../../../../common/lib/kibana';
import { useSourcererDataView } from '../../../../sourcerer/containers';
import { TestProviders } from '../../../../common/mock';
import { mockFindAttackDiscoverySchedules } from '../../mock/mock_find_attack_discovery_schedules';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
jest.mock('react-router', () => ({
matchPath: jest.fn(),
useLocation: jest.fn().mockReturnValue({
search: '',
}),
withRouter: jest.fn(),
}));
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../sourcerer/containers');
jest.mock('../schedule/logic/use_find_schedules');
const mockUseKibana = useKibana as jest.MockedFunction<typeof useKibana>;
const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction<
typeof useSourcererDataView
>;
const mockUseFindAttackDiscoverySchedules = useFindAttackDiscoverySchedules as jest.MockedFunction<
typeof useFindAttackDiscoverySchedules
>;
describe('useScheduleView', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseKibana.mockReturnValue({
services: {
lens: {
EmbeddableComponent: () => <div data-test-subj="mockEmbeddableComponent" />,
},
triggersActionsUi: {
...triggersActionsUiMock.createStart(),
},
uiSettings: {
get: jest.fn(),
},
unifiedSearch: {
ui: {
SearchBar: () => <div data-test-subj="mockSearchBar" />,
},
},
},
} as unknown as jest.Mocked<ReturnType<typeof useKibana>>);
mockUseSourcererDataView.mockReturnValue({
sourcererDataView: {},
loading: false,
} as unknown as jest.Mocked<ReturnType<typeof useSourcererDataView>>);
mockUseFindAttackDiscoverySchedules.mockReturnValue({
data: mockFindAttackDiscoverySchedules,
isLoading: false,
} as unknown as jest.Mocked<ReturnType<typeof useFindAttackDiscoverySchedules>>);
});
it('should return the `empty schedules` page if there are no existing schedules', () => {
mockUseFindAttackDiscoverySchedules.mockReturnValue({
data: { schedules: [], total: 0 },
isLoading: false,
} as unknown as jest.Mocked<ReturnType<typeof useFindAttackDiscoverySchedules>>);
const { result } = renderHook(() => useScheduleView());
render(<TestProviders>{result.current.scheduleView}</TestProviders>);
expect(screen.getByTestId('emptySchedule')).toBeInTheDocument();
});
it('should not return `create new schedule` action button if there are no existing schedules', () => {
mockUseFindAttackDiscoverySchedules.mockReturnValue({
data: { schedules: [], total: 0 },
isLoading: false,
} as unknown as jest.Mocked<ReturnType<typeof useFindAttackDiscoverySchedules>>);
const { result } = renderHook(() => useScheduleView());
render(<TestProviders>{result.current.actionButtons}</TestProviders>);
expect(screen.queryByTestId('createNewSchedule')).not.toBeInTheDocument();
});
it('should return the `attack discovery schedules table` if there are existing schedules', () => {
const { result } = renderHook(() => useScheduleView());
render(<TestProviders>{result.current.scheduleView}</TestProviders>);
expect(screen.getByTestId('schedulesTable')).toBeInTheDocument();
});
it('should return `create new schedule` action button if there are existing schedules', () => {
const { result } = renderHook(() => useScheduleView());
render(<TestProviders>{result.current.actionButtons}</TestProviders>);
expect(screen.getByTestId('createNewSchedule')).toBeInTheDocument();
});
it('should show create schedule flyout on `create new schedule` action button click', async () => {
const { result } = renderHook(() => useScheduleView());
render(<TestProviders>{result.current.actionButtons}</TestProviders>);
expect(screen.getByTestId('createNewSchedule')).toBeInTheDocument();
const createNewSchedule = screen.getByTestId('createNewSchedule');
act(() => {
fireEvent.click(createNewSchedule);
});
render(<TestProviders>{result.current.scheduleView}</TestProviders>);
await waitFor(() => {
expect(screen.getByTestId('scheduleCreateFlyout')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,77 @@
/*
* 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, { useCallback, useMemo, useState } from 'react';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSkeletonLoading,
EuiSkeletonText,
EuiSkeletonTitle,
} from '@elastic/eui';
import * as i18n from './translations';
import { useFindAttackDiscoverySchedules } from '../schedule/logic/use_find_schedules';
import { EmptyPage } from '../schedule/empty_page';
import { SchedulesTable } from '../schedule/schedules_table';
import { CreateFlyout } from '../schedule/create_flyout';
export interface UseScheduleView {
scheduleView: React.ReactNode;
actionButtons: React.ReactNode;
}
export const useScheduleView = (): UseScheduleView => {
// showing / hiding the flyout:
const [showFlyout, setShowFlyout] = useState<boolean>(false);
const openFlyout = useCallback(() => setShowFlyout(true), []);
const onClose = useCallback(() => setShowFlyout(false), []);
// TODO: add separate hook to fetch schedules stats/count
const { data: { total } = { total: 0 }, isLoading: isDataLoading } =
useFindAttackDiscoverySchedules();
const scheduleView = useMemo(() => {
return (
<>
<EuiSkeletonLoading
isLoading={isDataLoading}
loadingContent={
<>
<EuiSkeletonTitle />
<EuiSkeletonText />
</>
}
loadedContent={!total ? <EmptyPage /> : <SchedulesTable />}
/>
{showFlyout && <CreateFlyout onClose={onClose} />}
</>
);
}, [isDataLoading, onClose, showFlyout, total]);
const actionButtons = useMemo(() => {
return total ? (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="createNewSchedule"
fill
onClick={openFlyout}
size="s"
iconType="plusInCircle"
>
{i18n.CREATE_NEW_SCHEDULE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
) : null;
}, [openFlyout, total]);
return { scheduleView, actionButtons };
};

View file

@ -12,6 +12,8 @@ import { useKibana } from '../../../../common/lib/kibana';
import { useSourcererDataView } from '../../../../sourcerer/containers';
import { useTabsView } from './use_tabs_view';
import { TestProviders } from '../../../../common/mock';
import { useFindAttackDiscoverySchedules } from '../schedule/logic/use_find_schedules';
import { mockFindAttackDiscoverySchedules } from '../../mock/mock_find_attack_discovery_schedules';
jest.mock('react-router', () => ({
matchPath: jest.fn(),
@ -22,6 +24,7 @@ jest.mock('react-router', () => ({
}));
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../sourcerer/containers');
jest.mock('../schedule/logic/use_find_schedules');
const defaultProps = {
onSettingsReset: jest.fn(),
@ -40,6 +43,9 @@ const mockUseKibana = useKibana as jest.MockedFunction<typeof useKibana>;
const mockUseSourcererDataView = useSourcererDataView as jest.MockedFunction<
typeof useSourcererDataView
>;
const mockUseFindAttackDiscoverySchedules = useFindAttackDiscoverySchedules as jest.MockedFunction<
typeof useFindAttackDiscoverySchedules
>;
describe('useTabsView', () => {
beforeEach(() => {
@ -65,6 +71,11 @@ describe('useTabsView', () => {
sourcererDataView: {},
loading: false,
} as unknown as jest.Mocked<ReturnType<typeof useSourcererDataView>>);
mockUseFindAttackDiscoverySchedules.mockReturnValue({
data: { schedules: [], total: 0 },
isLoading: false,
} as unknown as jest.Mocked<ReturnType<typeof useFindAttackDiscoverySchedules>>);
});
it('should return the alert selection component with `AlertSelectionQuery` when settings tab is selected', () => {
@ -140,7 +151,29 @@ describe('useTabsView', () => {
});
render(<TestProviders>{result.current.tabsContainer}</TestProviders>);
await waitFor(() => {
expect(result.current.actionButtons).toBeUndefined();
expect(result.current.actionButtons).toBeNull();
});
});
it('should not return `create new schedule` action button when schedule tab is selected and there are existing schedules', async () => {
mockUseFindAttackDiscoverySchedules.mockReturnValue({
data: mockFindAttackDiscoverySchedules,
isLoading: false,
} as unknown as jest.Mocked<ReturnType<typeof useFindAttackDiscoverySchedules>>);
const { result } = renderHook(() => useTabsView(defaultProps));
render(<TestProviders>{result.current.tabsContainer}</TestProviders>);
const scheduleTabButton = screen.getByRole('tab', { name: 'Schedule' });
act(() => {
fireEvent.click(scheduleTabButton); // clicking invokes tab switching
});
// Render action buttons of the Schedule tab
render(<TestProviders>{result.current.actionButtons}</TestProviders>);
await waitFor(() => {
expect(screen.getByTestId('createNewSchedule')).toBeInTheDocument();
});
});
});

View file

@ -17,7 +17,7 @@ import {
import * as i18n from './translations';
import { useSettingsView } from './use_settings_view';
import type { AlertsSelectionSettings } from '../types';
import { Schedule } from '../schedule';
import { useScheduleView } from './use_schedule_view';
/*
* Fixes tabs to the top and allows the content to scroll.
@ -54,6 +54,7 @@ export const useTabsView = ({
onSettingsChanged,
settings,
});
const { scheduleView, actionButtons: scheduleTabButtons } = useScheduleView();
const settingsTab: EuiTabbedContentTab = useMemo(
() => ({
@ -76,11 +77,11 @@ export const useTabsView = ({
content: (
<>
<EuiSpacer size="m" />
<Schedule />
{scheduleView}
</>
),
}),
[]
[scheduleView]
);
const tabs = useMemo(() => {
@ -112,8 +113,8 @@ export const useTabsView = ({
}, [selectedTab, tabs]);
const actionButtons = useMemo(
() => (selectedTabId === 'settings' ? filterActionButtons : undefined),
[filterActionButtons, selectedTabId]
() => (selectedTabId === 'settings' ? filterActionButtons : scheduleTabButtons),
[filterActionButtons, scheduleTabButtons, selectedTabId]
);
return { tabsContainer, actionButtons };

View file

@ -0,0 +1,116 @@
/*
* 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 { coreMock } from '@kbn/core/public/mocks';
import { replaceParams } from '@kbn/openapi-common/shared';
import type {
AttackDiscoveryScheduleCreateProps,
AttackDiscoveryScheduleUpdateProps,
} from '@kbn/elastic-assistant-common';
import {
ATTACK_DISCOVERY_SCHEDULES,
ATTACK_DISCOVERY_SCHEDULES_BY_ID,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE,
ATTACK_DISCOVERY_SCHEDULES_FIND,
} from '@kbn/elastic-assistant-common';
import {
createAttackDiscoverySchedule,
deleteAttackDiscoverySchedule,
disableAttackDiscoverySchedule,
enableAttackDiscoverySchedule,
findAttackDiscoverySchedule,
getAttackDiscoverySchedule,
updateAttackDiscoverySchedule,
} from '.';
import { KibanaServices } from '../../../../../common/lib/kibana';
jest.mock('../../../../../common/lib/kibana');
const mockKibanaServices = KibanaServices.get as jest.Mock;
describe('Schedule API', () => {
beforeEach(() => {
mockKibanaServices.mockReturnValue(coreMock.createStart({ basePath: '/mock' }));
});
it('should send a create schedule POST request', async () => {
const body = { test: 'test schedule' } as unknown as AttackDiscoveryScheduleCreateProps;
await createAttackDiscoverySchedule({ body });
expect(mockKibanaServices().http.post).toHaveBeenCalledWith(ATTACK_DISCOVERY_SCHEDULES, {
body: JSON.stringify(body),
version: '1',
});
});
it('should send a fetch schedule GET request', async () => {
const id = 'test-id-1';
await getAttackDiscoverySchedule({ id });
expect(mockKibanaServices().http.get).toHaveBeenCalledWith(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID, { id }),
{ version: '1' }
);
});
it('should send an update schedule PUT request', async () => {
const id = 'test-id-2';
const body = { test: 'test schedule' } as unknown as AttackDiscoveryScheduleUpdateProps;
await updateAttackDiscoverySchedule({ id, body });
expect(mockKibanaServices().http.put).toHaveBeenCalledWith(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID, { id }),
{ body: JSON.stringify(body), version: '1' }
);
});
it('should send a delete schedule DELETE request', async () => {
const id = 'test-id-3';
await deleteAttackDiscoverySchedule({ id });
expect(mockKibanaServices().http.delete).toHaveBeenCalledWith(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID, { id }),
{ version: '1' }
);
});
it('should send an enable schedule POST request', async () => {
const id = 'test-id-4';
await enableAttackDiscoverySchedule({ id });
expect(mockKibanaServices().http.post).toHaveBeenCalledWith(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE, { id }),
{ version: '1' }
);
});
it('should send a disable schedule POST request', async () => {
const id = 'test-id-5';
await disableAttackDiscoverySchedule({ id });
expect(mockKibanaServices().http.post).toHaveBeenCalledWith(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE, { id }),
{ version: '1' }
);
});
it('should send a find schedules GET request', async () => {
const params = {
page: 10,
perPage: 123,
sortField: 'test-field-1',
sortDirection: 'desc' as 'asc' | 'desc',
};
await findAttackDiscoverySchedule(params);
expect(mockKibanaServices().http.get).toHaveBeenCalledWith(ATTACK_DISCOVERY_SCHEDULES_FIND, {
version: '1',
query: { ...params },
});
});
});

View file

@ -0,0 +1,159 @@
/*
* 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 { replaceParams } from '@kbn/openapi-common/shared';
import type {
AttackDiscoveryScheduleCreateProps,
AttackDiscoveryScheduleUpdateProps,
CreateAttackDiscoverySchedulesResponse,
DeleteAttackDiscoverySchedulesResponse,
DisableAttackDiscoverySchedulesResponse,
EnableAttackDiscoverySchedulesResponse,
FindAttackDiscoverySchedulesResponse,
GetAttackDiscoverySchedulesResponse,
UpdateAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import {
ATTACK_DISCOVERY_SCHEDULES,
ATTACK_DISCOVERY_SCHEDULES_BY_ID,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE,
ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE,
ATTACK_DISCOVERY_SCHEDULES_FIND,
} from '@kbn/elastic-assistant-common';
import { KibanaServices } from '../../../../../common/lib/kibana';
export interface CreateAttackDiscoveryScheduleParams {
/** The body containing the schedule attributes */
body: AttackDiscoveryScheduleCreateProps;
/** Optional AbortSignal for cancelling request */
signal?: AbortSignal;
}
/** Creates a new attack discovery schedule with the provided attributes. */
export const createAttackDiscoverySchedule = async ({
body,
signal,
}: CreateAttackDiscoveryScheduleParams): Promise<CreateAttackDiscoverySchedulesResponse> => {
return KibanaServices.get().http.post<CreateAttackDiscoverySchedulesResponse>(
ATTACK_DISCOVERY_SCHEDULES,
{ body: JSON.stringify(body), version: '1', signal }
);
};
export interface GetAttackDiscoveryScheduleParams {
/** `id` of the attack discovery schedule */
id: string;
/** Optional AbortSignal for cancelling request */
signal?: AbortSignal;
}
/** Retrieves the attack discovery schedule. */
export const getAttackDiscoverySchedule = async ({
id,
signal,
}: GetAttackDiscoveryScheduleParams): Promise<GetAttackDiscoverySchedulesResponse> => {
return KibanaServices.get().http.get<GetAttackDiscoverySchedulesResponse>(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID, { id }),
{ version: '1', signal }
);
};
export interface UpdateAttackDiscoveryScheduleParams {
/** `id` of the attack discovery schedule */
id: string;
/** The body containing the schedule attributes */
body: AttackDiscoveryScheduleUpdateProps;
/** Optional AbortSignal for cancelling request */
signal?: AbortSignal;
}
/** Updates the attack discovery schedule. */
export const updateAttackDiscoverySchedule = async ({
id,
body,
signal,
}: UpdateAttackDiscoveryScheduleParams): Promise<UpdateAttackDiscoverySchedulesResponse> => {
return KibanaServices.get().http.put<UpdateAttackDiscoverySchedulesResponse>(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID, { id }),
{ body: JSON.stringify(body), version: '1', signal }
);
};
export interface DeleteAttackDiscoveryScheduleParams {
/** `id` of the attack discovery schedule */
id: string;
/** Optional AbortSignal for cancelling request */
signal?: AbortSignal;
}
/** Deletes the attack discovery schedule. */
export const deleteAttackDiscoverySchedule = async ({
id,
signal,
}: DeleteAttackDiscoveryScheduleParams): Promise<DeleteAttackDiscoverySchedulesResponse> => {
return KibanaServices.get().http.delete<DeleteAttackDiscoverySchedulesResponse>(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID, { id }),
{ version: '1', signal }
);
};
export interface EnableAttackDiscoveryScheduleParams {
/** `id` of the attack discovery schedule */
id: string;
/** Optional AbortSignal for cancelling request */
signal?: AbortSignal;
}
/** Enables the attack discovery schedule. */
export const enableAttackDiscoverySchedule = async ({
id,
signal,
}: EnableAttackDiscoveryScheduleParams): Promise<EnableAttackDiscoverySchedulesResponse> => {
return KibanaServices.get().http.post<EnableAttackDiscoverySchedulesResponse>(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE, { id }),
{ version: '1', signal }
);
};
export interface DisableAttackDiscoveryScheduleParams {
/** `id` of the attack discovery schedule */
id: string;
/** Optional AbortSignal for cancelling request */
signal?: AbortSignal;
}
/** Disables the attack discovery schedule. */
export const disableAttackDiscoverySchedule = async ({
id,
signal,
}: DisableAttackDiscoveryScheduleParams): Promise<DisableAttackDiscoverySchedulesResponse> => {
return KibanaServices.get().http.post<DisableAttackDiscoverySchedulesResponse>(
replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE, { id }),
{ version: '1', signal }
);
};
export interface FindAttackDiscoveryScheduleParams {
/** Optional page number to retrieve */
page?: number;
/** Optional number of documents per page to retrieve */
perPage?: number;
/** Optional field of the attack discovery schedule object to sort results by */
sortField?: string;
/** Optional direction to sort results by */
sortDirection?: 'asc' | 'desc';
/** Optional AbortSignal for cancelling request */
signal?: AbortSignal;
}
/** Retrieves attack discovery schedules. */
export const findAttackDiscoverySchedule = async ({
page,
perPage,
sortField,
sortDirection,
signal,
}: FindAttackDiscoveryScheduleParams): Promise<FindAttackDiscoverySchedulesResponse> => {
return KibanaServices.get().http.get<FindAttackDiscoverySchedulesResponse>(
ATTACK_DISCOVERY_SCHEDULES_FIND,
{ version: '1', query: { page, perPage, sortField, sortDirection }, signal }
);
};

View file

@ -16,12 +16,21 @@ import {
useGeneratedHtmlId,
} from '@elastic/eui';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import { useAssistantContext, useLoadConnectors } from '@kbn/elastic-assistant';
import * as i18n from './translations';
import { useKibana } from '../../../../../common/lib/kibana';
import { convertToBuildEsQuery } from '../../../../../common/lib/kuery';
import { useSourcererDataView } from '../../../../../sourcerer/containers';
import { Footer } from '../../footer';
import { MIN_FLYOUT_WIDTH } from '../../constants';
import { useEditForm } from '../hooks/use_edit_form';
import type { AttackDiscoveryScheduleSchema } from '../hooks/types';
import { useEditForm } from '../edit_form';
import type { AttackDiscoveryScheduleSchema } from '../edit_form/types';
import { useCreateAttackDiscoverySchedule } from '../logic/use_create_schedule';
import { parseFilterQuery } from '../../parse_filter_query';
import { getGenAiConfig } from '../../../use_attack_discovery/helpers';
interface Props {
onClose: () => void;
@ -32,16 +41,78 @@ export const CreateFlyout: React.FC<Props> = React.memo(({ onClose }) => {
prefix: 'attackDiscoveryScheduleCreateFlyoutTitle',
});
const {
services: { uiSettings },
} = useKibana();
const { alertsIndexPattern, http } = useAssistantContext();
const { data: aiConnectors, isLoading: isLoadingConnectors } = useLoadConnectors({
http,
});
const { indexPattern } = useSourcererDataView();
const { mutateAsync: createAttackDiscoverySchedule, isLoading: isLoadingQuery } =
useCreateAttackDiscoverySchedule();
const onCreateSchedule = useCallback(
(scheduleData: AttackDiscoveryScheduleSchema) => {
// TODO: handle create schedule
onClose();
async (scheduleData: AttackDiscoveryScheduleSchema) => {
const connector = aiConnectors?.find((item) => item.id === scheduleData.connectorId);
if (!connector) {
return;
}
try {
const genAiConfig = getGenAiConfig(connector);
const [filterQuery, kqlError] = convertToBuildEsQuery({
config: getEsQueryConfig(uiSettings),
indexPattern,
queries: [scheduleData.alertsSelectionSettings.query],
filters: scheduleData.alertsSelectionSettings.filters,
});
const filter = parseFilterQuery({ filterQuery, kqlError });
const apiConfig = {
connectorId: connector.id,
name: connector.name,
actionTypeId: connector.actionTypeId,
provider: connector.apiProvider,
model: genAiConfig?.defaultModel,
};
const scheduleToCreate = {
name: scheduleData.name,
enabled: true,
params: {
alertsIndexPattern: alertsIndexPattern ?? '',
apiConfig,
end: scheduleData.alertsSelectionSettings.end,
filter,
size: scheduleData.alertsSelectionSettings.size,
start: scheduleData.alertsSelectionSettings.start,
},
schedule: { interval: scheduleData.interval },
actions: scheduleData.actions,
};
await createAttackDiscoverySchedule({ scheduleToCreate });
onClose();
} catch (err) {
// Error is handled by the mutation's onError callback, so no need to do anything here
}
},
[onClose]
[
aiConnectors,
alertsIndexPattern,
createAttackDiscoverySchedule,
onClose,
indexPattern,
uiSettings,
]
);
const { editForm, actionButtons } = useEditForm({
onSave: onCreateSchedule,
saveButtonDisabled: isLoadingConnectors || isLoadingQuery,
saveButtonTitle: i18n.SCHEDULE_CREATE_BUTTON_TITLE,
});

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './use_edit_form';

View file

@ -49,11 +49,17 @@ export interface UseEditForm {
export interface UseEditFormProps {
initialValue?: AttackDiscoveryScheduleSchema;
onSave?: (scheduleData: AttackDiscoveryScheduleSchema) => void;
saveButtonDisabled?: boolean;
saveButtonTitle?: string;
}
export const useEditForm = (props: UseEditFormProps): UseEditForm => {
const { initialValue = defaultInitialValue, onSave, saveButtonTitle } = props;
const {
initialValue = defaultInitialValue,
onSave,
saveButtonDisabled = false,
saveButtonTitle,
} = props;
const { euiTheme } = useEuiTheme();
const {
triggersActionsUi: { actionTypeRegistry },
@ -173,14 +179,20 @@ export const useEditForm = (props: UseEditFormProps): UseEditForm => {
grow={false}
>
<EuiFlexItem grow={false}>
<EuiButton data-test-subj="save" fill onClick={onCreate} size="s">
<EuiButton
data-test-subj="save"
fill
size="s"
onClick={onCreate}
disabled={saveButtonDisabled}
>
{saveButtonTitle ?? i18n.SCHEDULE_SAVE_BUTTON_TITLE}
</EuiButton>
</EuiFlexItem>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [euiTheme.size.s, onCreate, saveButtonTitle]);
}, [euiTheme.size.s, onCreate, saveButtonDisabled, saveButtonTitle]);
return { editForm, actionButtons };
};

View file

@ -17,11 +17,13 @@ import {
EuiText,
} from '@elastic/eui';
import { css } from '@emotion/react';
import ScheduleIconSVG from './icons/schedule.svg';
import * as i18n from './translations';
import { CreateFlyout } from './create_flyout';
export const EmptySchedule: React.FC = React.memo(() => {
import * as i18n from './translations';
import ScheduleIconSVG from '../icons/schedule.svg';
import { CreateFlyout } from '../create_flyout';
export const EmptyPage: React.FC = React.memo(() => {
// showing / hiding the flyout:
const [showFlyout, setShowFlyout] = useState<boolean>(false);
const openFlyout = useCallback(() => setShowFlyout(true), []);
@ -80,4 +82,4 @@ export const EmptySchedule: React.FC = React.memo(() => {
</>
);
});
EmptySchedule.displayName = 'EmptySchedule';
EmptyPage.displayName = 'EmptyPage';

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
const ONE_MINUTE = 60000;
export const DEFAULT_QUERY_OPTIONS = {
refetchIntervalInBackground: false,
staleTime: ONE_MINUTE * 5,
retry: false,
};

View file

@ -0,0 +1,88 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const FETCH_ATTACK_DISCOVERY_SCHEDULES_FAILURE = (single = true) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.fetchSchedulesFailDescription', {
defaultMessage:
'Failed to fetch {failed, plural, one {attack discovery schedule} other {attack discovery schedules}}',
values: { failed: single ? 1 : 2 },
});
export const CREATE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS = (succeeded = 1) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.createSchedulesSuccess', {
defaultMessage:
'{succeeded, plural, one {# attack discovery schedule} other {# attack discovery schedules}} created successfully.',
values: { succeeded },
});
export const CREATE_ATTACK_DISCOVERY_SCHEDULES_FAILURE = (failed = 1) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.createSchedulesFailDescription', {
defaultMessage:
'Failed to create {failed, plural, one {# attack discovery schedule} other {# attack discovery schedules}}',
values: { failed },
});
export const UPDATE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS = (succeeded = 1) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.updateSchedulesSuccess', {
defaultMessage:
'{succeeded, plural, one {# attack discovery schedule} other {# attack discovery schedules}} updated successfully.',
values: { succeeded },
});
export const UPDATE_ATTACK_DISCOVERY_SCHEDULES_FAILURE = (failed = 1) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.updateSchedulesFailDescription', {
defaultMessage:
'Failed to update {failed, plural, one {# attack discovery schedule} other {# attack discovery schedules}}',
values: { failed },
});
export const DELETE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS = (succeeded = 1) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.deleteSchedulesSuccess', {
defaultMessage:
'{succeeded, plural, one {# attack discovery schedule} other {# attack discovery schedules}} deleted successfully.',
values: { succeeded },
});
export const DELETE_ATTACK_DISCOVERY_SCHEDULES_FAILURE = (failed = 1) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.deleteSchedulesFailDescription', {
defaultMessage:
'Failed to delete {failed, plural, one {# attack discovery schedule} other {# attack discovery schedules}}',
values: { failed },
});
export const ENABLE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS = (succeeded = 1) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.enableSchedulesSuccess', {
defaultMessage:
'{succeeded, plural, one {# attack discovery schedule} other {# attack discovery schedules}} enabled successfully.',
values: { succeeded },
});
export const ENABLE_ATTACK_DISCOVERY_SCHEDULES_FAILURE = (failed = 1) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.enableSchedulesFailDescription', {
defaultMessage:
'Failed to enable {failed, plural, one {# attack discovery schedule} other {# attack discovery schedules}}',
values: { failed },
});
export const DISABLE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS = (succeeded = 1) =>
i18n.translate('xpack.securitySolution.attackDiscovery.schedule.disableSchedulesSuccess', {
defaultMessage:
'{succeeded, plural, one {# attack discovery schedule} other {# attack discovery schedules}} disabled successfully.',
values: { succeeded },
});
export const DISABLE_ATTACK_DISCOVERY_SCHEDULES_FAILURE = (failed = 1) =>
i18n.translate(
'xpack.securitySolution.attackDiscovery.schedule.disableSchedulesFailDescription',
{
defaultMessage:
'Failed to disable {failed, plural, one {# attack discovery schedule} other {# attack discovery schedules}}',
values: { failed },
}
);

View file

@ -0,0 +1,98 @@
/*
* 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 { act } from '@testing-library/react';
import { useCreateAttackDiscoverySchedule } from './use_create_schedule';
import { mockCreateAttackDiscoverySchedule } from '../../../mock/mock_attack_discovery_schedule';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock';
import { renderMutation } from '../../../../../management/hooks/test_utils';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { createAttackDiscoverySchedule } from '../api';
jest.mock('./use_find_schedules');
jest.mock('../api');
jest.mock('../../../../../common/hooks/use_app_toasts');
const createAttackDiscoveryScheduleMock = createAttackDiscoverySchedule as jest.MockedFunction<
typeof createAttackDiscoverySchedule
>;
const invalidateFindAttackDiscoveryScheduleMock = jest.fn();
const mockUseInvalidateFindAttackDiscoverySchedule =
useInvalidateFindAttackDiscoverySchedule as jest.MockedFunction<
typeof useInvalidateFindAttackDiscoverySchedule
>;
describe('useCreateAttackDiscoverySchedule', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
createAttackDiscoveryScheduleMock.mockReturnValue(
{} as unknown as jest.Mocked<ReturnType<typeof createAttackDiscoverySchedule>>
);
mockUseInvalidateFindAttackDiscoverySchedule.mockReturnValue(
invalidateFindAttackDiscoveryScheduleMock as unknown as jest.Mocked<
ReturnType<typeof useInvalidateFindAttackDiscoverySchedule>
>
);
});
it('should invoke `createAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useCreateAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ scheduleToCreate: mockCreateAttackDiscoverySchedule });
expect(createAttackDiscoveryScheduleMock).toHaveBeenCalledWith({
body: mockCreateAttackDiscoverySchedule,
});
});
});
it('should invoke `addSuccess`', async () => {
const result = await renderMutation(() => useCreateAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ scheduleToCreate: mockCreateAttackDiscoverySchedule });
expect(appToastsMock.addSuccess).toHaveBeenCalledWith(
'1 attack discovery schedule created successfully.'
);
});
});
it('should invoke `invalidateFindAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useCreateAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ scheduleToCreate: mockCreateAttackDiscoverySchedule });
expect(invalidateFindAttackDiscoveryScheduleMock).toHaveBeenCalled();
});
});
it('should invoke `addError`', async () => {
createAttackDiscoveryScheduleMock.mockRejectedValue('Royally failed!');
const result = await renderMutation(() => useCreateAttackDiscoverySchedule());
await act(async () => {
try {
await result.mutateAsync({ scheduleToCreate: mockCreateAttackDiscoverySchedule });
} catch (err) {
expect(appToastsMock.addError).toHaveBeenCalledWith('Royally failed!', {
title: 'Failed to create 1 attack discovery schedule',
});
}
});
});
});

View file

@ -0,0 +1,45 @@
/*
* 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 { useMutation } from '@tanstack/react-query';
import type {
AttackDiscoveryScheduleCreateProps,
CreateAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import { ATTACK_DISCOVERY_SCHEDULES } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { createAttackDiscoverySchedule } from '../api';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
export const CREATE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY = ['POST', ATTACK_DISCOVERY_SCHEDULES];
interface CreateAttackDiscoveryScheduleParams {
scheduleToCreate: AttackDiscoveryScheduleCreateProps;
}
export const useCreateAttackDiscoverySchedule = () => {
const { addError, addSuccess } = useAppToasts();
const invalidateFindAttackDiscoverySchedule = useInvalidateFindAttackDiscoverySchedule();
return useMutation<
CreateAttackDiscoverySchedulesResponse,
Error,
CreateAttackDiscoveryScheduleParams
>(({ scheduleToCreate }) => createAttackDiscoverySchedule({ body: scheduleToCreate }), {
mutationKey: CREATE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY,
onSuccess: () => {
invalidateFindAttackDiscoverySchedule();
addSuccess(i18n.CREATE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS());
},
onError: (error) => {
addError(error, { title: i18n.CREATE_ATTACK_DISCOVERY_SCHEDULES_FAILURE() });
},
});
};

View file

@ -0,0 +1,117 @@
/*
* 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 { act } from '@testing-library/react';
import { useDeleteAttackDiscoverySchedule } from './use_delete_schedule';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock';
import { renderMutation } from '../../../../../management/hooks/test_utils';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { deleteAttackDiscoverySchedule } from '../api';
import { useInvalidateGetAttackDiscoverySchedule } from './use_get_schedule';
jest.mock('./use_find_schedules');
jest.mock('./use_get_schedule');
jest.mock('../api');
jest.mock('../../../../../common/hooks/use_app_toasts');
const deleteAttackDiscoveryScheduleMock = deleteAttackDiscoverySchedule as jest.MockedFunction<
typeof deleteAttackDiscoverySchedule
>;
const invalidateFindAttackDiscoveryScheduleMock = jest.fn();
const mockUseInvalidateFindAttackDiscoverySchedule =
useInvalidateFindAttackDiscoverySchedule as jest.MockedFunction<
typeof useInvalidateFindAttackDiscoverySchedule
>;
const invalidateGetAttackDiscoveryScheduleMock = jest.fn();
const mockUseInvalidateGetAttackDiscoverySchedule =
useInvalidateGetAttackDiscoverySchedule as jest.MockedFunction<
typeof useInvalidateGetAttackDiscoverySchedule
>;
describe('useDeleteAttackDiscoverySchedule', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
deleteAttackDiscoveryScheduleMock.mockReturnValue(
{} as unknown as jest.Mocked<ReturnType<typeof deleteAttackDiscoverySchedule>>
);
mockUseInvalidateFindAttackDiscoverySchedule.mockReturnValue(
invalidateFindAttackDiscoveryScheduleMock as unknown as jest.Mocked<
ReturnType<typeof useInvalidateFindAttackDiscoverySchedule>
>
);
mockUseInvalidateGetAttackDiscoverySchedule.mockReturnValue(
invalidateGetAttackDiscoveryScheduleMock as unknown as jest.Mocked<
ReturnType<typeof useInvalidateGetAttackDiscoverySchedule>
>
);
});
it('should invoke `deleteAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useDeleteAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-0' });
expect(deleteAttackDiscoveryScheduleMock).toHaveBeenCalledWith({ id: 'test-0' });
});
});
it('should invoke `addSuccess`', async () => {
const result = await renderMutation(() => useDeleteAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-1' });
expect(appToastsMock.addSuccess).toHaveBeenCalledWith(
'1 attack discovery schedule deleted successfully.'
);
});
});
it('should invoke `invalidateFindAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useDeleteAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-2' });
expect(invalidateFindAttackDiscoveryScheduleMock).toHaveBeenCalled();
});
});
it('should invoke `invalidateGetAttackDiscoveryScheduleMock`', async () => {
const result = await renderMutation(() => useDeleteAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-3' });
expect(invalidateGetAttackDiscoveryScheduleMock).toHaveBeenCalled();
});
});
it('should invoke `addError`', async () => {
deleteAttackDiscoveryScheduleMock.mockRejectedValue('Royally failed!');
const result = await renderMutation(() => useDeleteAttackDiscoverySchedule());
await act(async () => {
try {
await result.mutateAsync({ id: 'test-4' });
} catch (err) {
expect(appToastsMock.addError).toHaveBeenCalledWith('Royally failed!', {
title: 'Failed to delete 1 attack discovery schedule',
});
}
});
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 { useMutation } from '@tanstack/react-query';
import type { DeleteAttackDiscoverySchedulesResponse } from '@kbn/elastic-assistant-common';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { deleteAttackDiscoverySchedule } from '../api';
import { useInvalidateGetAttackDiscoverySchedule } from './use_get_schedule';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
export const DELETE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY = [
'DELETE',
ATTACK_DISCOVERY_SCHEDULES_BY_ID,
];
interface DeleteAttackDiscoveryScheduleParams {
id: string;
}
export const useDeleteAttackDiscoverySchedule = () => {
const { addError, addSuccess } = useAppToasts();
const invalidateGetAttackDiscoverySchedule = useInvalidateGetAttackDiscoverySchedule();
const invalidateFindAttackDiscoverySchedule = useInvalidateFindAttackDiscoverySchedule();
return useMutation<
DeleteAttackDiscoverySchedulesResponse,
Error,
DeleteAttackDiscoveryScheduleParams
>(({ id }) => deleteAttackDiscoverySchedule({ id }), {
mutationKey: DELETE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY,
onSuccess: ({ id }) => {
invalidateGetAttackDiscoverySchedule(id);
invalidateFindAttackDiscoverySchedule();
addSuccess(i18n.DELETE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS());
},
onError: (error) => {
addError(error, { title: i18n.DELETE_ATTACK_DISCOVERY_SCHEDULES_FAILURE() });
},
});
};

View file

@ -0,0 +1,117 @@
/*
* 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 { act } from '@testing-library/react';
import { useDisableAttackDiscoverySchedule } from './use_disable_schedule';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock';
import { renderMutation } from '../../../../../management/hooks/test_utils';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { disableAttackDiscoverySchedule } from '../api';
import { useInvalidateGetAttackDiscoverySchedule } from './use_get_schedule';
jest.mock('./use_find_schedules');
jest.mock('./use_get_schedule');
jest.mock('../api');
jest.mock('../../../../../common/hooks/use_app_toasts');
const disableAttackDiscoveryScheduleMock = disableAttackDiscoverySchedule as jest.MockedFunction<
typeof disableAttackDiscoverySchedule
>;
const invalidateFindAttackDiscoveryScheduleMock = jest.fn();
const mockUseInvalidateFindAttackDiscoverySchedule =
useInvalidateFindAttackDiscoverySchedule as jest.MockedFunction<
typeof useInvalidateFindAttackDiscoverySchedule
>;
const invalidateGetAttackDiscoveryScheduleMock = jest.fn();
const mockUseInvalidateGetAttackDiscoverySchedule =
useInvalidateGetAttackDiscoverySchedule as jest.MockedFunction<
typeof useInvalidateGetAttackDiscoverySchedule
>;
describe('useDisableAttackDiscoverySchedule', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
disableAttackDiscoveryScheduleMock.mockReturnValue(
{} as unknown as jest.Mocked<ReturnType<typeof disableAttackDiscoverySchedule>>
);
mockUseInvalidateFindAttackDiscoverySchedule.mockReturnValue(
invalidateFindAttackDiscoveryScheduleMock as unknown as jest.Mocked<
ReturnType<typeof useInvalidateFindAttackDiscoverySchedule>
>
);
mockUseInvalidateGetAttackDiscoverySchedule.mockReturnValue(
invalidateGetAttackDiscoveryScheduleMock as unknown as jest.Mocked<
ReturnType<typeof useInvalidateGetAttackDiscoverySchedule>
>
);
});
it('should invoke `disableAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useDisableAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-0' });
expect(disableAttackDiscoveryScheduleMock).toHaveBeenCalledWith({ id: 'test-0' });
});
});
it('should invoke `addSuccess`', async () => {
const result = await renderMutation(() => useDisableAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-1' });
expect(appToastsMock.addSuccess).toHaveBeenCalledWith(
'1 attack discovery schedule disabled successfully.'
);
});
});
it('should invoke `invalidateFindAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useDisableAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-2' });
expect(invalidateFindAttackDiscoveryScheduleMock).toHaveBeenCalled();
});
});
it('should invoke `invalidateGetAttackDiscoveryScheduleMock`', async () => {
const result = await renderMutation(() => useDisableAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-3' });
expect(invalidateGetAttackDiscoveryScheduleMock).toHaveBeenCalled();
});
});
it('should invoke `addError`', async () => {
disableAttackDiscoveryScheduleMock.mockRejectedValue('Royally failed!');
const result = await renderMutation(() => useDisableAttackDiscoverySchedule());
await act(async () => {
try {
await result.mutateAsync({ id: 'test-4' });
} catch (err) {
expect(appToastsMock.addError).toHaveBeenCalledWith('Royally failed!', {
title: 'Failed to disable 1 attack discovery schedule',
});
}
});
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 { useMutation } from '@tanstack/react-query';
import type { DisableAttackDiscoverySchedulesResponse } from '@kbn/elastic-assistant-common';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { disableAttackDiscoverySchedule } from '../api';
import { useInvalidateGetAttackDiscoverySchedule } from './use_get_schedule';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
export const DISABLE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY = [
'POST',
ATTACK_DISCOVERY_SCHEDULES_BY_ID_DISABLE,
];
interface DisableAttackDiscoveryScheduleParams {
id: string;
}
export const useDisableAttackDiscoverySchedule = () => {
const { addError, addSuccess } = useAppToasts();
const invalidateGetAttackDiscoverySchedule = useInvalidateGetAttackDiscoverySchedule();
const invalidateFindAttackDiscoverySchedule = useInvalidateFindAttackDiscoverySchedule();
return useMutation<
DisableAttackDiscoverySchedulesResponse,
Error,
DisableAttackDiscoveryScheduleParams
>(({ id }) => disableAttackDiscoverySchedule({ id }), {
mutationKey: DISABLE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY,
onSuccess: ({ id }) => {
invalidateGetAttackDiscoverySchedule(id);
invalidateFindAttackDiscoverySchedule();
addSuccess(i18n.DISABLE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS());
},
onError: (error) => {
addError(error, { title: i18n.DISABLE_ATTACK_DISCOVERY_SCHEDULES_FAILURE() });
},
});
};

View file

@ -0,0 +1,117 @@
/*
* 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 { act } from '@testing-library/react';
import { useEnableAttackDiscoverySchedule } from './use_enable_schedule';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock';
import { renderMutation } from '../../../../../management/hooks/test_utils';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { enableAttackDiscoverySchedule } from '../api';
import { useInvalidateGetAttackDiscoverySchedule } from './use_get_schedule';
jest.mock('./use_find_schedules');
jest.mock('./use_get_schedule');
jest.mock('../api');
jest.mock('../../../../../common/hooks/use_app_toasts');
const enableAttackDiscoveryScheduleMock = enableAttackDiscoverySchedule as jest.MockedFunction<
typeof enableAttackDiscoverySchedule
>;
const invalidateFindAttackDiscoveryScheduleMock = jest.fn();
const mockUseInvalidateFindAttackDiscoverySchedule =
useInvalidateFindAttackDiscoverySchedule as jest.MockedFunction<
typeof useInvalidateFindAttackDiscoverySchedule
>;
const invalidateGetAttackDiscoveryScheduleMock = jest.fn();
const mockUseInvalidateGetAttackDiscoverySchedule =
useInvalidateGetAttackDiscoverySchedule as jest.MockedFunction<
typeof useInvalidateGetAttackDiscoverySchedule
>;
describe('useEnableAttackDiscoverySchedule', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
enableAttackDiscoveryScheduleMock.mockReturnValue(
{} as unknown as jest.Mocked<ReturnType<typeof enableAttackDiscoverySchedule>>
);
mockUseInvalidateFindAttackDiscoverySchedule.mockReturnValue(
invalidateFindAttackDiscoveryScheduleMock as unknown as jest.Mocked<
ReturnType<typeof useInvalidateFindAttackDiscoverySchedule>
>
);
mockUseInvalidateGetAttackDiscoverySchedule.mockReturnValue(
invalidateGetAttackDiscoveryScheduleMock as unknown as jest.Mocked<
ReturnType<typeof useInvalidateGetAttackDiscoverySchedule>
>
);
});
it('should invoke `enableAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useEnableAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-0' });
expect(enableAttackDiscoveryScheduleMock).toHaveBeenCalledWith({ id: 'test-0' });
});
});
it('should invoke `addSuccess`', async () => {
const result = await renderMutation(() => useEnableAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-1' });
expect(appToastsMock.addSuccess).toHaveBeenCalledWith(
'1 attack discovery schedule enabled successfully.'
);
});
});
it('should invoke `invalidateFindAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useEnableAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-2' });
expect(invalidateFindAttackDiscoveryScheduleMock).toHaveBeenCalled();
});
});
it('should invoke `invalidateGetAttackDiscoveryScheduleMock`', async () => {
const result = await renderMutation(() => useEnableAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-3' });
expect(invalidateGetAttackDiscoveryScheduleMock).toHaveBeenCalled();
});
});
it('should invoke `addError`', async () => {
enableAttackDiscoveryScheduleMock.mockRejectedValue('Royally failed!');
const result = await renderMutation(() => useEnableAttackDiscoverySchedule());
await act(async () => {
try {
await result.mutateAsync({ id: 'test-4' });
} catch (err) {
expect(appToastsMock.addError).toHaveBeenCalledWith('Royally failed!', {
title: 'Failed to enable 1 attack discovery schedule',
});
}
});
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 { useMutation } from '@tanstack/react-query';
import type { EnableAttackDiscoverySchedulesResponse } from '@kbn/elastic-assistant-common';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { enableAttackDiscoverySchedule } from '../api';
import { useInvalidateGetAttackDiscoverySchedule } from './use_get_schedule';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
export const ENABLE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY = [
'POST',
ATTACK_DISCOVERY_SCHEDULES_BY_ID_ENABLE,
];
interface EnableAttackDiscoveryScheduleParams {
id: string;
}
export const useEnableAttackDiscoverySchedule = () => {
const { addError, addSuccess } = useAppToasts();
const invalidateGetAttackDiscoverySchedule = useInvalidateGetAttackDiscoverySchedule();
const invalidateFindAttackDiscoverySchedule = useInvalidateFindAttackDiscoverySchedule();
return useMutation<
EnableAttackDiscoverySchedulesResponse,
Error,
EnableAttackDiscoveryScheduleParams
>(({ id }) => enableAttackDiscoverySchedule({ id }), {
mutationKey: ENABLE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY,
onSuccess: ({ id }) => {
invalidateGetAttackDiscoverySchedule(id);
invalidateFindAttackDiscoverySchedule();
addSuccess(i18n.ENABLE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS());
},
onError: (error) => {
addError(error, { title: i18n.ENABLE_ATTACK_DISCOVERY_SCHEDULES_FAILURE() });
},
});
};

View file

@ -0,0 +1,53 @@
/*
* 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 { useFindAttackDiscoverySchedules } from './use_find_schedules';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock';
import { renderQuery } from '../../../../../management/hooks/test_utils';
import { findAttackDiscoverySchedule } from '../api';
jest.mock('../api');
jest.mock('../../../../../common/hooks/use_app_toasts');
const findAttackDiscoveryScheduleMock = findAttackDiscoverySchedule as jest.MockedFunction<
typeof findAttackDiscoverySchedule
>;
describe('useFindAttackDiscoverySchedules', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
findAttackDiscoveryScheduleMock.mockReturnValue(
{} as unknown as jest.Mocked<ReturnType<typeof findAttackDiscoverySchedule>>
);
});
it('should invoke `findAttackDiscoverySchedule`', async () => {
await renderQuery(() => useFindAttackDiscoverySchedules({ page: 1 }), 'isSuccess');
expect(findAttackDiscoveryScheduleMock).toHaveBeenCalledWith({
page: 1,
signal: expect.anything(),
});
});
it('should invoke `addError`', async () => {
findAttackDiscoveryScheduleMock.mockRejectedValue('Royally failed!');
await renderQuery(() => useFindAttackDiscoverySchedules({ page: 2 }), 'isError');
expect(appToastsMock.addError).toHaveBeenCalledWith('Royally failed!', {
title: 'Failed to fetch attack discovery schedules',
});
});
});

View file

@ -0,0 +1,59 @@
/*
* 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 } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ATTACK_DISCOVERY_SCHEDULES_FIND } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { findAttackDiscoverySchedule } from '../api';
import { DEFAULT_QUERY_OPTIONS } from './constants';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
export const useFindAttackDiscoverySchedules = (params?: {
page?: number;
perPage?: number;
sortField?: string;
sortDirection?: 'asc' | 'desc';
}) => {
const { addError } = useAppToasts();
return useQuery(
['GET', ATTACK_DISCOVERY_SCHEDULES_FIND, params],
async ({ signal }) => {
const response = await findAttackDiscoverySchedule({ signal, ...params });
return { schedules: response.data, total: response.total };
},
{
...DEFAULT_QUERY_OPTIONS,
onError: (error) => {
addError(error, { title: i18n.FETCH_ATTACK_DISCOVERY_SCHEDULES_FAILURE(false) });
},
}
);
};
/**
* We should use this hook to invalidate the attack discovery schedule cache. For
* example, attack discovery schedule mutations, like create a schedule, should lead to cache invalidation.
*
* @returns A attack discovery schedule cache invalidation callback
*/
export const useInvalidateFindAttackDiscoverySchedule = () => {
const queryClient = useQueryClient();
return useCallback(() => {
/**
* Invalidate all queries that start with ATTACK_DISCOVERY_SCHEDULES_FIND. This
* includes the in-memory query cache and paged query cache.
*/
queryClient.invalidateQueries(['GET', ATTACK_DISCOVERY_SCHEDULES_FIND], {
refetchType: 'active',
});
}, [queryClient]);
};

View file

@ -0,0 +1,53 @@
/*
* 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 { useGetAttackDiscoverySchedule } from './use_get_schedule';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock';
import { renderQuery } from '../../../../../management/hooks/test_utils';
import { getAttackDiscoverySchedule } from '../api';
jest.mock('../api');
jest.mock('../../../../../common/hooks/use_app_toasts');
const getAttackDiscoveryScheduleMock = getAttackDiscoverySchedule as jest.MockedFunction<
typeof getAttackDiscoverySchedule
>;
describe('useGetAttackDiscoverySchedule', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
getAttackDiscoveryScheduleMock.mockReturnValue(
{} as unknown as jest.Mocked<ReturnType<typeof getAttackDiscoverySchedule>>
);
});
it('should invoke `getAttackDiscoverySchedule`', async () => {
await renderQuery(() => useGetAttackDiscoverySchedule({ id: 'test-1' }), 'isSuccess');
expect(getAttackDiscoveryScheduleMock).toHaveBeenCalledWith({
id: 'test-1',
signal: expect.anything(),
});
});
it('should invoke `addError`', async () => {
getAttackDiscoveryScheduleMock.mockRejectedValue('Royally failed!');
await renderQuery(() => useGetAttackDiscoverySchedule({ id: 'test-2' }), 'isError');
expect(appToastsMock.addError).toHaveBeenCalledWith('Royally failed!', {
title: 'Failed to fetch attack discovery schedule',
});
});
});

View file

@ -0,0 +1,63 @@
/*
* 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 } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { replaceParams } from '@kbn/openapi-common/shared';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { getAttackDiscoverySchedule } from '../api';
import { DEFAULT_QUERY_OPTIONS } from './constants';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
export const useGetAttackDiscoverySchedule = (params: { id: string }) => {
const { addError } = useAppToasts();
const { id } = params;
const SPECIFIC_PATH = replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID, { id });
return useQuery(
['GET', SPECIFIC_PATH, params],
async ({ signal }) => {
const response = await getAttackDiscoverySchedule({ signal, ...params });
return { schedule: response };
},
{
...DEFAULT_QUERY_OPTIONS,
onError: (error) => {
addError(error, { title: i18n.FETCH_ATTACK_DISCOVERY_SCHEDULES_FAILURE() });
},
}
);
};
/**
* We should use this hook to invalidate the attack discovery schedule cache. For
* example, attack discovery schedule mutations, like create a schedule, should lead to cache invalidation.
*
* @returns A attack discovery schedule cache invalidation callback
*/
export const useInvalidateGetAttackDiscoverySchedule = () => {
const queryClient = useQueryClient();
return useCallback(
(id: string) => {
const SPECIFIC_PATH = replaceParams(ATTACK_DISCOVERY_SCHEDULES_BY_ID, { id });
/**
* Invalidate all queries that start with SPECIFIC_PATH. This
* includes the in-memory query cache and paged query cache.
*/
queryClient.invalidateQueries(['GET', SPECIFIC_PATH], {
refetchType: 'active',
});
},
[queryClient]
);
};

View file

@ -0,0 +1,117 @@
/*
* 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 { act } from '@testing-library/react';
import { useUpdateAttackDiscoverySchedule } from './use_update_schedule';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../../common/hooks/use_app_toasts.mock';
import { renderMutation } from '../../../../../management/hooks/test_utils';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { updateAttackDiscoverySchedule } from '../api';
import { useInvalidateGetAttackDiscoverySchedule } from './use_get_schedule';
jest.mock('./use_find_schedules');
jest.mock('./use_get_schedule');
jest.mock('../api');
jest.mock('../../../../../common/hooks/use_app_toasts');
const updateAttackDiscoveryScheduleMock = updateAttackDiscoverySchedule as jest.MockedFunction<
typeof updateAttackDiscoverySchedule
>;
const invalidateFindAttackDiscoveryScheduleMock = jest.fn();
const mockUseInvalidateFindAttackDiscoverySchedule =
useInvalidateFindAttackDiscoverySchedule as jest.MockedFunction<
typeof useInvalidateFindAttackDiscoverySchedule
>;
const invalidateGetAttackDiscoveryScheduleMock = jest.fn();
const mockUseInvalidateGetAttackDiscoverySchedule =
useInvalidateGetAttackDiscoverySchedule as jest.MockedFunction<
typeof useInvalidateGetAttackDiscoverySchedule
>;
describe('useUpdateAttackDiscoverySchedule', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
beforeEach(() => {
jest.clearAllMocks();
appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
updateAttackDiscoveryScheduleMock.mockReturnValue(
{} as unknown as jest.Mocked<ReturnType<typeof updateAttackDiscoverySchedule>>
);
mockUseInvalidateFindAttackDiscoverySchedule.mockReturnValue(
invalidateFindAttackDiscoveryScheduleMock as unknown as jest.Mocked<
ReturnType<typeof useInvalidateFindAttackDiscoverySchedule>
>
);
mockUseInvalidateGetAttackDiscoverySchedule.mockReturnValue(
invalidateGetAttackDiscoveryScheduleMock as unknown as jest.Mocked<
ReturnType<typeof useInvalidateGetAttackDiscoverySchedule>
>
);
});
it('should invoke `updateAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useUpdateAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-0' });
expect(updateAttackDiscoveryScheduleMock).toHaveBeenCalledWith({ id: 'test-0' });
});
});
it('should invoke `addSuccess`', async () => {
const result = await renderMutation(() => useUpdateAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-1' });
expect(appToastsMock.addSuccess).toHaveBeenCalledWith(
'1 attack discovery schedule updated successfully.'
);
});
});
it('should invoke `invalidateFindAttackDiscoverySchedule`', async () => {
const result = await renderMutation(() => useUpdateAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-2' });
expect(invalidateFindAttackDiscoveryScheduleMock).toHaveBeenCalled();
});
});
it('should invoke `invalidateGetAttackDiscoveryScheduleMock`', async () => {
const result = await renderMutation(() => useUpdateAttackDiscoverySchedule());
await act(async () => {
await result.mutateAsync({ id: 'test-3' });
expect(invalidateGetAttackDiscoveryScheduleMock).toHaveBeenCalled();
});
});
it('should invoke `addError`', async () => {
updateAttackDiscoveryScheduleMock.mockRejectedValue('Royally failed!');
const result = await renderMutation(() => useUpdateAttackDiscoverySchedule());
await act(async () => {
try {
await result.mutateAsync({ id: 'test-4' });
} catch (err) {
expect(appToastsMock.addError).toHaveBeenCalledWith('Royally failed!', {
title: 'Failed to update 1 attack discovery schedule',
});
}
});
});
});

View file

@ -0,0 +1,52 @@
/*
* 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 { useMutation } from '@tanstack/react-query';
import type {
AttackDiscoveryScheduleUpdateProps,
UpdateAttackDiscoverySchedulesResponse,
} from '@kbn/elastic-assistant-common';
import { ATTACK_DISCOVERY_SCHEDULES_BY_ID } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { updateAttackDiscoverySchedule } from '../api';
import { useInvalidateGetAttackDiscoverySchedule } from './use_get_schedule';
import { useInvalidateFindAttackDiscoverySchedule } from './use_find_schedules';
import { useAppToasts } from '../../../../../common/hooks/use_app_toasts';
export const UPDATE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY = [
'PUT',
ATTACK_DISCOVERY_SCHEDULES_BY_ID,
];
interface UpdateAttackDiscoveryScheduleParams {
id: string;
scheduleToUpdate: AttackDiscoveryScheduleUpdateProps;
}
export const useUpdateAttackDiscoverySchedule = () => {
const { addError, addSuccess } = useAppToasts();
const invalidateGetAttackDiscoverySchedule = useInvalidateGetAttackDiscoverySchedule();
const invalidateFindAttackDiscoverySchedule = useInvalidateFindAttackDiscoverySchedule();
return useMutation<
UpdateAttackDiscoverySchedulesResponse,
Error,
UpdateAttackDiscoveryScheduleParams
>(({ id, scheduleToUpdate }) => updateAttackDiscoverySchedule({ id, body: scheduleToUpdate }), {
mutationKey: UPDATE_ATTACK_DISCOVERY_SCHEDULE_MUTATION_KEY,
onSuccess: ({ id }) => {
invalidateGetAttackDiscoverySchedule(id);
invalidateFindAttackDiscoverySchedule();
addSuccess(i18n.UPDATE_ATTACK_DISCOVERY_SCHEDULES_SUCCESS());
},
onError: (error) => {
addError(error, { title: i18n.UPDATE_ATTACK_DISCOVERY_SCHEDULES_FAILURE() });
},
});
};

View file

@ -0,0 +1,41 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import type { EuiTableFieldDataColumnType } from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import { createActionsColumn } from './actions';
import { TestProviders } from '../../../../../../common/mock';
import { mockAttackDiscoverySchedule } from '../../../../mock/mock_attack_discovery_schedule';
const deleteScheduleMock = jest.fn();
describe('Actions Column', () => {
beforeEach(() => {
jest.clearAllMocks();
const column = createActionsColumn({
isDisabled: false,
deleteSchedule: deleteScheduleMock,
}) as EuiTableFieldDataColumnType<AttackDiscoverySchedule>;
render(<TestProviders>{column.render?.('', mockAttackDiscoverySchedule)}</TestProviders>);
});
it('should render delete button', () => {
expect(screen.getByTestId('deleteButton')).toBeInTheDocument();
});
it('should invoke `deleteSchedule` when the delete button is clicked', async () => {
const deleteButton = screen.getByTestId('deleteButton');
fireEvent.click(deleteButton);
expect(deleteScheduleMock).toHaveBeenCalledWith(mockAttackDiscoverySchedule.id);
});
});

View file

@ -0,0 +1,58 @@
/*
* 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, { useCallback } from 'react';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import type { TableColumn } from './constants';
interface ActionProps {
deleteSchedule: (scheduleId: string) => Promise<void>;
isDisabled: boolean;
scheduleId: string;
}
const Action = ({ isDisabled, deleteSchedule, scheduleId }: ActionProps) => {
const onScheduleDeleteChange = useCallback(async () => {
deleteSchedule(scheduleId);
}, [deleteSchedule, scheduleId]);
return (
<EuiFlexGroup alignItems="center" justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="deleteButton"
aria-label={i18n.DELETE_ACTIONS_BUTTON_ARIAL_LABEL}
color="danger"
iconType="trash"
onClick={onScheduleDeleteChange}
disabled={isDisabled}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const createActionsColumn = ({
isDisabled,
deleteSchedule,
}: {
isDisabled: boolean;
deleteSchedule: (scheduleId: string) => Promise<void>;
}): TableColumn => {
return {
field: 'delete',
name: i18n.COLUMN_ACTIONS,
render: (_, schedule: AttackDiscoverySchedule) => (
<Action isDisabled={isDisabled} deleteSchedule={deleteSchedule} scheduleId={schedule.id} />
),
width: '65px',
align: 'center',
};
};

View file

@ -0,0 +1,11 @@
/*
* 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 type { EuiBasicTableColumn } from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
export type TableColumn = EuiBasicTableColumn<AttackDiscoverySchedule>;

View file

@ -0,0 +1,70 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import type { EuiTableFieldDataColumnType } from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import { createEnableColumn } from './enable';
import { TestProviders } from '../../../../../../common/mock';
import { mockAttackDiscoverySchedule } from '../../../../mock/mock_attack_discovery_schedule';
const onSwitchChangeMock = jest.fn();
const renderEnabledSchedule = (enabled = true) => {
const column = createEnableColumn({
isDisabled: false,
isLoading: false,
onSwitchChange: onSwitchChangeMock,
}) as EuiTableFieldDataColumnType<AttackDiscoverySchedule>;
render(
<TestProviders>
{column.render?.('', { ...mockAttackDiscoverySchedule, enabled })}
</TestProviders>
);
};
describe('Enable Column', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render enable button', () => {
renderEnabledSchedule();
expect(screen.getByTestId('scheduleSwitch')).toBeInTheDocument();
});
it('should render enable button as checked if schedule is enabled', () => {
renderEnabledSchedule(true);
expect(screen.getByTestId('scheduleSwitch')).toBeChecked();
});
it('should render enable button as not-checked if schedule is enabled', () => {
renderEnabledSchedule(false);
expect(screen.getByTestId('scheduleSwitch')).not.toBeChecked();
});
it('should invoke `onSwitchChange` with correct parameters for the enabled schedule', async () => {
renderEnabledSchedule(true);
const deleteButton = screen.getByTestId('scheduleSwitch');
fireEvent.click(deleteButton);
expect(onSwitchChangeMock).toHaveBeenCalledWith(mockAttackDiscoverySchedule.id, false);
});
it('should invoke `onSwitchChange` with correct parameters for the disabled schedule', async () => {
renderEnabledSchedule(false);
const deleteButton = screen.getByTestId('scheduleSwitch');
fireEvent.click(deleteButton);
expect(onSwitchChangeMock).toHaveBeenCalledWith(mockAttackDiscoverySchedule.id, true);
});
});

View file

@ -0,0 +1,98 @@
/*
* 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, { useCallback, useMemo, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiSwitch,
type EuiSwitchEvent,
} from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import type { TableColumn } from './constants';
interface EnableSwitchProps {
enabled: boolean;
isDisabled: boolean;
isLoading: boolean;
onSwitchChange: (scheduleId: string, enabled: boolean) => Promise<void>;
scheduleId: string;
}
const EnableSwitch = ({
enabled,
isDisabled,
isLoading,
onSwitchChange,
scheduleId,
}: EnableSwitchProps) => {
const [myIsLoading, setMyIsLoading] = useState(false);
const onScheduleStateChange = useCallback(
async (event: EuiSwitchEvent) => {
setMyIsLoading(true);
await onSwitchChange(scheduleId, !enabled);
setMyIsLoading(false);
},
[enabled, onSwitchChange, scheduleId]
);
const showLoader = useMemo((): boolean => {
if (myIsLoading !== isLoading) {
return isLoading || myIsLoading;
}
return myIsLoading;
}, [myIsLoading, isLoading]);
return (
<EuiFlexGroup alignItems="center" justifyContent="spaceAround">
<EuiFlexItem grow={false}>
{showLoader ? (
<EuiLoadingSpinner size="m" data-test-subj="scheduleSwitchLoader" />
) : (
<EuiSwitch
data-test-subj="scheduleSwitch"
showLabel={false}
label=""
disabled={isDisabled}
checked={enabled}
onChange={onScheduleStateChange}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const createEnableColumn = ({
isDisabled,
isLoading,
onSwitchChange,
}: {
isDisabled: boolean;
isLoading: boolean;
onSwitchChange: (scheduleId: string, enabled: boolean) => Promise<void>;
}): TableColumn => {
return {
field: 'enabled',
name: i18n.COLUMN_ENABLE,
render: (_, schedule: AttackDiscoverySchedule) => (
<EnableSwitch
enabled={schedule.enabled}
isDisabled={isDisabled}
isLoading={isLoading}
onSwitchChange={onSwitchChange}
scheduleId={schedule.id}
/>
),
width: '65px',
align: 'center',
};
};

View file

@ -5,10 +5,9 @@
* 2.0.
*/
import React from 'react';
import { EmptySchedule } from './empty_schedule';
export * from './constants';
export const Schedule: React.FC = React.memo(() => {
return <EmptySchedule />;
});
Schedule.displayName = 'Schedule';
export * from './actions';
export * from './enable';
export * from './name';
export * from './status';

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 React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { EuiTableFieldDataColumnType } from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import { createNameColumn } from './name';
import { TestProviders } from '../../../../../../common/mock';
import { mockAttackDiscoverySchedule } from '../../../../mock/mock_attack_discovery_schedule';
const openScheduleDetailsMock = jest.fn();
describe('Name Column', () => {
beforeEach(() => {
jest.clearAllMocks();
const column = createNameColumn({
openScheduleDetails: openScheduleDetailsMock,
}) as EuiTableFieldDataColumnType<AttackDiscoverySchedule>;
render(<TestProviders>{column.render?.('', mockAttackDiscoverySchedule)}</TestProviders>);
});
it('should render schedule details link', () => {
expect(screen.getByTestId('scheduleName')).toBeInTheDocument();
});
it('should invoke `openScheduleDetails` when the name link is clicked', async () => {
const detailsLink = screen.getByTestId('scheduleName');
fireEvent.click(detailsLink);
expect(openScheduleDetailsMock).toHaveBeenCalledWith(mockAttackDiscoverySchedule.id);
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 { EuiLink } from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import type { TableColumn } from './constants';
interface NameProps {
schedule: AttackDiscoverySchedule;
openScheduleDetails: (scheduleId: string) => void;
}
const Name = ({ schedule, openScheduleDetails }: NameProps) => {
return (
<EuiLink
onClick={() => {
openScheduleDetails(schedule.id);
}}
data-test-subj="scheduleName"
>
{schedule.name}
</EuiLink>
);
};
export const createNameColumn = ({
openScheduleDetails,
}: {
openScheduleDetails: (scheduleId: string) => void;
}): TableColumn => {
return {
field: 'name',
name: i18n.COLUMN_NAME,
render: (_, schedule: AttackDiscoverySchedule) => (
<Name schedule={schedule} openScheduleDetails={openScheduleDetails} />
),
truncateText: true,
width: '60%',
align: 'left',
};
};

View file

@ -0,0 +1,29 @@
/*
* 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, screen } from '@testing-library/react';
import type { EuiTableFieldDataColumnType } from '@elastic/eui';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import { createStatusColumn } from './status';
import { TestProviders } from '../../../../../../common/mock';
import { mockAttackDiscoverySchedule } from '../../../../mock/mock_attack_discovery_schedule';
describe('Status Column', () => {
beforeEach(() => {
jest.clearAllMocks();
const column = createStatusColumn() as EuiTableFieldDataColumnType<AttackDiscoverySchedule>;
render(<TestProviders>{column.render?.('', mockAttackDiscoverySchedule)}</TestProviders>);
});
it('should render schedule execution status', () => {
expect(screen.getByTestId('scheduleExecutionStatus')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,23 @@
/*
* 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 type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import type { TableColumn } from './constants';
import { StatusBadge } from './status_badge';
export const createStatusColumn = (): TableColumn => {
return {
field: 'status',
name: i18n.COLUMN_STATUS,
render: (_, schedule: AttackDiscoverySchedule) => <StatusBadge schedule={schedule} />,
truncateText: true,
width: '100px',
};
};

View file

@ -0,0 +1,89 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import { StatusBadge } from '.';
import { TestProviders } from '../../../../../../../common/mock';
import { mockAttackDiscoverySchedule } from '../../../../../mock/mock_attack_discovery_schedule';
import { waitForEuiToolTipVisible } from '@elastic/eui/lib/test/rtl';
const renderScheduleStatus = (
status: 'unknown' | 'ok' | 'active' | 'error' | 'warning' = 'ok',
message?: string
) => {
const lastExecution = {
date: new Date().toISOString(),
status,
duration: 26,
message,
};
render(
<TestProviders>
<StatusBadge schedule={{ ...mockAttackDiscoverySchedule, lastExecution }} />
</TestProviders>
);
};
describe('StatusBadge', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render schedule execution status', () => {
renderScheduleStatus();
expect(screen.getByTestId('scheduleExecutionStatus')).toBeInTheDocument();
});
it('should render correct label for `ok` status', () => {
renderScheduleStatus('ok');
expect(screen.getByTestId('scheduleExecutionStatus')).toHaveTextContent('Success');
});
it('should render correct label for `active` status', () => {
renderScheduleStatus('active');
expect(screen.getByTestId('scheduleExecutionStatus')).toHaveTextContent('Success');
});
it('should render correct label for `error` status', () => {
renderScheduleStatus('error');
expect(screen.getByTestId('scheduleExecutionStatus')).toHaveTextContent('Failed');
});
it('should render correct label for `warning` status', () => {
renderScheduleStatus('warning');
expect(screen.getByTestId('scheduleExecutionStatus')).toHaveTextContent('Warning');
});
it('should render correct label for `unknown` status', () => {
renderScheduleStatus('unknown');
expect(screen.getByTestId('scheduleExecutionStatus')).toHaveTextContent('Unknown');
});
it('should render execution status as a tooltip if execution message is not set', async () => {
renderScheduleStatus('error');
const status = screen.getByTestId('scheduleExecutionStatus');
fireEvent.mouseOver(status.parentElement as Node);
await waitForEuiToolTipVisible();
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toHaveTextContent('Failed');
});
it('should render existing execution message as a tooltip', async () => {
renderScheduleStatus('error', 'Failed badly!');
const status = screen.getByTestId('scheduleExecutionStatus');
fireEvent.mouseOver(status.parentElement as Node);
await waitForEuiToolTipVisible();
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toHaveTextContent('Failed badly!');
});
});

View file

@ -0,0 +1,81 @@
/*
* 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 type { EuiThemeComputed } from '@elastic/eui';
import { EuiHealth, EuiToolTip, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
import type {
AttackDiscoverySchedule,
AttackDiscoveryScheduleExecutionStatus,
} from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
const statusTextWrapperClassName = css`
width: 100%;
display: inline-grid;
`;
const getExecutionStatusHealthColor = (
status: AttackDiscoveryScheduleExecutionStatus,
euiTheme: EuiThemeComputed
) => {
switch (status) {
case 'active':
case 'ok':
return euiTheme.colors.success;
case 'error':
return euiTheme.colors.danger;
case 'warning':
return euiTheme.colors.warning;
default:
return 'subdued';
}
};
const getExecutionStatusLabel = (status: AttackDiscoveryScheduleExecutionStatus) => {
switch (status) {
case 'active':
case 'ok':
return i18n.STATUS_SUCCESS;
case 'error':
return i18n.STATUS_FAILED;
case 'warning':
return i18n.STATUS_WARNING;
default:
return i18n.STATUS_UNKNOWN;
}
};
interface StatusBadgeProps {
schedule: AttackDiscoverySchedule;
}
export const StatusBadge: React.FC<StatusBadgeProps> = React.memo(({ schedule }) => {
const { euiTheme } = useEuiTheme();
if (!schedule.lastExecution) {
return null;
}
const executionStatus = schedule.lastExecution.status;
const executionMessage = schedule.lastExecution.message;
const label = getExecutionStatusLabel(executionStatus);
const color = getExecutionStatusHealthColor(executionStatus, euiTheme);
return (
<EuiToolTip content={executionMessage ?? label}>
<EuiHealth color={color} data-test-subj={'scheduleExecutionStatus'}>
<div className={statusTextWrapperClassName}>
<span className="eui-textTruncate">{label}</span>
</div>
</EuiHealth>
</EuiToolTip>
);
});
StatusBadge.displayName = 'StatusBadge';

View file

@ -0,0 +1,36 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const STATUS_SUCCESS = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.tableColumn.status.successLabel',
{
defaultMessage: 'Success',
}
);
export const STATUS_FAILED = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.tableColumn.status.failedLabel',
{
defaultMessage: 'Failed',
}
);
export const STATUS_WARNING = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.tableColumn.status.warningLabel',
{
defaultMessage: 'Warning',
}
);
export const STATUS_UNKNOWN = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.tableColumn.status.unknownLabel',
{
defaultMessage: 'Unknown',
}
);

View file

@ -0,0 +1,43 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const COLUMN_ACTIONS = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.tableColumn.actionsLabel',
{
defaultMessage: 'Actions',
}
);
export const DELETE_ACTIONS_BUTTON_ARIAL_LABEL = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.tableColumn.deleteActionsArialLabel',
{
defaultMessage: 'Delete schedule',
}
);
export const COLUMN_ENABLE = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.tableColumn.enableHeaderLabel',
{
defaultMessage: 'Enabled',
}
);
export const COLUMN_NAME = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.tableColumn.nameHeaderLabel',
{
defaultMessage: 'Title',
}
);
export const COLUMN_STATUS = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.tableColumn.statusHeaderLabel',
{
defaultMessage: 'Status',
}
);

View file

@ -0,0 +1,130 @@
/*
* 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 { act, fireEvent, render, waitFor } from '@testing-library/react';
import { SchedulesTable } from '.';
import { useFindAttackDiscoverySchedules } from '../logic/use_find_schedules';
import { useEnableAttackDiscoverySchedule } from '../logic/use_enable_schedule';
import { useDisableAttackDiscoverySchedule } from '../logic/use_disable_schedule';
import { useDeleteAttackDiscoverySchedule } from '../logic/use_delete_schedule';
import { mockFindAttackDiscoverySchedules } from '../../../mock/mock_find_attack_discovery_schedules';
jest.mock('../logic/use_find_schedules');
jest.mock('../logic/use_enable_schedule');
jest.mock('../logic/use_disable_schedule');
jest.mock('../logic/use_delete_schedule');
const mockUseFindAttackDiscoverySchedules = useFindAttackDiscoverySchedules as jest.MockedFunction<
typeof useFindAttackDiscoverySchedules
>;
const enableAttackDiscoveryScheduleMock = jest.fn();
const mockUseEnableAttackDiscoverySchedule =
useEnableAttackDiscoverySchedule as jest.MockedFunction<typeof useEnableAttackDiscoverySchedule>;
const disableAttackDiscoveryScheduleMock = jest.fn();
const mockUseDisableAttackDiscoverySchedule =
useDisableAttackDiscoverySchedule as jest.MockedFunction<
typeof useDisableAttackDiscoverySchedule
>;
const deleteAttackDiscoveryScheduleMock = jest.fn();
const mockUseDeleteAttackDiscoverySchedule =
useDeleteAttackDiscoverySchedule as jest.MockedFunction<typeof useDeleteAttackDiscoverySchedule>;
describe('SchedulesTable', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseFindAttackDiscoverySchedules.mockReturnValue({
data: mockFindAttackDiscoverySchedules,
isLoading: false,
} as unknown as jest.Mocked<ReturnType<typeof useFindAttackDiscoverySchedules>>);
mockUseEnableAttackDiscoverySchedule.mockReturnValue({
mutateAsync: enableAttackDiscoveryScheduleMock,
} as unknown as jest.Mocked<ReturnType<typeof useEnableAttackDiscoverySchedule>>);
mockUseDisableAttackDiscoverySchedule.mockReturnValue({
mutateAsync: disableAttackDiscoveryScheduleMock,
} as unknown as jest.Mocked<ReturnType<typeof useDisableAttackDiscoverySchedule>>);
mockUseDeleteAttackDiscoverySchedule.mockReturnValue({
mutateAsync: deleteAttackDiscoveryScheduleMock,
} as unknown as jest.Mocked<ReturnType<typeof useDeleteAttackDiscoverySchedule>>);
});
it('should render the schedules table container', () => {
const { getByTestId } = render(<SchedulesTable />);
expect(getByTestId('schedulesTableContainer')).toBeInTheDocument();
});
it('should render the schedules table description', () => {
const { getByTestId } = render(<SchedulesTable />);
expect(getByTestId('schedulesTableDescription')).toBeInTheDocument();
});
it('should render the correct number of rows in the schedules table', () => {
const { getAllByRole } = render(<SchedulesTable />);
expect(getAllByRole('row').length).toBe(1 + mockFindAttackDiscoverySchedules.schedules.length); // 1 header row + schedule rows
});
it('should invoke delete schedule mutation', async () => {
const { getAllByTestId } = render(<SchedulesTable />);
const firstDeleteButton = getAllByTestId('deleteButton')[0];
act(() => {
fireEvent.click(firstDeleteButton);
});
await waitFor(() => {
expect(deleteAttackDiscoveryScheduleMock).toHaveBeenCalledWith({
id: mockFindAttackDiscoverySchedules.schedules[0].id,
});
});
});
it('should invoke disable schedule mutation', async () => {
const { getAllByTestId } = render(<SchedulesTable />);
const firstSwitchButton = getAllByTestId('scheduleSwitch')[0];
act(() => {
fireEvent.click(firstSwitchButton);
});
await waitFor(() => {
expect(disableAttackDiscoveryScheduleMock).toHaveBeenCalledWith({
id: mockFindAttackDiscoverySchedules.schedules[0].id,
});
});
});
it('should invoke enable schedule mutation', async () => {
const schedules = [
mockFindAttackDiscoverySchedules.schedules[0],
{ ...mockFindAttackDiscoverySchedules.schedules[1], enabled: false },
];
mockUseFindAttackDiscoverySchedules.mockReturnValue({
data: { total: schedules.length, schedules },
isLoading: false,
} as unknown as jest.Mocked<ReturnType<typeof useFindAttackDiscoverySchedules>>);
const { getAllByTestId } = render(<SchedulesTable />);
const secondSwitchButton = getAllByTestId('scheduleSwitch')[1];
act(() => {
fireEvent.click(secondSwitchButton);
});
await waitFor(() => {
expect(enableAttackDiscoveryScheduleMock).toHaveBeenCalledWith({
id: schedules[1].id,
});
});
});
});

View file

@ -0,0 +1,151 @@
/*
* 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 type { CriteriaWithPagination } from '@elastic/eui';
import { EuiSpacer, EuiBasicTable } from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import type { AttackDiscoverySchedule } from '@kbn/elastic-assistant-common';
import * as i18n from './translations';
import { useColumns } from './use_columns';
import { useFindAttackDiscoverySchedules } from '../logic/use_find_schedules';
import { useEnableAttackDiscoverySchedule } from '../logic/use_enable_schedule';
import { useDisableAttackDiscoverySchedule } from '../logic/use_disable_schedule';
import { useDeleteAttackDiscoverySchedule } from '../logic/use_delete_schedule';
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT_FIELD = 'name';
const DEFAULT_SORT_DIRECTION = 'asc';
/**
* Table Component for displaying Attack Discovery Schedules
*/
export const SchedulesTable: React.FC = React.memo(() => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [sortField, setSortField] = useState<keyof AttackDiscoverySchedule>(DEFAULT_SORT_FIELD);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>(DEFAULT_SORT_DIRECTION);
const { data: { schedules, total } = { schedules: [], total: 0 }, isLoading: isDataLoading } =
useFindAttackDiscoverySchedules({
page: pageIndex,
perPage: pageSize,
sortField,
sortDirection,
});
const pagination = useMemo(() => {
return {
pageIndex,
pageSize,
totalItemCount: total,
};
}, [pageIndex, pageSize, total]);
const sorting = useMemo(() => {
return {
sort: {
field: sortField,
direction: sortDirection,
},
};
}, [sortDirection, sortField]);
const onTableChange = useCallback(
({ page, sort }: CriteriaWithPagination<AttackDiscoverySchedule>) => {
if (page) {
setPageIndex(page.index);
setPageSize(page.size);
}
if (sort) {
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
}
},
[]
);
const [isTableLoading, setTableLoading] = useState(false);
const { mutateAsync: enableAttackDiscoverySchedule } = useEnableAttackDiscoverySchedule();
const { mutateAsync: disableAttackDiscoverySchedule } = useDisableAttackDiscoverySchedule();
const { mutateAsync: deleteAttackDiscoverySchedule } = useDeleteAttackDiscoverySchedule();
const openScheduleDetails = useCallback((scheduleId: string) => {
// TODO: implement attack discovery schedule details
}, []);
const enableSchedule = useCallback(
async (id: string) => {
try {
setTableLoading(true);
await enableAttackDiscoverySchedule({ id });
} catch (err) {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
setTableLoading(false);
}
},
[enableAttackDiscoverySchedule]
);
const disableSchedule = useCallback(
async (id: string) => {
try {
setTableLoading(true);
await disableAttackDiscoverySchedule({ id });
} catch (err) {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
setTableLoading(false);
}
},
[disableAttackDiscoverySchedule]
);
const deleteSchedule = useCallback(
async (id: string) => {
try {
setTableLoading(true);
await deleteAttackDiscoverySchedule({ id });
} catch (err) {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
setTableLoading(false);
}
},
[deleteAttackDiscoverySchedule]
);
const rulesColumns = useColumns({
isDisabled: isDataLoading,
isLoading: isTableLoading,
openScheduleDetails,
enableSchedule,
disableSchedule,
deleteSchedule,
});
return (
<div data-test-subj="schedulesTableContainer">
<div data-test-subj="schedulesTableDescription">
{i18n.ATTACK_DISCOVER_SCHEDULES_DESCRIPTION}
</div>
<EuiSpacer size="m" />
<EuiBasicTable<AttackDiscoverySchedule>
loading={isTableLoading}
items={schedules}
pagination={pagination}
sorting={sorting}
onChange={onTableChange}
itemId={'id'}
data-test-subj={'schedulesTable'}
columns={rulesColumns}
/>
</div>
);
});
SchedulesTable.displayName = 'SchedulesTable';

View file

@ -0,0 +1,16 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ATTACK_DISCOVER_SCHEDULES_DESCRIPTION = i18n.translate(
'xpack.securitySolution.attackDiscovery.settingsFlyout.schedule.table.description',
{
defaultMessage:
'Scheduled Attack Discoveries will generate automatically, based on their settings.',
}
);

View file

@ -0,0 +1,67 @@
/*
* 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 { renderHook } from '@testing-library/react';
import { useColumns } from './use_columns';
import {
createActionsColumn,
createEnableColumn,
createNameColumn,
createStatusColumn,
} from './columns';
jest.mock('./columns');
const mockCreateActionsColumn = createActionsColumn as jest.MockedFunction<
typeof createActionsColumn
>;
const mockCreateEnableColumn = createEnableColumn as jest.MockedFunction<typeof createEnableColumn>;
const mockCreateNameColumn = createNameColumn as jest.MockedFunction<typeof createNameColumn>;
const mockCreateStatusColumn = createStatusColumn as jest.MockedFunction<typeof createStatusColumn>;
const openScheduleDetails = jest.fn();
const enableSchedule = jest.fn();
const disableSchedule = jest.fn();
const deleteSchedule = jest.fn();
describe('useColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
renderHook(() =>
useColumns({
isDisabled: false,
isLoading: false,
openScheduleDetails,
enableSchedule,
disableSchedule,
deleteSchedule,
})
);
});
it('should invoke `createNameColumn`', () => {
expect(mockCreateNameColumn).toHaveBeenCalledWith({ openScheduleDetails });
});
it('should invoke `createStatusColumn`', () => {
expect(mockCreateStatusColumn).toHaveBeenCalled();
});
it('should invoke `createEnableColumn`', () => {
expect(mockCreateEnableColumn).toHaveBeenCalledWith({
isDisabled: false,
isLoading: false,
onSwitchChange: expect.anything(),
});
});
it('should invoke `createActionsColumn`', () => {
expect(mockCreateActionsColumn).toHaveBeenCalledWith({ isDisabled: false, deleteSchedule });
});
});

View file

@ -0,0 +1,52 @@
/*
* 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, useMemo } from 'react';
import type { TableColumn } from './columns';
import {
createActionsColumn,
createEnableColumn,
createNameColumn,
createStatusColumn,
} from './columns';
export const useColumns = ({
isDisabled,
isLoading,
openScheduleDetails,
enableSchedule,
disableSchedule,
deleteSchedule,
}: {
isDisabled: boolean;
isLoading: boolean;
openScheduleDetails: (scheduleId: string) => void;
enableSchedule: (scheduleId: string) => Promise<void>;
disableSchedule: (scheduleId: string) => Promise<void>;
deleteSchedule: (scheduleId: string) => Promise<void>;
}): TableColumn[] => {
const onSwitchChange = useCallback(
async (scheduleId: string, enabled: boolean) => {
if (enabled) {
await enableSchedule(scheduleId);
} else {
await disableSchedule(scheduleId);
}
},
[disableSchedule, enableSchedule]
);
return useMemo(
() => [
createNameColumn({ openScheduleDetails }),
createStatusColumn(),
createEnableColumn({ isDisabled, isLoading, onSwitchChange }),
createActionsColumn({ isDisabled, deleteSchedule }),
],
[deleteSchedule, isDisabled, isLoading, onSwitchChange, openScheduleDetails]
);
};

View file

@ -12,7 +12,11 @@ import type {
GenerationInterval,
AttackDiscoveryStats,
} from '@kbn/elastic-assistant-common';
import { AttackDiscoveryPostResponse, API_VERSIONS } from '@kbn/elastic-assistant-common';
import {
AttackDiscoveryPostResponse,
API_VERSIONS,
ATTACK_DISCOVERY,
} from '@kbn/elastic-assistant-common';
import { isEmpty } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useFetchAnonymizationFields } from '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields';
@ -188,7 +192,7 @@ export const useAttackDiscovery = ({
setApproximateFutureTime(null);
// call the internal API to generate attack discoveries:
const rawResponse = await http.post('/internal/elastic_assistant/attack_discovery', {
const rawResponse = await http.post(ATTACK_DISCOVERY, {
body: JSON.stringify(bodyWithOverrides),
version: API_VERSIONS.internal.v1,
});