diff --git a/.buildkite/scripts/steps/check_types_commits.sh b/.buildkite/scripts/steps/check_types_commits.sh index d34c4dae5ffa9..973e4ce6fbb60 100755 --- a/.buildkite/scripts/steps/check_types_commits.sh +++ b/.buildkite/scripts/steps/check_types_commits.sh @@ -2,40 +2,92 @@ set -euo pipefail -# This script will collect typescript projects and run typecheck on projects between the given 2 parameters -# Could be used for selective typechecking on projects that might be affected for a given PR. +# This script detects the files changed in a given set of commits, finds the related tsconfig.json files, and scope the TypeScript type check to those. +# In CI, this script can be used for selective type-checking on projects that might be affected for a given PR. # (The accuracy for finding related projects is not a 100%) -if [[ "${CI-}" == "true" ]]; then - .buildkite/scripts/bootstrap.sh +argv=( "$@" ) +diffArgs=("--name-only") +uniq_dirs=() +uniq_tsconfigs=() - sha1=$(git merge-base $GITHUB_PR_TARGET_BRANCH $GITHUB_PR_TRIGGERED_SHA) - sha2="${GITHUB_PR_TRIGGERED_SHA-}" -else - if [[ "${1-}" == "--cached" ]]; then - # Only check staged files - sha1=$1 - sha2="" +is_flag_set () { + flag=$1 + if [ ${#argv[@]} -gt 0 ] && [[ ${argv[@]} =~ $flag ]]; then + true else - # Script take between 0 and 2 arguments representing two commit SHA's: - # If 0, it will diff HEAD and HEAD^ - # If 1 (SHA1), it will diff SHA1 and SHA1^ - # If 2 (SHA1, SHA2), it will diff SHA1 and SHA2 - sha1="${1-HEAD}" - sha2="${2-$sha1^}" + false fi +} + +get_args_for_flag_result=() +get_args_for_flag () { + flag=$1 + found=false + get_args_for_flag_result=() + if [ ${#argv[@]} -gt 0 ]; then + for i in "${!argv[@]}"; do + arg="${argv[$i]}" + if [ "$found" == false ] && [[ "$arg" == "$flag" ]]; then + found=true + elif [ "$found" == true ]; then + if [[ "$arg" == -* ]]; then + return + else + get_args_for_flag_result+=("$arg") + fi + fi + done + fi +} + +if is_flag_set "--help" || is_flag_set "-h"; then + echo "Detects the files changed in a given set of commits, finds the related" + echo "tsconfig.json files, and scope the TypeScript type check to those." + echo + echo "Usage:" + echo " $0 [options]" + echo " $0 [ []]" + echo + echo "Options:" + echo " --help, -h Show this help" + echo " --cached Check staged changes" + echo " --merge-base [ []]" + echo " Check changes between nearest common ansestor (merge-base) of" + echo " ref1 and ref2. Defaults: 'main' and 'HEAD'" + echo + echo "If no options are provided, the script takes between 0 and 2 arguments" + echo "representing two git refs:" + echo " If 0, it will diff HEAD and HEAD^" + echo " If 1 (REF1), it will diff REF1 and REF1^" + echo " If 2 (REF1, REF2), it will diff REF1 and REF2" + exit fi -uniq_dirs=() -uniq_tsconfigs=() +if [[ "${CI-}" == "true" ]]; then + # Buildkite only + .buildkite/scripts/bootstrap.sh -if [[ "$sha1" == "--cached" ]]; then - echo "Detecting files changed in staging area..." + targetBranch="${GITHUB_PR_TARGET_BRANCH-}" + git fetch origin $targetBranch + sha=$(git merge-base "origin/$targetBranch" "${GITHUB_PR_TRIGGERED_SHA-}") + diffArgs+=("$sha" "${GITHUB_PR_TRIGGERED_SHA-}") +elif is_flag_set "--merge-base"; then + # Similar to when CI=true, but locally + get_args_for_flag "--merge-base" + diffArgs+=("--merge-base" "${get_args_for_flag_result[0]-main}" "${get_args_for_flag_result[1]-HEAD}") +elif is_flag_set "--cached"; then + # Only check staged files + diffArgs+=("--cached") else - echo "Detecting files changed between $sha1 and $sha2..." + # Full manual mode! + ref1="${1-HEAD}" + diffArgs+=("$ref1" "${2-$ref1^}") fi -files=($(git diff --name-only $sha1 $sha2)) +echo "Detecting files changed..." +echo "DEBUG: git diff args: ${diffArgs[@]}" +files=($(git diff "${diffArgs[@]}")) add_dir () { new_dir=$1 @@ -117,11 +169,7 @@ if [ ${#uniq_dirs[@]} -gt 0 ]; then fi if [ ${#uniq_tsconfigs[@]} -eq 0 ]; then - if [[ "$sha1" == "--cached" ]]; then - echo "No tsconfig.json files found for staged changes" - else - echo "No tsconfig.json files found for changes between $sha1 and $sha2" - fi + echo "No tsconfig.json files found" exit fi diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dd7ceab0cbd5c..31bcc2356bb28 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -274,6 +274,7 @@ packages/core/status/core-status-server-mocks @elastic/kibana-core packages/core/test-helpers/core-test-helpers-deprecations-getters @elastic/kibana-core packages/core/test-helpers/core-test-helpers-http-setup-browser @elastic/kibana-core packages/core/test-helpers/core-test-helpers-kbn-server @elastic/kibana-core +packages/core/test-helpers/core-test-helpers-model-versions @elastic/kibana-core packages/core/test-helpers/core-test-helpers-so-type-serializer @elastic/kibana-core packages/core/test-helpers/core-test-helpers-test-utils @elastic/kibana-core packages/core/theme/core-theme-browser @elastic/kibana-core @@ -1239,6 +1240,7 @@ x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @elastic/kib /x-pack/plugins/security_solution/public/common/components/ml_popover @elastic/security-detection-rule-management /x-pack/plugins/security_solution/public/common/components/popover_items @elastic/security-detection-rule-management /x-pack/plugins/security_solution/public/detection_engine/fleet_integrations @elastic/security-detection-rule-management +/x-pack/plugins/security_solution/public/detection_engine/endpoint_exceptions @elastic/security-defend-workflows /x-pack/plugins/security_solution/public/detection_engine/rule_details_ui @elastic/security-detection-rule-management /x-pack/plugins/security_solution/public/detection_engine/rule_management @elastic/security-detection-rule-management /x-pack/plugins/security_solution/public/detection_engine/rule_management_ui @elastic/security-detection-rule-management @@ -1336,6 +1338,7 @@ x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @elastic/kib /x-pack/test/security_solution_endpoint_api_int/ @elastic/security-defend-workflows /x-pack/test_serverless/shared/lib/security/kibana_roles/ @elastic/security-defend-workflows /x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management @elastic/security-defend-workflows +/x-pack/plugins/security_solution_serverless/public/upselling/pages/endpoint_management @elastic/security-defend-workflows /x-pack/plugins/security_solution_serverless/server/endpoint @elastic/security-defend-workflows ## Security Solution sub teams - security-telemetry (Data Engineering) diff --git a/package.json b/package.json index b099df2f57086..e95c9000155a9 100644 --- a/package.json +++ b/package.json @@ -936,8 +936,8 @@ "jsts": "^1.6.2", "kea": "^2.4.2", "langchain": "^0.0.151", - "launchdarkly-js-client-sdk": "^2.22.1", - "launchdarkly-node-server-sdk": "^6.4.2", + "launchdarkly-js-client-sdk": "^3.1.4", + "launchdarkly-node-server-sdk": "^7.0.3", "load-json-file": "^6.2.0", "lodash": "^4.17.21", "lru-cache": "^4.1.5", @@ -1180,6 +1180,7 @@ "@kbn/core-saved-objects-server-mocks": "link:packages/core/saved-objects/core-saved-objects-server-mocks", "@kbn/core-status-server-mocks": "link:packages/core/status/core-status-server-mocks", "@kbn/core-test-helpers-kbn-server": "link:packages/core/test-helpers/core-test-helpers-kbn-server", + "@kbn/core-test-helpers-model-versions": "link:packages/core/test-helpers/core-test-helpers-model-versions", "@kbn/core-theme-browser-mocks": "link:packages/core/theme/core-theme-browser-mocks", "@kbn/core-ui-settings-browser-mocks": "link:packages/core/ui-settings/core-ui-settings-browser-mocks", "@kbn/core-ui-settings-server-mocks": "link:packages/core/ui-settings/core-ui-settings-server-mocks", diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/README.md b/packages/core/test-helpers/core-test-helpers-model-versions/README.md new file mode 100644 index 0000000000000..71aca755a14b6 --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/README.md @@ -0,0 +1,19 @@ +# @kbn/core-test-helpers-model-versions + +Package exposing utilities for model version integration testing. + +This package exposes a `createModelVersionTestBed` utility which allow simulating +a testbed environment where we're in the cohabitation period between two versions, to test the interactions +between two model versions of a set of SO types. + +### Limitations: + +Because the test bed is only creating the parts of Core required to create the two SO +repositories, and because we're not loading all plugins (for proper isolation), the integration +test bed has some limitations: + +- no extensions are enabled + - no security + - no encryption + - no spaces +- all SO types will be using the same SO index \ No newline at end of file diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/index.ts b/packages/core/test-helpers/core-test-helpers-model-versions/index.ts new file mode 100644 index 0000000000000..e8a5565f1dd79 --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/index.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { createModelVersionTestBed } from './src/test_bed'; + +export type { + ModelVersionTestBed, + ModelVersionTestKit, + ModelVersionTestkitOptions, + SavedObjectTestkitDefinition, +} from './src/types'; diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/jest.config.js b/packages/core/test-helpers/core-test-helpers-model-versions/jest.config.js new file mode 100644 index 0000000000000..77b74018b2b85 --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../../../..', + roots: ['/packages/core/test-helpers/core-test-helpers-model-versions'], +}; diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/kibana.jsonc b/packages/core/test-helpers/core-test-helpers-model-versions/kibana.jsonc new file mode 100644 index 0000000000000..d6ea333ad06f7 --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/kibana.jsonc @@ -0,0 +1,6 @@ +{ + "type": "shared-common", + "id": "@kbn/core-test-helpers-model-versions", + "owner": "@elastic/kibana-core", + "devOnly": true +} diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/package.json b/packages/core/test-helpers/core-test-helpers-model-versions/package.json new file mode 100644 index 0000000000000..13e5760f1a1d5 --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/core-test-helpers-model-versions", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/src/elasticsearch.ts b/packages/core/test-helpers/core-test-helpers-model-versions/src/elasticsearch.ts new file mode 100644 index 0000000000000..3af641d5597aa --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/src/elasticsearch.ts @@ -0,0 +1,34 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { createTestServers, type TestElasticsearchUtils } from '@kbn/core-test-helpers-kbn-server'; + +/** + * Start the traditional ES cluster and return the instance. + */ +export const startElasticsearch = async ({ + basePath, + dataArchive, + timeout, +}: { + basePath?: string; + dataArchive?: string; + timeout?: number; +} = {}): Promise => { + const { startES } = createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t + (timeout ?? 0)), + settings: { + es: { + license: 'basic', + basePath, + dataArchive, + }, + }, + }); + return await startES(); +}; diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/src/test_bed.ts b/packages/core/test-helpers/core-test-helpers-model-versions/src/test_bed.ts new file mode 100644 index 0000000000000..aa8c4ab8df425 --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/src/test_bed.ts @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { TestElasticsearchUtils } from '@kbn/core-test-helpers-kbn-server'; +import { startElasticsearch } from './elasticsearch'; +import { prepareModelVersionTestKit } from './test_kit'; +import type { ModelVersionTestBed } from './types'; + +/** + * Create a {@link ModelVersionTestBed} that can be used for model version integration testing. + * + * @example + * ```ts + * describe('myIntegrationTest', () => { + * const testbed = createModelVersionTestBed(); + * let testkit: ModelVersionTestKit; + * + * beforeAll(async () => { + * await testbed.startES(); + * }); + * + * afterAll(async () => { + * await testbed.stopES(); + * }); + * + * beforeEach(async () => { + * testkit = await testbed.prepareTestKit({ + * savedObjectDefinitions: [{ + * definition: mySoTypeDefinition, + * modelVersionBefore: 1, + * modelVersionAfter: 2, + * }] + * }) + * }); + * + * afterEach(async () => { + * if(testkit) { + * await testkit.tearsDown(); + * } + * }); + * + * it('can be used to test model version cohabitation', async () => { + * // last registered version is `1` + * const repositoryV1 = testkit.repositoryBefore; + * // last registered version is `2` + * const repositoryV2 = testkit.repositoryAfter; + * + * // do something with the two repositories, e.g + * await repositoryV1.create(someAttrs, { id }); + * const v2docReadFromV1 = await repositoryV2.get('my-type', id); + * expect(v2docReadFromV1.attributes).toEqual(something); + * }) + * }) + * ``` + * + * @public + */ +export const createModelVersionTestBed = (): ModelVersionTestBed => { + let elasticsearch: TestElasticsearchUtils | undefined; + + const startES = async () => { + if (elasticsearch) { + throw new Error('Elasticsearch already started'); + } + elasticsearch = await startElasticsearch(); + }; + + const stopES = async () => { + if (!elasticsearch) { + throw new Error('Elasticsearch not started'); + } + await elasticsearch.stop(); + await delay(10); + elasticsearch = undefined; + }; + + return { + startES, + stopES, + prepareTestKit: prepareModelVersionTestKit, + }; +}; + +const delay = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/src/test_kit.ts b/packages/core/test-helpers/core-test-helpers-model-versions/src/test_kit.ts new file mode 100644 index 0000000000000..93517ea3b33a6 --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/src/test_kit.ts @@ -0,0 +1,264 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import fs from 'fs/promises'; +import { defaultsDeep } from 'lodash'; +import { BehaviorSubject, firstValueFrom, map } from 'rxjs'; +import { ConfigService, Env } from '@kbn/config'; +import { getEnvOptions } from '@kbn/config-mocks'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { KibanaMigrator } from '@kbn/core-saved-objects-migration-server-internal'; +import { + SavedObjectConfig, + type SavedObjectsConfigType, + type SavedObjectsMigrationConfigType, + type IndexTypesMap, +} from '@kbn/core-saved-objects-base-server-internal'; +import { SavedObjectsRepository } from '@kbn/core-saved-objects-api-server-internal'; +import { + ElasticsearchConfig, + type ElasticsearchConfigType, + getCapabilitiesFromClient, +} from '@kbn/core-elasticsearch-server-internal'; +import { AgentManager, configureClient } from '@kbn/core-elasticsearch-client-server-internal'; +import { type LoggingConfigType, LoggingSystem } from '@kbn/core-logging-server-internal'; +import { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server'; +import { esTestConfig, kibanaServerTestUser } from '@kbn/test'; +import type { LoggerFactory } from '@kbn/logging'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import { registerServiceConfig } from '@kbn/core-root-server-internal'; +import { getDocLinks, getDocLinksMeta } from '@kbn/doc-links'; +import type { DocLinksServiceStart } from '@kbn/core-doc-links-server'; +import type { NodeRoles } from '@kbn/core-node-server'; +import { getTypeRegistries } from './type_registry'; +import type { ModelVersionTestkitOptions, ModelVersionTestKit } from './types'; + +const env = Env.createDefault(REPO_ROOT, getEnvOptions()); +const currentVersion = env.packageInfo.version; +const currentBranch = env.packageInfo.branch; +const defaultKibanaIndex = '.kibana_migrator_tests'; +const defaultNodeRoles: NodeRoles = { migrator: true, ui: true, backgroundTasks: true }; + +/** + * Prepare the model version integration test kit + * + * @internal + */ +export const prepareModelVersionTestKit = async ({ + savedObjectDefinitions, + objectsToCreateBetween = [], + settingOverrides = {}, + kibanaBranch = currentBranch, + kibanaVersion = currentVersion, + kibanaIndex = defaultKibanaIndex, + logFilePath, +}: ModelVersionTestkitOptions): Promise => { + await fs.unlink(logFilePath).catch(() => {}); + + const loggingSystem = new LoggingSystem(); + const loggerFactory = loggingSystem.asLoggerFactory(); + + const configService = getConfigService(settingOverrides, loggerFactory, logFilePath); + + // configure logging system + const loggingConf = await firstValueFrom(configService.atPath('logging')); + await loggingSystem.upgrade(loggingConf); + + const esClient = await getElasticsearchClient(configService, loggerFactory, kibanaVersion); + + const { registryBefore, registryAfter } = getTypeRegistries({ + types: savedObjectDefinitions, + kibanaIndex, + }); + + const commonMigratorParams = { + configService, + client: esClient, + loggerFactory, + kibanaIndex, + defaultIndexTypesMap: {}, + kibanaVersion, + kibanaBranch, + nodeRoles: defaultNodeRoles, + }; + + const firstMigrator = await getMigrator({ + ...commonMigratorParams, + typeRegistry: registryBefore, + }); + + const secondMigrator = await getMigrator({ + ...commonMigratorParams, + typeRegistry: registryAfter, + }); + + const repositoryBefore = SavedObjectsRepository.createRepository( + firstMigrator, + registryBefore, + kibanaIndex, + esClient, + loggerFactory.get('saved_objects') + ); + + const repositoryAfter = SavedObjectsRepository.createRepository( + secondMigrator, + registryAfter, + kibanaIndex, + esClient, + loggerFactory.get('saved_objects') + ); + + await runMigrations(firstMigrator); + + if (objectsToCreateBetween.length) { + await repositoryBefore.bulkCreate(objectsToCreateBetween, { refresh: 'wait_for' }); + } + + await runMigrations(secondMigrator); + + const tearsDown = async () => { + await esClient.indices.delete({ index: `${kibanaIndex}_*`, allow_no_indices: true }); + }; + + return { + esClient, + repositoryBefore, + repositoryAfter, + tearsDown, + }; +}; + +const getConfigService = ( + settings: Record, + loggerFactory: LoggerFactory, + logFilePath: string +) => { + // Define some basic default kibana settings + const DEFAULTS_SETTINGS = { + server: { + autoListen: true, + // Use the ephemeral port to make sure that tests use the first available + // port and aren't affected by the timing issues in test environment. + port: 0, + xsrf: { disableProtection: true }, + }, + elasticsearch: { + hosts: [esTestConfig.getUrl()], + username: kibanaServerTestUser.username, + password: kibanaServerTestUser.password, + }, + migrations: { + algorithm: 'v2', + skip: false, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + level: 'info', + appenders: ['file'], + }, + ], + }, + plugins: {}, + }; + + const rawConfigProvider = { + getConfig$: () => new BehaviorSubject(defaultsDeep({}, settings, DEFAULTS_SETTINGS)), + }; + + const configService = new ConfigService(rawConfigProvider, env, loggerFactory); + registerServiceConfig(configService); + return configService; +}; + +const getElasticsearchClient = async ( + configService: ConfigService, + loggerFactory: LoggerFactory, + kibanaVersion: string +) => { + const esClientConfig = await firstValueFrom( + configService + .atPath('elasticsearch') + .pipe(map((rawConfig) => new ElasticsearchConfig(rawConfig))) + ); + + return configureClient(esClientConfig, { + logger: loggerFactory.get('elasticsearch'), + type: 'data', + agentFactoryProvider: new AgentManager( + loggerFactory.get('elasticsearch-service', 'agent-manager') + ), + kibanaVersion, + }); +}; + +const getMigrator = async ({ + configService, + client, + kibanaIndex, + typeRegistry, + defaultIndexTypesMap, + loggerFactory, + kibanaVersion, + kibanaBranch, + nodeRoles, +}: { + configService: ConfigService; + client: ElasticsearchClient; + kibanaIndex: string; + typeRegistry: ISavedObjectTypeRegistry; + defaultIndexTypesMap: IndexTypesMap; + loggerFactory: LoggerFactory; + kibanaVersion: string; + kibanaBranch: string; + nodeRoles: NodeRoles; +}) => { + const savedObjectsConf = await firstValueFrom( + configService.atPath('savedObjects') + ); + const savedObjectsMigrationConf = await firstValueFrom( + configService.atPath('migrations') + ); + const soConfig = new SavedObjectConfig(savedObjectsConf, savedObjectsMigrationConf); + + const docLinks: DocLinksServiceStart = { + ...getDocLinksMeta({ kibanaBranch }), + links: getDocLinks({ kibanaBranch }), + }; + + const esCapabilities = await getCapabilitiesFromClient(client); + + return new KibanaMigrator({ + client, + kibanaIndex, + typeRegistry, + defaultIndexTypesMap, + soMigrationsConfig: soConfig.migration, + kibanaVersion, + logger: loggerFactory.get('savedobjects-service'), + docLinks, + waitForMigrationCompletion: false, // ensure we have an active role in the migration + nodeRoles, + esCapabilities, + }); +}; + +const runMigrations = async (migrator: KibanaMigrator) => { + migrator.prepareMigrations(); + await migrator.runMigrations(); +}; diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/src/type_registry.ts b/packages/core/test-helpers/core-test-helpers-model-versions/src/type_registry.ts new file mode 100644 index 0000000000000..62751737b594b --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/src/type_registry.ts @@ -0,0 +1,82 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsType } from '@kbn/core-saved-objects-server'; +import { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal'; +import type { SavedObjectTestkitDefinition } from './types'; + +export interface TestkitTypeRegistries { + registryBefore: SavedObjectTypeRegistry; + registryAfter: SavedObjectTypeRegistry; +} + +/** + * Create the 'before' and 'after' type registries from the provided testkit type definitions. + */ +export const getTypeRegistries = ({ + types, + kibanaIndex, +}: { + types: SavedObjectTestkitDefinition[]; + kibanaIndex: string; +}): TestkitTypeRegistries => { + const registryBefore = new SavedObjectTypeRegistry(); + const registryAfter = new SavedObjectTypeRegistry(); + + for (const definition of types) { + const { typeBefore, typeAfter } = getTypes({ definition, kibanaIndex }); + registryBefore.registerType(typeBefore); + registryAfter.registerType(typeAfter); + } + + return { + registryBefore, + registryAfter, + }; +}; + +const getTypes = ({ + definition, + kibanaIndex, +}: { + definition: SavedObjectTestkitDefinition; + kibanaIndex: string; +}): { typeBefore: SavedObjectsType; typeAfter: SavedObjectsType } => { + const modelVersionMap = + typeof definition.definition.modelVersions === 'function' + ? definition.definition.modelVersions() + : definition.definition.modelVersions ?? {}; + + const typeBefore: SavedObjectsType = { + ...definition.definition, + indexPattern: kibanaIndex, + modelVersions: removeKeysGreaterThan(modelVersionMap, definition.modelVersionBefore), + }; + + const typeAfter: SavedObjectsType = { + ...definition.definition, + indexPattern: kibanaIndex, + modelVersions: removeKeysGreaterThan(modelVersionMap, definition.modelVersionAfter), + }; + + return { + typeBefore, + typeAfter, + }; +}; + +const removeKeysGreaterThan = >( + record: T, + version: number +): T => { + return Object.fromEntries( + Object.entries(record).filter(([key, value]) => { + return parseInt(key, 10) <= version; + }) + ) as T; +}; diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/src/types.ts b/packages/core/test-helpers/core-test-helpers-model-versions/src/types.ts new file mode 100644 index 0000000000000..37f22a297ee31 --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/src/types.ts @@ -0,0 +1,147 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { Client } from '@elastic/elasticsearch'; +import type { SavedObjectsType } from '@kbn/core-saved-objects-server'; +import type { + ISavedObjectsRepository, + SavedObjectsBulkCreateObject, +} from '@kbn/core-saved-objects-api-server'; + +/** + * A testbed that can be used for model version integration tests. + * + * @public + */ +export interface ModelVersionTestBed { + /** + * Starts the ES cluster. + * This should usually be called only once before the suite runs, within a `beforeAll` block. + */ + startES: () => Promise; + /** + * Stops the ES cluster. + * This should usually be called only after the suite runs, within a `afterAll` block. + */ + stopES: () => Promise; + /** + * Prepare and return the testkit instance. + * + * @see {@link ModelVersionTestkitOptions} + * @see {@link ModelVersionTestKit} + */ + prepareTestKit: (options: ModelVersionTestkitOptions) => Promise; +} + +/** + * Options used to create a {@link ModelVersionTestKit} via {@link ModelVersionTestBed#prepareTestKit} + * + * @public + */ +export interface ModelVersionTestkitOptions { + /** + * The {@link SavedObjectTestkitDefinition | definitions} for the SO type(s) to test + */ + savedObjectDefinitions: SavedObjectTestkitDefinition[]; + /** + * The path of the file to write logs to. + * Necessary because the testkit doesn't know the test's location + * + * @example + * ```ts + * const logFilePath = Path.join(__dirname, '{my_test}.log'); + * ``` + */ + logFilePath: string; + /** + * (optional) if specified, the provided list of objects will be created (using `SOR.bulkCreate`) + * between the first (before) the second (after) migrator runs. Objects are therefor expected to be of + * the `versionBefore` version. + */ + objectsToCreateBetween?: SavedObjectsBulkCreateObject[]; + /** + * (optional) raw record of settings to be used to override the default Kibana configuration. + * if provided, will be merged by the default test configuration. + * + * @example + * ``` + * const settingOverrides = { + * migrations: { + * algorithm: 'zdt, + * } + * } + * ``` + */ + settingOverrides?: Record; + /** + * (optional) allows to override the kibanaVersion that will be passed down to the migrator instances + * Defaults to the version coming from the package.json. + */ + kibanaVersion?: string; + /** + * (optional) allows to override the kibanaBranch that will be passed down to the migrator instances + * Defaults to the version coming from the package.json. + */ + kibanaBranch?: string; + /** + * (optional) the index (pattern) to use for all types. + * Defaults to `.kibana_migrator_tests` + */ + kibanaIndex?: string; +} + +/** + * Testkit composed of various services that can be used to run the + * model version integration tests. + * + * Mostly composed of the two `repositoryBefore` and `repositoryAfter` repositories + * that can be used to interact with different versions of the SO types. + * + * @public + */ +export interface ModelVersionTestKit { + /** + * An ES client connecting to the Elasticsearch cluster used by the testkit. + */ + esClient: Client; + /** + * The SO repository using the SO type definitions at the `before` versions. + */ + repositoryBefore: ISavedObjectsRepository; + /** + * The SO repository using the SO type definitions at the `after` versions. + */ + repositoryAfter: ISavedObjectsRepository; + /** + * Cleanup function that will delete the test index. + * Should be called before calling `testbed.prepareTestKit` again. + */ + tearsDown: () => Promise; +} + +/** + * Represents the info necessary to prepare a given type for the sandbox. + * Contains both the actual SO type definition, and the versions + * that should be used at 'before' and 'after' model versions. + * + * @public + */ +export interface SavedObjectTestkitDefinition { + /** + * The SO type definition + */ + definition: SavedObjectsType; + /** + * The model version to be used for the 'before' repository. + */ + modelVersionBefore: number; + /** + * The model version to be used for the 'after' repository. + */ + modelVersionAfter: number; +} diff --git a/packages/core/test-helpers/core-test-helpers-model-versions/tsconfig.json b/packages/core/test-helpers/core-test-helpers-model-versions/tsconfig.json new file mode 100644 index 0000000000000..fe08f1cce0cea --- /dev/null +++ b/packages/core/test-helpers/core-test-helpers-model-versions/tsconfig.json @@ -0,0 +1,37 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/core-test-helpers-kbn-server", + "@kbn/config", + "@kbn/config-mocks", + "@kbn/repo-info", + "@kbn/core-saved-objects-migration-server-internal", + "@kbn/core-saved-objects-base-server-internal", + "@kbn/core-saved-objects-api-server-internal", + "@kbn/core-elasticsearch-server-internal", + "@kbn/core-elasticsearch-client-server-internal", + "@kbn/core-logging-server-internal", + "@kbn/core-saved-objects-server", + "@kbn/test", + "@kbn/logging", + "@kbn/core-elasticsearch-server", + "@kbn/core-root-server-internal", + "@kbn/core-saved-objects-api-server", + "@kbn/doc-links", + "@kbn/core-doc-links-server", + "@kbn/core-node-server", + ] +} diff --git a/packages/kbn-alerts-as-data-utils/src/field_maps/legacy_experimental_field_map.ts b/packages/kbn-alerts-as-data-utils/src/field_maps/legacy_experimental_field_map.ts index b31727115b53c..520b682097460 100644 --- a/packages/kbn-alerts-as-data-utils/src/field_maps/legacy_experimental_field_map.ts +++ b/packages/kbn-alerts-as-data-utils/src/field_maps/legacy_experimental_field_map.ts @@ -11,6 +11,9 @@ import { ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, ALERT_EVALUATION_VALUES, + ALERT_GROUP, + ALERT_GROUP_FIELD, + ALERT_GROUP_VALUE, } from '@kbn/rule-data-utils'; export const legacyExperimentalFieldMap = { @@ -27,6 +30,21 @@ export const legacyExperimentalFieldMap = { required: false, array: true, }, + [ALERT_GROUP]: { + type: 'object', + array: true, + required: false, + }, + [ALERT_GROUP_FIELD]: { + type: 'keyword', + array: false, + required: false, + }, + [ALERT_GROUP_VALUE]: { + type: 'keyword', + array: false, + required: false, + }, } as const; export type ExperimentalRuleFieldMap = typeof legacyExperimentalFieldMap; diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_apm_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_apm_schema.ts index b6cdedfab73df..b15fe189b7e25 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_apm_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_apm_schema.ts @@ -76,6 +76,12 @@ const ObservabilityApmAlertOptional = rt.partial({ 'kibana.alert.evaluation.threshold': schemaStringOrNumber, 'kibana.alert.evaluation.value': schemaStringOrNumber, 'kibana.alert.evaluation.values': schemaStringOrNumberArray, + 'kibana.alert.group': rt.array( + rt.partial({ + field: schemaString, + value: schemaString, + }) + ), 'processor.event': schemaString, 'service.environment': schemaString, 'service.language.name': schemaString, diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_logs_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_logs_schema.ts index 60d5f4102ce81..62b23dcab24ee 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_logs_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_logs_schema.ts @@ -74,6 +74,12 @@ const ObservabilityLogsAlertOptional = rt.partial({ 'kibana.alert.evaluation.threshold': schemaStringOrNumber, 'kibana.alert.evaluation.value': schemaStringOrNumber, 'kibana.alert.evaluation.values': schemaStringOrNumberArray, + 'kibana.alert.group': rt.array( + rt.partial({ + field: schemaString, + value: schemaString, + }) + ), }); // prettier-ignore diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_metrics_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_metrics_schema.ts index 7b731bf066c91..3806f2d096bd2 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_metrics_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_metrics_schema.ts @@ -74,6 +74,12 @@ const ObservabilityMetricsAlertOptional = rt.partial({ 'kibana.alert.evaluation.threshold': schemaStringOrNumber, 'kibana.alert.evaluation.value': schemaStringOrNumber, 'kibana.alert.evaluation.values': schemaStringOrNumberArray, + 'kibana.alert.group': rt.array( + rt.partial({ + field: schemaString, + value: schemaString, + }) + ), }); // prettier-ignore diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_slo_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_slo_schema.ts index 85378d662d43a..62ad6c82c6391 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_slo_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_slo_schema.ts @@ -73,6 +73,12 @@ const ObservabilitySloAlertOptional = rt.partial({ 'kibana.alert.evaluation.threshold': schemaStringOrNumber, 'kibana.alert.evaluation.value': schemaStringOrNumber, 'kibana.alert.evaluation.values': schemaStringOrNumberArray, + 'kibana.alert.group': rt.array( + rt.partial({ + field: schemaString, + value: schemaString, + }) + ), 'slo.id': schemaString, 'slo.instanceId': schemaString, 'slo.revision': schemaStringOrNumber, diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts index e9735c96a34e7..12a54768d71e4 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/observability_uptime_schema.ts @@ -77,6 +77,12 @@ const ObservabilityUptimeAlertOptional = rt.partial({ 'kibana.alert.evaluation.threshold': schemaStringOrNumber, 'kibana.alert.evaluation.value': schemaStringOrNumber, 'kibana.alert.evaluation.values': schemaStringOrNumberArray, + 'kibana.alert.group': rt.array( + rt.partial({ + field: schemaString, + value: schemaString, + }) + ), 'monitor.id': schemaString, 'monitor.name': schemaString, 'monitor.type': schemaString, diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index c18777c8b052d..2f8ad9cf0a18b 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1370,6 +1370,41 @@ }, "category": { "type": "keyword" + }, + "customFields": { + "type": "nested", + "properties": { + "key": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "keyword", + "fields": { + "number": { + "type": "long", + "ignore_malformed": true + }, + "boolean": { + "type": "boolean", + "ignore_malformed": true + }, + "string": { + "type": "text" + }, + "date": { + "type": "date", + "ignore_malformed": true + }, + "ip": { + "type": "ip", + "ignore_malformed": true + } + } + } + } } } }, diff --git a/packages/kbn-management/cards_navigation/src/consts.tsx b/packages/kbn-management/cards_navigation/src/consts.tsx index 45deab542b0a8..03abf7e3cd407 100644 --- a/packages/kbn-management/cards_navigation/src/consts.tsx +++ b/packages/kbn-management/cards_navigation/src/consts.tsx @@ -82,7 +82,7 @@ export const appDefinitions: Record = { category: appCategories.DATA, description: i18n.translate('management.landing.withCardNavigation.mlDescription', { defaultMessage: - 'View, export, and import machine learning analytics and anomaly detection items.', + 'Identify, analyze, and process your data using advanced analysis techniques.', }), icon: , }, diff --git a/packages/kbn-openapi-generator/src/template_service/templates/zod_query_item.handlebars b/packages/kbn-openapi-generator/src/template_service/templates/zod_query_item.handlebars index 0b718d941cbed..7fa146cd783e4 100644 --- a/packages/kbn-openapi-generator/src/template_service/templates/zod_query_item.handlebars +++ b/packages/kbn-openapi-generator/src/template_service/templates/zod_query_item.handlebars @@ -43,9 +43,17 @@ {{~/if~}} {{~#if (eq type "integer")}} -{{> zod_schema_item}} + z.coerce.number().int() + {{~#if minimum includeZero=true}}.min({{minimum}}){{/if~}} + {{~#if maximum includeZero=true}}.max({{maximum}}){{/if~}} + {{~#if (eq requiredBool false)}}.optional(){{/if~}} + {{~#if (defined default)}}.default({{{toJSON default}}}){{/if~}} {{~/if~}} {{~#if (eq type "number")}} -{{> zod_schema_item}} + z.coerce.number() + {{~#if minimum includeZero=true}}.min({{minimum}}){{/if~}} + {{~#if maximum includeZero=true}}.max({{maximum}}){{/if~}} + {{~#if (eq requiredBool false)}}.optional(){{/if~}} + {{~#if (defined default)}}.default({{{toJSON default}}}){{/if~}} {{~/if~}} diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index 5f0570fa9542e..b387ab67750d5 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -88,6 +88,9 @@ const ALERT_EVALUATION_THRESHOLD = `${ALERT_NAMESPACE}.evaluation.threshold` as const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const; const ALERT_CONTEXT = `${ALERT_NAMESPACE}.context` as const; const ALERT_EVALUATION_VALUES = `${ALERT_NAMESPACE}.evaluation.values` as const; +const ALERT_GROUP = `${ALERT_NAMESPACE}.group` as const; +const ALERT_GROUP_FIELD = `${ALERT_GROUP}.field` as const; +const ALERT_GROUP_VALUE = `${ALERT_GROUP}.value` as const; // Fields pertaining to the rule associated with the alert const ALERT_RULE_EXCEPTIONS_LIST = `${ALERT_RULE_NAMESPACE}.exceptions_list` as const; @@ -129,6 +132,9 @@ const fields = { ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, ALERT_EVALUATION_VALUES, + ALERT_GROUP, + ALERT_GROUP_FIELD, + ALERT_GROUP_VALUE, ALERT_FLAPPING, ALERT_MAINTENANCE_WINDOW_IDS, ALERT_INSTANCE_ID, @@ -200,6 +206,9 @@ export { ALERT_EVALUATION_VALUE, ALERT_CONTEXT, ALERT_EVALUATION_VALUES, + ALERT_GROUP, + ALERT_GROUP_FIELD, + ALERT_GROUP_VALUE, ALERT_RULE_EXCEPTIONS_LIST, ALERT_RULE_NAMESPACE_FIELD, ALERT_THREAT_FRAMEWORK, diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index ec5fd0ca2add5..423008ee1eece 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -69,7 +69,7 @@ describe('checking migration metadata changes on all registered SO types', () => "canvas-element": "cdedc2123eb8a1506b87a56b0bcce60f4ec08bc8", "canvas-workpad": "9d82aafb19586b119e5c9382f938abe28c26ca5c", "canvas-workpad-template": "c077b0087346776bb3542b51e1385d172cb24179", - "cases": "b43a8ce985c406167e1d115381805a48cb3b0e61", + "cases": "2392189ed338857d4815a4cef6051f9ad124d39d", "cases-comments": "5cb0a421588831c2a950e50f486048b8aabbae25", "cases-configure": "44ed7b8e0f44df39516b8870589b89e32224d2bf", "cases-connector-mappings": "f9d1ac57e484e69506c36a8051e4d61f4a8cfd25", diff --git a/src/core/server/integration_tests/saved_objects/migrations/zdt_2/sor_higher_version_docs.test.ts b/src/core/server/integration_tests/saved_objects/migrations/zdt_2/sor_higher_version_docs.test.ts index e431c3607d983..fd423c36a54f6 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/zdt_2/sor_higher_version_docs.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/zdt_2/sor_higher_version_docs.test.ts @@ -8,57 +8,49 @@ import { pick, range } from 'lodash'; import Path from 'path'; -import fs from 'fs/promises'; -import { type TestElasticsearchUtils } from '@kbn/core-test-helpers-kbn-server'; import '../jest_matchers'; import { ISavedObjectsRepository } from '@kbn/core-saved-objects-api-server'; -import { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; -import { getKibanaMigratorTestKit, startElasticsearch } from '../kibana_migrator_test_kit'; -import { delay, createType } from '../test_utils'; -import { getBaseMigratorParams } from '../fixtures/zdt_base.fixtures'; +import { createType } from '../test_utils'; import { SavedObjectsBulkCreateObject } from '@kbn/core-saved-objects-api-server'; +import { createModelVersionTestBed } from '@kbn/core-test-helpers-model-versions'; export const logFilePath = Path.join(__dirname, 'sor_higher.test.log'); +const modelVersionTestBed = createModelVersionTestBed(); + describe('Higher version doc conversion', () => { - let esServer: TestElasticsearchUtils['es']; let repositoryV1: ISavedObjectsRepository; let repositoryV2: ISavedObjectsRepository; - const getTestType = ({ includeVersion2 }: { includeVersion2: boolean }) => { - const modelVersions: SavedObjectsModelVersionMap = { - 1: { - changes: [], - schemas: { - forwardCompatibility: (attrs: any) => { - return pick(attrs, 'text', 'bool'); + const getTestType = () => { + return createType({ + name: 'test-type', + switchToModelVersionAt: '8.0.0', + modelVersions: { + 1: { + changes: [], + schemas: { + forwardCompatibility: (attrs: any) => { + return pick(attrs, 'text', 'bool'); + }, }, }, - }, - }; - - if (includeVersion2) { - modelVersions[2] = { - changes: [ - { - type: 'data_backfill', - backfillFn: (document) => { - return { attributes: { newField: 'someValue' } }; + 2: { + changes: [ + { + type: 'data_backfill', + backfillFn: (document) => { + return { attributes: { newField: 'someValue' } }; + }, + }, + ], + schemas: { + forwardCompatibility: (attrs: any) => { + return pick(attrs, 'text', 'bool', 'newField'); }, - }, - ], - schemas: { - forwardCompatibility: (attrs: any) => { - return pick(attrs, 'text', 'bool', 'newField'); }, }, - }; - } - - return createType({ - name: 'test-type', - switchToModelVersionAt: '8.0.0', - modelVersions, + }, mappings: { dynamic: false, properties: { @@ -69,58 +61,34 @@ describe('Higher version doc conversion', () => { }); }; - const createBaseline = async () => { - const testTypeV1 = getTestType({ includeVersion2: false }); - const testTypeV2 = getTestType({ includeVersion2: true }); + beforeAll(async () => { + await modelVersionTestBed.startES(); - const { - runMigrations, - savedObjectsRepository: savedObjectsRepositoryV1, - client, - } = await getKibanaMigratorTestKit({ - ...getBaseMigratorParams(), + const testkit = await modelVersionTestBed.prepareTestKit({ logFilePath, - types: [testTypeV1], + savedObjectDefinitions: [ + { + definition: getTestType(), + modelVersionBefore: 1, + modelVersionAfter: 2, + }, + ], + objectsToCreateBetween: range(5).map((number) => ({ + id: `doc-${number}`, + type: 'test-type', + attributes: { + text: `a_${number}`, + bool: true, + }, + })), }); - await runMigrations(); - - const sampleAObjs = range(5).map((number) => ({ - id: `doc-${number}`, - type: 'test-type', - attributes: { - text: `a_${number}`, - bool: true, - }, - })); - - await savedObjectsRepositoryV1.bulkCreate(sampleAObjs, { refresh: 'wait_for' }); - - const { runMigrations: runMigrationsAgain, savedObjectsRepository: savedObjectsRepositoryV2 } = - await getKibanaMigratorTestKit({ - ...getBaseMigratorParams(), - logFilePath, - types: [testTypeV2], - }); - await runMigrationsAgain(); - - // returns the repository for v1 - return { savedObjectsRepositoryV1, savedObjectsRepositoryV2, client }; - }; - - beforeAll(async () => { - await fs.unlink(logFilePath).catch(() => {}); - esServer = await startElasticsearch(); - - const { savedObjectsRepositoryV1: sorV1, savedObjectsRepositoryV2: sorV2 } = - await createBaseline(); - repositoryV1 = sorV1; - repositoryV2 = sorV2; + repositoryV1 = testkit.repositoryBefore; + repositoryV2 = testkit.repositoryAfter; }); afterAll(async () => { - await esServer?.stop(); - await delay(10); + await modelVersionTestBed.stopES(); }); describe('#get', () => { diff --git a/src/core/tsconfig.json b/src/core/tsconfig.json index 8232e102ddab2..2e63a6c845681 100644 --- a/src/core/tsconfig.json +++ b/src/core/tsconfig.json @@ -152,6 +152,7 @@ "@kbn/tooling-log", "@kbn/stdio-dev-helpers", "@kbn/safer-lodash-set", + "@kbn/core-test-helpers-model-versions", ], "exclude": [ "target/**/*", diff --git a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx index f9f3efdd299f3..00b68c2abc547 100644 --- a/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx +++ b/src/plugins/dashboard/public/dashboard_app/dashboard_app.tsx @@ -31,15 +31,15 @@ import { } from './url/search_sessions_integration'; import { DashboardAPI, DashboardRenderer } from '..'; import { type DashboardEmbedSettings } from './types'; -import { DASHBOARD_APP_ID } from '../dashboard_constants'; import { pluginServices } from '../services/plugin_services'; -import { DashboardTopNav } from './top_nav/dashboard_top_nav'; import { AwaitingDashboardAPI } from '../dashboard_container'; import { DashboardRedirect } from '../dashboard_container/types'; import { useDashboardMountContext } from './hooks/dashboard_mount_context'; +import { createDashboardEditUrl, DASHBOARD_APP_ID } from '../dashboard_constants'; import { useDashboardOutcomeValidation } from './hooks/use_dashboard_outcome_validation'; import { loadDashboardHistoryLocationState } from './locator/load_dashboard_history_location_state'; import type { DashboardCreationOptions } from '../dashboard_container/embeddable/dashboard_container_factory'; +import { DashboardTopNav } from '../dashboard_top_nav'; export interface DashboardAppProps { history: History; @@ -160,6 +160,10 @@ export function DashboardApp({ getInitialInput, validateLoadedSavedObject: validateOutcome, isEmbeddedExternally: Boolean(embedSettings), // embed settings are only sent if the dashboard URL has `embed=true` + getEmbeddableAppContext: (dashboardId) => ({ + currentAppId: DASHBOARD_APP_ID, + getCurrentPath: () => `#${createDashboardEditUrl(dashboardId)}`, + }), }); }, [ history, @@ -192,9 +196,11 @@ export function DashboardApp({ {!showNoDataPage && ( <> {dashboardAPI && ( - - - + )} {getLegacyConflictWarning?.()} diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx index 63dd1d96d1169..0190bbaefa00b 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_editing_toolbar.tsx @@ -5,7 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - import { css } from '@emotion/react'; import React, { useCallback } from 'react'; import { METRIC_TYPE } from '@kbn/analytics'; @@ -21,7 +20,7 @@ import { EditorMenu } from './editor_menu'; import { useDashboardAPI } from '../dashboard_app'; import { pluginServices } from '../../services/plugin_services'; import { ControlsToolbarButton } from './controls_toolbar_button'; -import { DASHBOARD_APP_ID, DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants'; +import { DASHBOARD_UI_METRIC_ID } from '../../dashboard_constants'; import { dashboardReplacePanelActionStrings } from '../../dashboard_actions/_dashboard_actions_strings'; export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean }) { @@ -70,12 +69,13 @@ export function DashboardEditingToolbar({ isDisabled }: { isDisabled?: boolean } stateTransferService.navigateToEditor(appId, { path, state: { - originatingApp: DASHBOARD_APP_ID, + originatingApp: dashboard.getAppContext()?.currentAppId, + originatingPath: dashboard.getAppContext()?.getCurrentPath?.(), searchSessionId: search.session.getSessionId(), }, }); }, - [stateTransferService, search.session, trackUiMetric] + [stateTransferService, dashboard, search.session, trackUiMetric] ); const createNewEmbeddable = useCallback( diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx index 643765bdfbab6..9c5aedb76c147 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx +++ b/src/plugins/dashboard/public/dashboard_app/top_nav/use_dashboard_menu_items.tsx @@ -26,10 +26,12 @@ export const useDashboardMenuItems = ({ redirectTo, isLabsShown, setIsLabsShown, + showResetChange, }: { redirectTo: DashboardRedirect; isLabsShown: boolean; setIsLabsShown: Dispatch>; + showResetChange?: boolean; }) => { const [isSaveInProgress, setIsSaveInProgress] = useState(false); @@ -276,32 +278,56 @@ export const useDashboardMenuItems = ({ const shareMenuItem = share ? [menuItems.share] : []; const cloneMenuItem = showWriteControls ? [menuItems.clone] : []; const editMenuItem = showWriteControls && !managed ? [menuItems.edit] : []; + const mayberesetChangesMenuItem = showResetChange ? [resetChangesMenuItem] : []; + return [ ...labsMenuItem, menuItems.fullScreen, ...shareMenuItem, ...cloneMenuItem, - resetChangesMenuItem, + ...mayberesetChangesMenuItem, ...editMenuItem, ]; - }, [isLabsEnabled, menuItems, share, showWriteControls, managed, resetChangesMenuItem]); + }, [ + isLabsEnabled, + menuItems, + share, + showWriteControls, + managed, + showResetChange, + resetChangesMenuItem, + ]); const editModeTopNavConfig = useMemo(() => { const labsMenuItem = isLabsEnabled ? [menuItems.labs] : []; const shareMenuItem = share ? [menuItems.share] : []; const editModeItems: TopNavMenuData[] = []; + if (lastSavedId) { - editModeItems.push( - menuItems.saveAs, - menuItems.switchToViewMode, - resetChangesMenuItem, - menuItems.quickSave - ); + editModeItems.push(menuItems.saveAs, menuItems.switchToViewMode); + + if (showResetChange) { + editModeItems.push(resetChangesMenuItem); + } + + editModeItems.push(menuItems.quickSave); } else { editModeItems.push(menuItems.switchToViewMode, menuItems.saveAs); } return [...labsMenuItem, menuItems.settings, ...shareMenuItem, ...editModeItems]; - }, [lastSavedId, menuItems, share, resetChangesMenuItem, isLabsEnabled]); + }, [ + isLabsEnabled, + menuItems.labs, + menuItems.share, + menuItems.settings, + menuItems.saveAs, + menuItems.switchToViewMode, + menuItems.quickSave, + share, + lastSavedId, + showResetChange, + resetChangesMenuItem, + ]); return { viewModeTopNavConfig, editModeTopNavConfig }; }; diff --git a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx index 050de4189c279..8767b5abe3567 100644 --- a/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx +++ b/src/plugins/dashboard/public/dashboard_container/component/empty_screen/dashboard_empty_screen.tsx @@ -24,7 +24,7 @@ import { ViewMode } from '@kbn/embeddable-plugin/public'; import { pluginServices } from '../../../services/plugin_services'; import { emptyScreenStrings } from '../../_dashboard_container_strings'; import { useDashboardContainer } from '../../embeddable/dashboard_container'; -import { DASHBOARD_UI_METRIC_ID, DASHBOARD_APP_ID } from '../../../dashboard_constants'; +import { DASHBOARD_UI_METRIC_ID } from '../../../dashboard_constants'; export function DashboardEmptyScreen() { const { @@ -44,6 +44,14 @@ export function DashboardEmptyScreen() { [getVisTypeAliases] ); + const dashboardContainer = useDashboardContainer(); + const isDarkTheme = useObservable(theme$)?.darkMode; + const isEditMode = + dashboardContainer.select((state) => state.explicitInput.viewMode) === ViewMode.EDIT; + const embeddableAppContext = dashboardContainer.getAppContext(); + const originatingPath = embeddableAppContext?.getCurrentPath?.() ?? ''; + const originatingApp = embeddableAppContext?.currentAppId; + const goToLens = useCallback(() => { if (!lensAlias || !lensAlias.aliasPath) return; const trackUiMetric = usageCollection.reportUiCounter?.bind( @@ -57,16 +65,19 @@ export function DashboardEmptyScreen() { getStateTransfer().navigateToEditor(lensAlias.aliasApp, { path: lensAlias.aliasPath, state: { - originatingApp: DASHBOARD_APP_ID, + originatingApp, + originatingPath, searchSessionId: search.session.getSessionId(), }, }); - }, [getStateTransfer, lensAlias, search.session, usageCollection]); - - const dashboardContainer = useDashboardContainer(); - const isDarkTheme = useObservable(theme$)?.darkMode; - const isEditMode = - dashboardContainer.select((state) => state.explicitInput.viewMode) === ViewMode.EDIT; + }, [ + getStateTransfer, + lensAlias, + originatingApp, + originatingPath, + search.session, + usageCollection, + ]); // TODO replace these SVGs with versions from EuiIllustration as soon as it becomes available. const imageUrl = basePath.prepend( diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx index 4c88d246f6ca3..0899fa0ebc97e 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/dashboard_container.tsx @@ -53,7 +53,7 @@ import { DASHBOARD_CONTAINER_TYPE } from '../..'; import { placePanel } from '../component/panel_placement'; import { pluginServices } from '../../services/plugin_services'; import { initializeDashboard } from './create/create_dashboard'; -import { DASHBOARD_LOADED_EVENT } from '../../dashboard_constants'; +import { DASHBOARD_APP_ID, DASHBOARD_LOADED_EVENT } from '../../dashboard_constants'; import { DashboardCreationOptions } from './dashboard_container_factory'; import { DashboardAnalyticsService } from '../../services/analytics/types'; import { DashboardViewport } from '../component/viewport/dashboard_viewport'; @@ -107,7 +107,6 @@ export class DashboardContainer extends Container void; private cleanupStateTools: () => void; @@ -185,6 +184,16 @@ export class DashboardContainer extends Container 'valid' | 'invalid' | 'redirected'; isEmbeddedExternally?: boolean; + + getEmbeddableAppContext?: (dashboardId?: string) => EmbeddableAppContext; } export class DashboardContainerFactoryDefinition diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/_dashboard_top_nav.scss b/src/plugins/dashboard/public/dashboard_top_nav/_dashboard_top_nav.scss similarity index 100% rename from src/plugins/dashboard/public/dashboard_app/top_nav/_dashboard_top_nav.scss rename to src/plugins/dashboard/public/dashboard_top_nav/_dashboard_top_nav.scss diff --git a/src/plugins/dashboard/public/dashboard_top_nav/dashboard_top_nav_with_context.tsx b/src/plugins/dashboard/public/dashboard_top_nav/dashboard_top_nav_with_context.tsx new file mode 100644 index 0000000000000..ed2a7426697c1 --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_top_nav/dashboard_top_nav_with_context.tsx @@ -0,0 +1,27 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { DashboardAPIContext } from '../dashboard_app/dashboard_app'; +import { DashboardContainer } from '../dashboard_container'; +import { + InternalDashboardTopNav, + InternalDashboardTopNavProps, +} from './internal_dashboard_top_nav'; +export interface DashboardTopNavProps extends InternalDashboardTopNavProps { + dashboardContainer: DashboardContainer; +} + +export const DashboardTopNavWithContext = (props: DashboardTopNavProps) => ( + + + +); + +// eslint-disable-next-line import/no-default-export +export default DashboardTopNavWithContext; diff --git a/src/plugins/dashboard/public/dashboard_top_nav/index.tsx b/src/plugins/dashboard/public/dashboard_top_nav/index.tsx new file mode 100644 index 0000000000000..d0cfc496fcc3f --- /dev/null +++ b/src/plugins/dashboard/public/dashboard_top_nav/index.tsx @@ -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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import React, { Suspense } from 'react'; +import { servicesReady } from '../plugin'; +import { DashboardTopNavProps } from './dashboard_top_nav_with_context'; + +const LazyDashboardTopNav = React.lazy(() => + (async () => { + const modulePromise = import('./dashboard_top_nav_with_context'); + const [module] = await Promise.all([modulePromise, servicesReady]); + + return { + default: module.DashboardTopNavWithContext, + }; + })().then((module) => module) +); + +export const DashboardTopNav = (props: DashboardTopNavProps) => { + return ( + }> + + + ); +}; diff --git a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx similarity index 78% rename from src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx rename to src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx index 67c51a19052d9..c2e0e273a572e 100644 --- a/src/plugins/dashboard/public/dashboard_app/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/dashboard_top_nav/internal_dashboard_top_nav.tsx @@ -18,36 +18,49 @@ import { ViewMode } from '@kbn/embeddable-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; import { TopNavMenuProps } from '@kbn/navigation-plugin/public'; import { EuiHorizontalRule, EuiIcon, EuiToolTipProps } from '@elastic/eui'; - +import { EuiBreadcrumbProps } from '@elastic/eui/src/components/breadcrumbs/breadcrumb'; +import { MountPoint } from '@kbn/core/public'; import { getDashboardTitle, leaveConfirmStrings, getDashboardBreadcrumb, unsavedChangesBadgeStrings, dashboardManagedBadge, -} from '../_dashboard_app_strings'; -import { UI_SETTINGS } from '../../../common'; -import { useDashboardAPI } from '../dashboard_app'; -import { DashboardEmbedSettings } from '../types'; -import { pluginServices } from '../../services/plugin_services'; -import { useDashboardMenuItems } from './use_dashboard_menu_items'; -import { DashboardRedirect } from '../../dashboard_container/types'; -import { DashboardEditingToolbar } from './dashboard_editing_toolbar'; -import { useDashboardMountContext } from '../hooks/dashboard_mount_context'; -import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../../dashboard_constants'; - +} from '../dashboard_app/_dashboard_app_strings'; +import { UI_SETTINGS } from '../../common'; +import { useDashboardAPI } from '../dashboard_app/dashboard_app'; +import { pluginServices } from '../services/plugin_services'; +import { useDashboardMenuItems } from '../dashboard_app/top_nav/use_dashboard_menu_items'; +import { DashboardEmbedSettings } from '../dashboard_app/types'; +import { DashboardEditingToolbar } from '../dashboard_app/top_nav/dashboard_editing_toolbar'; +import { useDashboardMountContext } from '../dashboard_app/hooks/dashboard_mount_context'; +import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../dashboard_constants'; import './_dashboard_top_nav.scss'; -export interface DashboardTopNavProps { +import { DashboardRedirect } from '../dashboard_container/types'; + +export interface InternalDashboardTopNavProps { + customLeadingBreadCrumbs?: EuiBreadcrumbProps[]; embedSettings?: DashboardEmbedSettings; + forceHideUnifiedSearch?: boolean; redirectTo: DashboardRedirect; + setCustomHeaderActionMenu?: (menuMount: MountPoint | undefined) => void; + showBorderBottom?: boolean; + showResetChange?: boolean; } const LabsFlyout = withSuspense(LazyLabsFlyout, null); -export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavProps) { +export function InternalDashboardTopNav({ + customLeadingBreadCrumbs = [], + embedSettings, + forceHideUnifiedSearch, + redirectTo, + setCustomHeaderActionMenu, + showBorderBottom = true, + showResetChange = true, +}: InternalDashboardTopNavProps) { const [isChromeVisible, setIsChromeVisible] = useState(false); const [isLabsShown, setIsLabsShown] = useState(false); - const dashboardTitleRef = useRef(null); /** @@ -168,19 +181,33 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr // set only the dashboardTitleBreadcrumbs because the main breadcrumbs automatically come as part of the navigation config serverless.setBreadcrumbs(dashboardTitleBreadcrumbs); } else { - // non-serverless regular breadcrumbs - setBreadcrumbs([ - { - text: getDashboardBreadcrumb(), - 'data-test-subj': 'dashboardListingBreadcrumb', - onClick: () => { - redirectTo({ destination: 'listing' }); + /** + * non-serverless regular breadcrumbs + * Dashboard embedded in other plugins (e.g. SecuritySolution) + * will have custom leading breadcrumbs for back to their app. + **/ + setBreadcrumbs( + customLeadingBreadCrumbs.concat([ + { + text: getDashboardBreadcrumb(), + 'data-test-subj': 'dashboardListingBreadcrumb', + onClick: () => { + redirectTo({ destination: 'listing' }); + }, }, - }, - ...dashboardTitleBreadcrumbs, - ]); + ...dashboardTitleBreadcrumbs, + ]) + ); } - }, [setBreadcrumbs, redirectTo, dashboardTitle, dashboard, viewMode, serverless]); + }, [ + setBreadcrumbs, + redirectTo, + dashboardTitle, + dashboard, + viewMode, + serverless, + customLeadingBreadCrumbs, + ]); /** * Build app leave handler whenever hasUnsavedChanges changes @@ -205,12 +232,6 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr }; }, [onAppLeave, getStateTransfer, hasUnsavedChanges, viewMode]); - const { viewModeTopNavConfig, editModeTopNavConfig } = useDashboardMenuItems({ - redirectTo, - isLabsShown, - setIsLabsShown, - }); - const visibilityProps = useMemo(() => { const shouldShowNavBarComponent = (forceShow: boolean): boolean => (forceShow || isChromeVisible) && !fullScreenMode; @@ -218,14 +239,17 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr !forceHide && (filterManager.getFilters().length > 0 || !fullScreenMode); const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu)); - const showQueryInput = shouldShowNavBarComponent( - Boolean(embedSettings?.forceShowQueryInput || viewMode === ViewMode.PRINT) - ); - const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); + const showQueryInput = Boolean(forceHideUnifiedSearch) + ? false + : shouldShowNavBarComponent( + Boolean(embedSettings?.forceShowQueryInput || viewMode === ViewMode.PRINT) + ); + const showDatePicker = Boolean(forceHideUnifiedSearch) + ? false + : shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker)); const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar)); const showQueryBar = showQueryInput || showDatePicker || showFilterBar; const showSearchBar = showQueryBar || showFilterBar; - return { showTopNavMenu, showSearchBar, @@ -233,7 +257,21 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr showQueryInput, showDatePicker, }; - }, [embedSettings, filterManager, fullScreenMode, isChromeVisible, viewMode]); + }, [ + embedSettings, + filterManager, + forceHideUnifiedSearch, + fullScreenMode, + isChromeVisible, + viewMode, + ]); + + const { viewModeTopNavConfig, editModeTopNavConfig } = useDashboardMenuItems({ + redirectTo, + isLabsShown, + setIsLabsShown, + showResetChange, + }); UseUnmount(() => { dashboard.clearOverlays(); @@ -301,7 +339,11 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr saveQueryMenuVisibility={allowSaveQuery ? 'allowed_by_app_privilege' : 'globally_managed'} appName={LEGACY_DASHBOARD_APP_ID} visible={viewMode !== ViewMode.PRINT} - setMenuMountPoint={embedSettings || fullScreenMode ? undefined : setHeaderActionMenu} + setMenuMountPoint={ + embedSettings || fullScreenMode + ? setCustomHeaderActionMenu ?? undefined + : setHeaderActionMenu + } className={fullScreenMode ? 'kbnTopNavMenu-isFullScreen' : undefined} config={ visibilityProps.showTopNavMenu @@ -327,7 +369,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr {viewMode === ViewMode.EDIT ? ( ) : null} - + {showBorderBottom && } ); } diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 6882090df441a..89cc7b1aabed8 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -25,7 +25,7 @@ export { export type { DashboardSetup, DashboardStart, DashboardFeatureFlagConfig } from './plugin'; export { DashboardListingTable } from './dashboard_listing'; - +export { DashboardTopNav } from './dashboard_top_nav'; export { type DashboardAppLocator, type DashboardAppLocatorParams, diff --git a/src/plugins/embeddable/public/embeddable_panel/embeddable_panel.tsx b/src/plugins/embeddable/public/embeddable_panel/embeddable_panel.tsx index 3d823c215498b..93279e311b065 100644 --- a/src/plugins/embeddable/public/embeddable_panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/embeddable_panel/embeddable_panel.tsx @@ -49,14 +49,7 @@ const getEventStatus = (output: EmbeddableOutput): EmbeddablePhase => { }; export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => { - const { - hideHeader, - showShadow, - embeddable, - hideInspector, - containerContext, - onPanelStatusChange, - } = panelProps; + const { hideHeader, showShadow, embeddable, hideInspector, onPanelStatusChange } = panelProps; const [node, setNode] = useState(); const embeddableRoot: React.RefObject = useMemo(() => React.createRef(), []); @@ -74,8 +67,7 @@ export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => { const editPanel = new EditPanelAction( embeddableStart.getEmbeddableFactory, core.application, - stateTransfer, - containerContext?.getCurrentPath + stateTransfer ); const actions: PanelUniversalActions = { @@ -91,7 +83,7 @@ export const EmbeddablePanel = (panelProps: UnwrappedEmbeddablePanelProps) => { }; if (!hideInspector) actions.inspectPanel = new InspectPanelAction(inspector); return actions; - }, [containerContext?.getCurrentPath, hideInspector]); + }, [hideInspector]); /** * Track panel status changes diff --git a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.test.tsx b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.test.tsx index a1b1bf4df5ec4..33b1cc15a55bc 100644 --- a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.test.tsx +++ b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.test.tsx @@ -46,12 +46,7 @@ test('is compatible when edit url is available, in edit mode and editable', asyn test('redirects to app using state transfer', async () => { applicationMock.currentAppId$ = of('superCoolCurrentApp'); const testPath = '/test-path'; - const action = new EditPanelAction( - getFactory, - applicationMock, - stateTransferMock, - () => testPath - ); + const action = new EditPanelAction(getFactory, applicationMock, stateTransferMock); const embeddable = new EditableEmbeddable( { id: '123', @@ -62,6 +57,9 @@ test('redirects to app using state transfer', async () => { true ); embeddable.getOutput = jest.fn(() => ({ editApp: 'ultraVisualize', editPath: '/123' })); + embeddable.getAppContext = jest.fn().mockReturnValue({ + getCurrentPath: () => testPath, + }); await action.execute({ embeddable }); expect(stateTransferMock.navigateToEditor).toHaveBeenCalledWith('ultraVisualize', { path: '/123', diff --git a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts index fe55b9a39158b..32e9fbac493aa 100644 --- a/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts +++ b/src/plugins/embeddable/public/embeddable_panel/panel_actions/edit_panel_action/edit_panel_action.ts @@ -45,8 +45,7 @@ export class EditPanelAction implements Action { constructor( private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'], private readonly application: ApplicationStart, - private readonly stateTransfer?: EmbeddableStateTransfer, - private readonly getOriginatingPath?: () => string + private readonly stateTransfer?: EmbeddableStateTransfer ) { if (this.application?.currentAppId$) { this.application.currentAppId$ @@ -139,7 +138,7 @@ export class EditPanelAction implements Action { if (app && path) { if (this.currentAppId) { - const originatingPath = this.getOriginatingPath?.(); + const originatingPath = embeddable.getAppContext()?.getCurrentPath?.(); const state: EmbeddableEditorState = { originatingApp: this.currentAppId, diff --git a/src/plugins/embeddable/public/embeddable_panel/types.ts b/src/plugins/embeddable/public/embeddable_panel/types.ts index 9a1b17d4a3a4d..03e29810d4056 100644 --- a/src/plugins/embeddable/public/embeddable_panel/types.ts +++ b/src/plugins/embeddable/public/embeddable_panel/types.ts @@ -19,11 +19,12 @@ import { import { EmbeddableError } from '../lib/embeddables/i_embeddable'; import { EmbeddableContext, EmbeddableInput, EmbeddableOutput, IEmbeddable } from '..'; -export interface EmbeddableContainerContext { +export interface EmbeddableAppContext { /** * Current app's path including query and hash starting from {appId} */ getCurrentPath?: () => string; + currentAppId?: string; } /** @@ -53,7 +54,6 @@ export interface EmbeddablePanelProps { hideHeader?: boolean; hideInspector?: boolean; showNotifications?: boolean; - containerContext?: EmbeddableContainerContext; actionPredicate?: (actionId: string) => boolean; onPanelStatusChange?: (info: EmbeddablePhaseEvent) => void; getActions?: UiActionsService['getTriggerCompatibleActions']; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 0e3650ea8a8a4..ebe3b1a11af03 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -100,7 +100,7 @@ export { export type { EmbeddablePhase, EmbeddablePhaseEvent, - EmbeddableContainerContext, + EmbeddableAppContext, } from './embeddable_panel/types'; export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './lib/attribute_service'; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index d145bfb3c1ae0..ac1c8462b5bf3 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -17,6 +17,7 @@ import { IContainer } from '../containers'; import { EmbeddableError, EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { EmbeddableInput, ViewMode } from '../../../common/types'; import { genericEmbeddableInputIsEqual, omitGenericEmbeddableInput } from './diff_embeddable_input'; +import { EmbeddableAppContext } from '../../embeddable_panel/types'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { if (input.hidePanelTitles) return ''; @@ -102,6 +103,10 @@ export abstract class Embeddable< .subscribe((title) => this.renderComplete.setTitle(title)); } + public getAppContext(): EmbeddableAppContext | undefined { + return this.parent?.getAppContext(); + } + public reportsEmbeddableLoad() { return false; } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index f371208271623..92d0309688e76 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -11,6 +11,7 @@ import { ErrorLike } from '@kbn/expressions-plugin/common'; import { Adapters } from '../types'; import { IContainer } from '../containers/i_container'; import { EmbeddableInput } from '../../../common/types'; +import { EmbeddableAppContext } from '../../embeddable_panel/types'; export type EmbeddableError = ErrorLike; export type { EmbeddableInput }; @@ -181,6 +182,11 @@ export interface IEmbeddable< */ getRoot(): IEmbeddable | IContainer; + /** + * Returns the context of this embeddable's container, or undefined. + */ + getAppContext(): EmbeddableAppContext | undefined; + /** * Renders the embeddable at the given node. * @param domNode diff --git a/tsconfig.base.json b/tsconfig.base.json index 9f21fe96ae942..98ee10281509a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -542,6 +542,8 @@ "@kbn/core-test-helpers-http-setup-browser/*": ["packages/core/test-helpers/core-test-helpers-http-setup-browser/*"], "@kbn/core-test-helpers-kbn-server": ["packages/core/test-helpers/core-test-helpers-kbn-server"], "@kbn/core-test-helpers-kbn-server/*": ["packages/core/test-helpers/core-test-helpers-kbn-server/*"], + "@kbn/core-test-helpers-model-versions": ["packages/core/test-helpers/core-test-helpers-model-versions"], + "@kbn/core-test-helpers-model-versions/*": ["packages/core/test-helpers/core-test-helpers-model-versions/*"], "@kbn/core-test-helpers-so-type-serializer": ["packages/core/test-helpers/core-test-helpers-so-type-serializer"], "@kbn/core-test-helpers-so-type-serializer/*": ["packages/core/test-helpers/core-test-helpers-so-type-serializer/*"], "@kbn/core-test-helpers-test-utils": ["packages/core/test-helpers/core-test-helpers-test-utils"], diff --git a/x-pack/packages/ml/agg_utils/src/build_sampler_aggregation.ts b/x-pack/packages/ml/agg_utils/src/build_sampler_aggregation.ts index b430590245f64..3183d4b82fb86 100644 --- a/x-pack/packages/ml/agg_utils/src/build_sampler_aggregation.ts +++ b/x-pack/packages/ml/agg_utils/src/build_sampler_aggregation.ts @@ -11,6 +11,11 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; * Wraps the supplied aggregations in a sampler aggregation. * A supplied samplerShardSize (the shard_size parameter of the sampler aggregation) * of less than 1 indicates no sampling, and the aggs are returned as-is. + * + * @param aggs - The aggregations to be wrapped in the sampler aggregation. + * @param shardSize - The shard size parameter for the sampler aggregation. + * A value less than 1 indicates no sampling. + * @returns The wrapped aggregations. */ export function buildSamplerAggregation( aggs: any, diff --git a/x-pack/packages/ml/agg_utils/src/fetch_agg_intervals.ts b/x-pack/packages/ml/agg_utils/src/fetch_agg_intervals.ts index 671e68d58ec31..d8a4c81f95e93 100644 --- a/x-pack/packages/ml/agg_utils/src/fetch_agg_intervals.ts +++ b/x-pack/packages/ml/agg_utils/src/fetch_agg_intervals.ts @@ -23,6 +23,17 @@ const MAX_CHART_COLUMNS = 20; /** * Returns aggregation intervals for the supplied document fields. + * + * @param client - The Elasticsearch client. + * @param indexPattern - The index pattern to search. + * @param query - The query to filter documents. + * @param fields - An array of field definitions for which aggregation intervals are requested. + * @param samplerShardSize - The shard size parameter for sampling aggregations. A value less than 1 indicates no sampling. + * @param runtimeMappings - Optional runtime mappings to apply. + * @param abortSignal - Optional AbortSignal for canceling the request. + * @param randomSamplerProbability - Optional probability value for random sampling. + * @param randomSamplerSeed - Optional seed value for random sampling. + * @returns A promise that resolves to a map of aggregation intervals for the specified fields. */ export const fetchAggIntervals = async ( client: ElasticsearchClient, diff --git a/x-pack/packages/ml/agg_utils/src/fetch_histograms_for_fields.ts b/x-pack/packages/ml/agg_utils/src/fetch_histograms_for_fields.ts index 1baa98317008a..39908328ef34c 100644 --- a/x-pack/packages/ml/agg_utils/src/fetch_histograms_for_fields.ts +++ b/x-pack/packages/ml/agg_utils/src/fetch_histograms_for_fields.ts @@ -41,20 +41,55 @@ interface AggTerms { }; } +/** + * Represents an item in numeric data. + * @interface + */ interface NumericDataItem { + /** + * The numeric key. + */ key: number; + + /** + * An optional string representation of the key. + */ key_as_string?: string; + + /** + * The document count associated with the key. + */ doc_count: number; } /** - * Interface to describe the data structure returned for numeric based charts. + * Interface describing the data structure returned for numeric-based charts. + * @interface */ export interface NumericChartData { + /** + * An array of data points, each represented by a NumericDataItem. + */ data: NumericDataItem[]; + + /** + * The identifier for the data set. + */ id: string; + + /** + * The interval value for the data. + */ interval: number; + + /** + * An array of statistics values, typically [min, max]. + */ stats: [number, number]; + + /** + * The type of chart, which is 'numeric'. + */ type: 'numeric'; } @@ -62,6 +97,9 @@ export interface NumericChartData { * Numeric based histogram field interface, limited to `date` and `number`. */ export interface NumericHistogramField extends HistogramField { + /** + * The type of the numeric histogram field. + */ type: KBN_FIELD_TYPES.DATE | KBN_FIELD_TYPES.NUMBER; } type NumericHistogramFieldWithColumnStats = NumericHistogramField & NumericColumnStats; @@ -139,6 +177,7 @@ export type FieldsForHistograms = Array< * @param fields the fields the histograms should be generated for * @param samplerShardSize shard_size parameter of the sampler aggregation * @param runtimeMappings optional runtime mappings + * @param abortSignal optional abort signal * @param randomSamplerProbability optional random sampler probability * @param randomSamplerSeed optional random sampler seed * @returns an array of histogram data for each supplied field diff --git a/x-pack/packages/ml/agg_utils/src/get_sampler_aggregations_response_path.ts b/x-pack/packages/ml/agg_utils/src/get_sampler_aggregations_response_path.ts index 48a9e5051cacd..b3b864ecebfda 100644 --- a/x-pack/packages/ml/agg_utils/src/get_sampler_aggregations_response_path.ts +++ b/x-pack/packages/ml/agg_utils/src/get_sampler_aggregations_response_path.ts @@ -5,10 +5,14 @@ * 2.0. */ -// Returns the path of aggregations in the elasticsearch response, as an array, -// depending on whether sampling is being used. -// A supplied samplerShardSize (the shard_size parameter of the sampler aggregation) -// of less than 1 indicates no sampling, and an empty array is returned. +/** + * Returns the path of aggregations in the Elasticsearch response as an array, + * depending on whether sampling is being used. + * + * @param samplerShardSize - The shard size parameter of the sampler aggregation. + * A value less than 1 indicates no sampling. + * @returns An array representing the path of aggregations in the response. + */ export function getSamplerAggregationsResponsePath(samplerShardSize: number): string[] { return samplerShardSize > 0 ? ['sample'] : []; } diff --git a/x-pack/packages/ml/agg_utils/src/types.ts b/x-pack/packages/ml/agg_utils/src/types.ts index 9c55d5480381b..92b1d03ea9d5e 100644 --- a/x-pack/packages/ml/agg_utils/src/types.ts +++ b/x-pack/packages/ml/agg_utils/src/types.ts @@ -7,19 +7,33 @@ import { KBN_FIELD_TYPES } from '@kbn/field-types'; +/** + * Represents a field-based cardinality aggregation configuration. + * @interface + */ interface FieldAggCardinality { + /** The field on which the cardinality aggregation is applied. */ field: string; + + /** Optional property percent. */ percent?: any; } +/** + * Represents a script-based cardinality aggregation configuration. + * @interface + */ interface ScriptAggCardinality { + /** The script used for the cardinality aggregation. */ script: any; } /** * Interface for cardinality aggregation. + * @interface */ export interface AggCardinality { + /** The cardinality aggregation configuration */ cardinality: FieldAggCardinality | ScriptAggCardinality; } @@ -27,19 +41,28 @@ export interface AggCardinality { * Field/value pair definition. */ export interface FieldValuePair { + /** The field name. */ fieldName: string; - // For dynamic fieldValues we only identify fields as `string`, - // but for example `http.response.status_code` which is part of - // of the list of predefined field candidates is of type long/number. + /** + * For dynamic fieldValues we only identify fields as `string`, + * but for example `http.response.status_code` which is part of + * of the list of predefined field candidates is of type long/number + */ fieldValue: string | number; } /** - * Interface to describe attributes used for histograms. + * Interface describing attributes used for numeric histograms. + * @interface */ export interface NumericColumnStats { + /** The interval value for the histogram. */ interval: number; + + /** The minimum value in the histogram. */ min: number; + + /** The maximum value in the histogram. */ max: number; } @@ -49,69 +72,130 @@ export interface NumericColumnStats { export type NumericColumnStatsMap = Record; /** - * Parameters to identify which histogram data needs to be generated for a field. + * Represents parameters used to identify which histogram data needs to be generated for a field. + * @interface */ export interface HistogramField { + /** + * The name of the field for which histogram data is generated. + */ fieldName: string; + + /** + * The type of the field, using Kibana field types. + */ type: KBN_FIELD_TYPES; } /** - * Significant term meta data for a field/value pair. - * Note this is used as a custom type within Log Rate Analysis - * for a p-value based variant, not a generic significant terms - * aggregation type. + * Represents significant term metadata for a field/value pair. + * This interface is used as a custom type within Log Rate Analysis + * for a p-value based variant, not related to the generic + * significant terms aggregation type. + * + * @interface + * @extends FieldValuePair */ export interface SignificantTerm extends FieldValuePair { + /** The document count for the significant term. */ doc_count: number; + + /** The background count for the significant term. */ bg_count: number; + + /** The total document count for all terms. */ total_doc_count: number; + + /** The total background count for all terms. */ total_bg_count: number; + + /** The score associated with the significant term. */ score: number; + + /** The p-value for the significant term, or null if not available. */ pValue: number | null; + + /** The normalized score for the significant term. */ normalizedScore: number; + + /** An optional histogram of significant term items. */ histogram?: SignificantTermHistogramItem[]; + + /** Indicates if the significant term is unique within a group. */ unique?: boolean; } /** - * Significant term histogram data item. + * Represents a data item in a significant term histogram. + * @interface */ export interface SignificantTermHistogramItem { + /** The document count for this item in the overall context. */ doc_count_overall: number; + + /** The document count for this item in the significant term context. */ doc_count_significant_term: number; + + /** The numeric key associated with this item. */ key: number; + + /** The string representation of the key. */ key_as_string: string; } /** - * Histogram data for a field/value pair. + * Represents histogram data for a field/value pair. + * @interface */ export interface SignificantTermHistogram extends FieldValuePair { + /** An array of significant term histogram items. */ histogram: SignificantTermHistogramItem[]; } /** - * Histogram data for a group of field/value pairs. + * Represents histogram data for a group of field/value pairs. + * @interface */ export interface SignificantTermGroupHistogram { + /** The identifier for the group. */ id: string; + + /** An array of significant term histogram items. */ histogram: SignificantTermHistogramItem[]; } +/** + * Represents an item in a significant term group. + * @interface + */ export interface SignificantTermGroupItem extends FieldValuePair { + /** The document count associated with this item. */ docCount: number; + + /** The p-value for this item, or null if not available. */ pValue: number | null; + + /** An optional number of duplicates. */ duplicate?: number; } /** - * Tree leaves + * Represents a significant term group. + * @interface */ export interface SignificantTermGroup { + /** The identifier for the item. */ id: string; + + /** An array of significant term group items. */ group: SignificantTermGroupItem[]; + + /** The document count associated with this item. */ docCount: number; + + /** The p-value for this item, or null if not available. */ pValue: number | null; + + /** An optional array of significant term histogram items. */ histogram?: SignificantTermHistogramItem[]; } diff --git a/x-pack/packages/ml/agg_utils/src/validate_number.ts b/x-pack/packages/ml/agg_utils/src/validate_number.ts index 15da7b250c324..0d89cfbfab434 100644 --- a/x-pack/packages/ml/agg_utils/src/validate_number.ts +++ b/x-pack/packages/ml/agg_utils/src/validate_number.ts @@ -8,20 +8,50 @@ import { memoize } from 'lodash'; import { isPopulatedObject } from '@kbn/ml-is-populated-object'; +/** + * Represents the result of number validation. + * @interface + */ export interface NumberValidationResult { + /** The minimum allowed value. */ min: boolean; + + /** The maximum allowed value. */ max: boolean; + + /** Boolean flag to allow integer values only. */ integerOnly: boolean; } /** - * Validate if a number is greater than a specified minimum & lesser than specified maximum + * An interface describing conditions for validating numbers. + * @interface */ -export function numberValidator(conditions?: { +interface NumberValidatorConditions { + /** + * The minimum value for validation. + */ min?: number; + + /** + * The maximum value for validation. + */ max?: number; + + /** + * Indicates whether only integer values are valid. + */ integerOnly?: boolean; -}) { +} + +/** + * Validate if a number is within specified minimum and maximum bounds. + * + * @param conditions - An optional object containing validation conditions. + * @returns validation results. + * @throws {Error} If the provided conditions are invalid (min is greater than max). + */ +export function numberValidator(conditions?: NumberValidatorConditions) { if ( conditions?.min !== undefined && conditions.max !== undefined && diff --git a/x-pack/packages/security-solution/upselling/service/types.ts b/x-pack/packages/security-solution/upselling/service/types.ts index 2f5a06c391471..d14f39ac9796a 100644 --- a/x-pack/packages/security-solution/upselling/service/types.ts +++ b/x-pack/packages/security-solution/upselling/service/types.ts @@ -14,6 +14,7 @@ export type SectionUpsellings = Partial { }); }); + describe('global_labels', () => { + const task = tasks.find((t) => t.name === 'global_labels'); + + it('returns count of global labels when present', async () => { + const fieldCaps = jest.fn().mockResolvedValue({ + indices: [ + '.ds-metrics-apm.service_destination.1m-default-2023.09.26-000005', + '.ds-metrics-apm.service_summary.1m-default-2023.09.26-000005', + '.ds-metrics-apm.service_transaction.1m-default-2023.09.26-000005', + '.ds-metrics-apm.transaction.1m-default-2023.09.26-000005', + ], + fields: { + 'labels.telemetry_auto_version': { + keyword: { + type: 'keyword', + metadata_field: false, + searchable: true, + aggregatable: true, + }, + }, + labels: { + object: { + type: 'object', + metadata_field: false, + searchable: false, + aggregatable: false, + }, + }, + }, + }); + + expect( + await task?.executor({ indices, telemetryClient: { fieldCaps } } as any) + ).toEqual({ + counts: { + global_labels: { + '1d': 1, + }, + }, + }); + }); + + it('returns 0 count of global labels when not present', async () => { + const fieldCaps = jest.fn().mockResolvedValue({ + indices: [], + fields: {}, + }); + + expect( + await task?.executor({ indices, telemetryClient: { fieldCaps } } as any) + ).toEqual({ + counts: { + global_labels: { + '1d': 0, + }, + }, + }); + }); + }); + describe('cloud', () => { const task = tasks.find((t) => t.name === 'cloud'); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts index d5e9203446c38..b980c79e261cd 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -589,6 +589,43 @@ export const tasks: TelemetryTask[] = [ }; }, }, + { + name: 'global_labels', + executor: async ({ telemetryClient }) => { + const metricConsistingGlobalLabels = [ + 'service_summary', + 'service_transaction', + 'transaction', + 'service_destination', + ]; + + const index = metricConsistingGlobalLabels + .map((metric) => `metrics-apm.${metric}*`) + .join(','); + + const response = await telemetryClient.fieldCaps({ + index, + fields: 'labels.*', + expand_wildcards: 'all', + index_filter: range1d, + }); + + const globalLabelCount = Object.keys(response.fields).length; + + // Skip the top level Labels field which is sometimes present in the response + const count = response.fields?.labels + ? globalLabelCount - 1 + : globalLabelCount; + + return { + counts: { + global_labels: { + '1d': count, + }, + }, + }; + }, + }, { name: 'services', executor: async ({ indices, telemetryClient }) => { diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts index ccc6dd4ab01b7..7c5ca6c78c7b3 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/schema.ts @@ -758,6 +758,15 @@ export const apmSchema: MakeSchemaFrom = { }, }, }, + global_labels: { + '1d': { + type: 'long', + _meta: { + description: + 'Total number of global labels used for creating aggregation keys for internal metrics computed from indices which received data in the last 24 hours', + }, + }, + }, max_transaction_groups_per_service: { '1d': { type: 'long', @@ -1145,6 +1154,17 @@ export const apmSchema: MakeSchemaFrom = { }, }, }, + global_labels: { + took: { + ms: { + type: 'long', + _meta: { + description: + 'Execution time in milliseconds for the "global_labels" task', + }, + }, + }, + }, services: { took: { ms: { diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/telemetry_client.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/telemetry_client.ts index 8afcd019233de..4941dd40633d9 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/telemetry_client.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/telemetry_client.ts @@ -46,6 +46,10 @@ export interface TelemetryClient { path: string; method: 'get'; }) => Promise; + + fieldCaps( + params: estypes.FieldCapsRequest + ): Promise; } export async function getTelemetryClient({ @@ -69,5 +73,9 @@ export async function getTelemetryClient({ unwrapEsResponse( esClient.asInternalUser.transport.request(params, { meta: true }) ), + fieldCaps: (params) => + unwrapEsResponse( + esClient.asInternalUser.fieldCaps(params, { meta: true }) + ), }; } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts index eebce519e73d5..b4beb768ae334 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts @@ -111,6 +111,7 @@ export interface APMUsage { services: TimeframeMap1d; environments: TimeframeMap1d; span_destination_service_resource: TimeframeMap1d; + global_labels: TimeframeMap1d; }; cardinality: { client: { geo: { country_iso_code: { rum: TimeframeMap1d } } }; @@ -223,6 +224,7 @@ export interface APMUsage { | 'host' | 'processor_events' | 'agent_configuration' + | 'global_labels' | 'services' | 'versions' | 'groupings' diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 51c6cdc54131d..ec602510fd9f0 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -17,13 +17,13 @@ import { isErrorEmbeddable, EmbeddablePanel, } from '@kbn/embeddable-plugin/public'; -import type { EmbeddableContainerContext } from '@kbn/embeddable-plugin/public'; +import type { EmbeddableAppContext } from '@kbn/embeddable-plugin/public'; import { StartDeps } from '../../plugin'; import { EmbeddableExpression } from '../../expression_types/embeddable'; import { RendererStrings } from '../../../i18n'; import { embeddableInputToExpression } from './embeddable_input_to_expression'; import { RendererFactory, EmbeddableInput } from '../../../types'; -import { CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib'; +import { CANVAS_APP, CANVAS_EMBEDDABLE_CLASSNAME } from '../../../common/lib'; const { embeddable: strings } = RendererStrings; @@ -41,18 +41,19 @@ const renderEmbeddableFactory = (core: CoreStart, plugins: StartDeps) => { return null; } - const embeddableContainerContext: EmbeddableContainerContext = { + const canvasAppContext: EmbeddableAppContext = { getCurrentPath: () => { const urlToApp = core.application.getUrlForApp(currentAppId); const inAppPath = window.location.pathname.replace(urlToApp, ''); return inAppPath + window.location.search + window.location.hash; }, + currentAppId: CANVAS_APP, }; - return ( - - ); + embeddable.getAppContext = () => canvasAppContext; + + return ; }; return (embeddableObject: IEmbeddable) => { diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 01c8543493f7a..603fa70e7d0d4 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -128,6 +128,11 @@ export const MAX_CASES_TO_UPDATE = 100 as const; export const MAX_BULK_CREATE_ATTACHMENTS = 100 as const; export const MAX_USER_ACTIONS_PER_CASE = 10000 as const; export const MAX_PERSISTABLE_STATE_AND_EXTERNAL_REFERENCES = 100 as const; +export const MAX_CUSTOM_FIELDS_PER_CASE = 10 as const; +export const MAX_CUSTOM_FIELD_KEY_LENGTH = 36 as const; // uuidv4 length +export const MAX_CUSTOM_FIELD_LABEL_LENGTH = 50 as const; +export const MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH = 160 as const; +export const MAX_CUSTOM_FIELD_TEXT_VALUE_ITEMS = 10 as const; /** * Cases features diff --git a/x-pack/plugins/cases/common/schema/index.ts b/x-pack/plugins/cases/common/schema/index.ts index bc3e6af9ea584..b38d499c8c04c 100644 --- a/x-pack/plugins/cases/common/schema/index.ts +++ b/x-pack/plugins/cases/common/schema/index.ts @@ -153,3 +153,26 @@ export const limitedNumberSchema = ({ fieldName, min, max }: LimitedSchemaType) }), rt.identity ); + +export interface RegexStringSchemaType { + codec: rt.Type; + pattern: string; + message: string; +} + +export const regexStringRt = ({ codec, pattern, message }: RegexStringSchemaType) => + new rt.Type( + 'RegexString', + codec.is, + (input, context) => + either.chain(codec.validate(input, context), (value) => { + const regex = new RegExp(pattern, 'g'); + + if (!regex.test(value)) { + return rt.failure(input, context, message); + } + + return rt.success(value); + }), + rt.identity + ); diff --git a/x-pack/plugins/cases/common/types/api/case/v1.test.ts b/x-pack/plugins/cases/common/types/api/case/v1.test.ts index 91676b7ca6510..ce27fe5070b2c 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.test.ts @@ -16,12 +16,16 @@ import { MAX_LENGTH_PER_TAG, MAX_TITLE_LENGTH, MAX_CATEGORY_LENGTH, + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, } from '../../../constants'; import { PathReporter } from 'io-ts/lib/PathReporter'; import { AttachmentType } from '../../domain/attachment/v1'; +import type { Case } from '../../domain/case/v1'; import { CaseSeverity, CaseStatuses } from '../../domain/case/v1'; import { ConnectorTypes } from '../../domain/connector/v1'; import { CasesStatusRequestRt, CasesStatusResponseRt } from '../stats/v1'; +import type { CasePostRequest } from './v1'; import { AllReportersFindRequestRt, CasePatchRequestRt, @@ -37,8 +41,9 @@ import { CasesFindResponseRt, CasesPatchRequestRt, } from './v1'; +import { CustomFieldTypes } from '../../domain/custom_field/v1'; -const basicCase = { +const basicCase: Case = { owner: 'cases', closed_at: null, closed_by: null, @@ -96,289 +101,398 @@ const basicCase = { // damaged_raccoon uid assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], category: null, + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'second_custom_field_key', + type: CustomFieldTypes.TEXT, + value: ['www.example.com'], + }, + ], }; -describe('Status', () => { - describe('CasesStatusRequestRt', () => { - const defaultRequest = { - from: '2022-04-28T15:18:00.000Z', - to: '2022-04-28T15:22:00.000Z', - owner: 'cases', - }; +describe('CasePostRequestRt', () => { + const defaultRequest: CasePostRequest = { + description: 'A description', + tags: ['new', 'case'], + title: 'My new case', + connector: { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }, + settings: { + syncAlerts: true, + }, + owner: 'cases', + severity: CaseSeverity.LOW, + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + customFields: [ + { + key: 'first_custom_field_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }; - it('has expected attributes in request', () => { - const query = CasesStatusRequestRt.decode(defaultRequest); + it('has expected attributes in request', () => { + const query = CasePostRequestRt.decode(defaultRequest); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, }); + }); - it('has removes foo:bar attributes from request', () => { - const query = CasesStatusRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + it('removes foo:bar attributes from request', () => { + const query = CasePostRequestRt.decode({ ...defaultRequest, foo: 'bar' }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, }); }); - describe('CasesStatusResponseRt', () => { - const defaultResponse = { - count_closed_cases: 1, - count_in_progress_cases: 2, - count_open_cases: 1, - }; - - it('has expected attributes in response', () => { - const query = CasesStatusResponseRt.decode(defaultResponse); + it('removes foo:bar attributes from connector', () => { + const query = CasePostRequestRt.decode({ + ...defaultRequest, + connector: { ...defaultRequest.connector, foo: 'bar' }, + }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultResponse, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, }); + }); - it('removes foo:bar attributes from response', () => { - const query = CasesStatusResponseRt.decode({ ...defaultResponse, foo: 'bar' }); + it(`throws an error when the assignees are more than ${MAX_ASSIGNEES_PER_CASE}`, async () => { + const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foobar' }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultResponse, - }); - }); + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees })) + ).toContain('The length of the field assignees is too long. Array must be of length <= 10.'); }); - describe('CasePostRequestRt', () => { - const defaultRequest = { - description: 'A description', - tags: ['new', 'case'], - title: 'My new case', - connector: { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }, - settings: { - syncAlerts: true, - }, - owner: 'cases', - severity: CaseSeverity.LOW, - assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], + it('does not throw an error with empty assignees', async () => { + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees: [] })) + ).toContain('No errors!'); + }); + + it('does not throw an error with undefined assignees', async () => { + const { assignees, ...rest } = defaultRequest; + + expect(PathReporter.report(CasePostRequestRt.decode(rest))).toContain('No errors!'); + }); + + it(`throws an error when the description contains more than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { + const description = 'a'.repeat(MAX_DESCRIPTION_LENGTH + 1); + + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, description })) + ).toContain('The length of the description is too long. The maximum length is 30000.'); + }); + + it(`throws an error when there are more than ${MAX_TAGS_PER_CASE} tags`, async () => { + const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foobar'); + + expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags }))).toContain( + 'The length of the field tags is too long. Array must be of length <= 200.' + ); + }); + + it(`throws an error when the a tag is more than ${MAX_LENGTH_PER_TAG} characters`, async () => { + const tag = 'a'.repeat(MAX_LENGTH_PER_TAG + 1); + + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags: [tag] })) + ).toContain('The length of the tag is too long. The maximum length is 256.'); + }); + + it(`throws an error when the title contains more than ${MAX_TITLE_LENGTH} characters`, async () => { + const title = 'a'.repeat(MAX_TITLE_LENGTH + 1); + + expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, title }))).toContain( + 'The length of the title is too long. The maximum length is 160.' + ); + }); + + it(`throws an error when the category contains more than ${MAX_CATEGORY_LENGTH} characters`, async () => { + const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1); + + expect( + PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, category })) + ).toContain('The length of the category is too long. The maximum length is 50.'); + }); + + it('removes foo:bar attributes from customFields', () => { + const customField = { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], }; - it('has expected attributes in request', () => { - const query = CasePostRequestRt.decode(defaultRequest); + const query = CasePostRequestRt.decode({ + ...defaultRequest, + customFields: [{ ...customField, foo: 'bar' }], + }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, customFields: [{ ...customField }] }, }); + }); - it('removes foo:bar attributes from request', () => { - const query = CasePostRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + it('removes foo:bar attributes from field inside customFields', () => { + const customField = { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }; - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + const query = CasePostRequestRt.decode({ + ...defaultRequest, + customFields: [{ ...customField, foo: 'bar' }], }); - it('removes foo:bar attributes from connector', () => { - const query = CasePostRequestRt.decode({ - ...defaultRequest, - connector: { ...defaultRequest.connector, foo: 'bar' }, - }); + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, customFields: [{ ...customField }] }, + }); + }); - expect(query).toStrictEqual({ - _tag: 'Right', - right: defaultRequest, - }); + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], }); - it(`throws an error when the assignees are more than ${MAX_ASSIGNEES_PER_CASE}`, async () => { - const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foobar' }); + expect( + PathReporter.report( + CasePostRequestRt.decode({ + ...defaultRequest, + customFields, + }) + ) + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it('does not throw an error with undefined customFields', async () => { + const { customFields, ...rest } = defaultRequest; - expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees })) - ).toContain('The length of the field assignees is too long. Array must be of length <= 10.'); + expect(PathReporter.report(CasePostRequestRt.decode(rest))).toContain('No errors!'); + }); + + it(`throws an error when a text customFields is longer than ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}`, () => { + expect( + PathReporter.report( + CasePostRequestRt.decode({ + ...defaultRequest, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['#'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1)], + }, + ], + }) + ) + ).toContain( + `The length of the value is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}.` + ); + }); +}); + +describe('CasesFindRequestRt', () => { + const defaultRequest = { + tags: ['new', 'case'], + status: CaseStatuses.open, + severity: CaseSeverity.LOW, + assignees: ['damaged_racoon'], + reporters: ['damaged_racoon'], + defaultSearchOperator: 'AND', + from: 'now', + page: '1', + perPage: '10', + search: 'search text', + searchFields: ['title', 'description'], + to: '1w', + sortOrder: 'desc', + sortField: 'createdAt', + owner: 'cases', + }; + + it('has expected attributes in request', () => { + const query = CasesFindRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, page: 1, perPage: 10 }, }); + }); - it('does not throw an error with empty assignees', async () => { - expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, assignees: [] })) - ).toContain('No errors!'); + it('removes foo:bar attributes from request', () => { + const query = CasesFindRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, page: 1, perPage: 10 }, }); + }); - it('does not throw an error with undefined assignees', async () => { - const { assignees, ...rest } = defaultRequest; + const searchFields = Object.keys(CasesFindRequestSearchFieldsRt.keys); - expect(PathReporter.report(CasePostRequestRt.decode(rest))).toContain('No errors!'); + it.each(searchFields)('succeeds with %s as searchFields', (field) => { + const query = CasesFindRequestRt.decode({ ...defaultRequest, searchFields: field }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, searchFields: field, page: 1, perPage: 10 }, }); + }); - it(`throws an error when the description contains more than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { - const description = 'a'.repeat(MAX_DESCRIPTION_LENGTH + 1); + const sortFields = Object.keys(CasesFindRequestSortFieldsRt.keys); + it.each(sortFields)('succeeds with %s as sortField', (sortField) => { + const query = CasesFindRequestRt.decode({ ...defaultRequest, sortField }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, sortField, page: 1, perPage: 10 }, + }); + }); + + it('removes rootSearchField when passed', () => { + expect( + PathReporter.report( + CasesFindRequestRt.decode({ ...defaultRequest, rootSearchField: ['foobar'] }) + ) + ).toContain('No errors!'); + }); + + describe('errors', () => { + it('throws error when invalid searchField passed', () => { expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, description })) - ).toContain('The length of the description is too long. The maximum length is 30000.'); + PathReporter.report( + CasesFindRequestRt.decode({ ...defaultRequest, searchFields: 'foobar' }) + ) + ).not.toContain('No errors!'); }); - it(`throws an error when there are more than ${MAX_TAGS_PER_CASE} tags`, async () => { - const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foobar'); + it('throws error when invalid sortField passed', () => { + expect( + PathReporter.report(CasesFindRequestRt.decode({ ...defaultRequest, sortField: 'foobar' })) + ).not.toContain('No errors!'); + }); - expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags }))).toContain( - 'The length of the field tags is too long. Array must be of length <= 200.' + it('succeeds when valid parameters passed', () => { + expect(PathReporter.report(CasesFindRequestRt.decode(defaultRequest))).toContain( + 'No errors!' ); }); - it(`throws an error when the a tag is more than ${MAX_LENGTH_PER_TAG} characters`, async () => { - const tag = 'a'.repeat(MAX_LENGTH_PER_TAG + 1); + it(`throws an error when the category array has ${MAX_CATEGORY_FILTER_LENGTH} items`, async () => { + const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar'); - expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, tags: [tag] })) - ).toContain('The length of the tag is too long. The maximum length is 256.'); + expect(PathReporter.report(CasesFindRequestRt.decode({ category }))).toContain( + 'The length of the field category is too long. Array must be of length <= 100.' + ); }); - it(`throws an error when the title contains more than ${MAX_TITLE_LENGTH} characters`, async () => { - const title = 'a'.repeat(MAX_TITLE_LENGTH + 1); + it(`throws an error when the tags array has ${MAX_TAGS_FILTER_LENGTH} items`, async () => { + const tags = Array(MAX_TAGS_FILTER_LENGTH + 1).fill('foobar'); - expect(PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, title }))).toContain( - 'The length of the title is too long. The maximum length is 160.' + expect(PathReporter.report(CasesFindRequestRt.decode({ tags }))).toContain( + 'The length of the field tags is too long. Array must be of length <= 100.' ); }); - it(`throws an error when the category contains more than ${MAX_CATEGORY_LENGTH} characters`, async () => { - const category = 'a'.repeat(MAX_CATEGORY_LENGTH + 1); + it(`throws an error when the assignees array has ${MAX_ASSIGNEES_FILTER_LENGTH} items`, async () => { + const assignees = Array(MAX_ASSIGNEES_FILTER_LENGTH + 1).fill('foobar'); - expect( - PathReporter.report(CasePostRequestRt.decode({ ...defaultRequest, category })) - ).toContain('The length of the category is too long. The maximum length is 50.'); + expect(PathReporter.report(CasesFindRequestRt.decode({ assignees }))).toContain( + 'The length of the field assignees is too long. Array must be of length <= 100.' + ); + }); + + it(`throws an error when the reporters array has ${MAX_REPORTERS_FILTER_LENGTH} items`, async () => { + const reporters = Array(MAX_REPORTERS_FILTER_LENGTH + 1).fill('foobar'); + + expect(PathReporter.report(CasesFindRequestRt.decode({ reporters }))).toContain( + 'The length of the field reporters is too long. Array must be of length <= 100.' + ); }); }); +}); - describe('CasesFindRequestRt', () => { +describe('Status', () => { + describe('CasesStatusRequestRt', () => { const defaultRequest = { - tags: ['new', 'case'], - status: CaseStatuses.open, - severity: CaseSeverity.LOW, - assignees: ['damaged_racoon'], - reporters: ['damaged_racoon'], - defaultSearchOperator: 'AND', - from: 'now', - page: '1', - perPage: '10', - search: 'search text', - searchFields: ['title', 'description'], - to: '1w', - sortOrder: 'desc', - sortField: 'createdAt', + from: '2022-04-28T15:18:00.000Z', + to: '2022-04-28T15:22:00.000Z', owner: 'cases', }; it('has expected attributes in request', () => { - const query = CasesFindRequestRt.decode(defaultRequest); + const query = CasesStatusRequestRt.decode(defaultRequest); expect(query).toStrictEqual({ _tag: 'Right', - right: { ...defaultRequest, page: 1, perPage: 10 }, + right: defaultRequest, }); }); - it('removes foo:bar attributes from request', () => { - const query = CasesFindRequestRt.decode({ ...defaultRequest, foo: 'bar' }); + it('has removes foo:bar attributes from request', () => { + const query = CasesStatusRequestRt.decode({ ...defaultRequest, foo: 'bar' }); expect(query).toStrictEqual({ _tag: 'Right', - right: { ...defaultRequest, page: 1, perPage: 10 }, + right: defaultRequest, }); }); + }); - const searchFields = Object.keys(CasesFindRequestSearchFieldsRt.keys); + describe('CasesStatusResponseRt', () => { + const defaultResponse = { + count_closed_cases: 1, + count_in_progress_cases: 2, + count_open_cases: 1, + }; - it.each(searchFields)('succeeds with %s as searchFields', (field) => { - const query = CasesFindRequestRt.decode({ ...defaultRequest, searchFields: field }); + it('has expected attributes in response', () => { + const query = CasesStatusResponseRt.decode(defaultResponse); expect(query).toStrictEqual({ _tag: 'Right', - right: { ...defaultRequest, searchFields: field, page: 1, perPage: 10 }, + right: defaultResponse, }); }); - const sortFields = Object.keys(CasesFindRequestSortFieldsRt.keys); - - it.each(sortFields)('succeeds with %s as sortField', (sortField) => { - const query = CasesFindRequestRt.decode({ ...defaultRequest, sortField }); + it('removes foo:bar attributes from response', () => { + const query = CasesStatusResponseRt.decode({ ...defaultResponse, foo: 'bar' }); expect(query).toStrictEqual({ _tag: 'Right', - right: { ...defaultRequest, sortField, page: 1, perPage: 10 }, - }); - }); - - it('removes rootSearchField when passed', () => { - expect( - PathReporter.report( - CasesFindRequestRt.decode({ ...defaultRequest, rootSearchField: ['foobar'] }) - ) - ).toContain('No errors!'); - }); - - describe('errors', () => { - it('throws error when invalid searchField passed', () => { - expect( - PathReporter.report( - CasesFindRequestRt.decode({ ...defaultRequest, searchFields: 'foobar' }) - ) - ).not.toContain('No errors!'); - }); - - it('throws error when invalid sortField passed', () => { - expect( - PathReporter.report(CasesFindRequestRt.decode({ ...defaultRequest, sortField: 'foobar' })) - ).not.toContain('No errors!'); - }); - - it('succeeds when valid parameters passed', () => { - expect(PathReporter.report(CasesFindRequestRt.decode(defaultRequest))).toContain( - 'No errors!' - ); - }); - - it(`throws an error when the category array has ${MAX_CATEGORY_FILTER_LENGTH} items`, async () => { - const category = Array(MAX_CATEGORY_FILTER_LENGTH + 1).fill('foobar'); - - expect(PathReporter.report(CasesFindRequestRt.decode({ category }))).toContain( - 'The length of the field category is too long. Array must be of length <= 100.' - ); - }); - - it(`throws an error when the tags array has ${MAX_TAGS_FILTER_LENGTH} items`, async () => { - const tags = Array(MAX_TAGS_FILTER_LENGTH + 1).fill('foobar'); - - expect(PathReporter.report(CasesFindRequestRt.decode({ tags }))).toContain( - 'The length of the field tags is too long. Array must be of length <= 100.' - ); - }); - - it(`throws an error when the assignees array has ${MAX_ASSIGNEES_FILTER_LENGTH} items`, async () => { - const assignees = Array(MAX_ASSIGNEES_FILTER_LENGTH + 1).fill('foobar'); - - expect(PathReporter.report(CasesFindRequestRt.decode({ assignees }))).toContain( - 'The length of the field assignees is too long. Array must be of length <= 100.' - ); - }); - - it(`throws an error when the reporters array has ${MAX_REPORTERS_FILTER_LENGTH} items`, async () => { - const reporters = Array(MAX_REPORTERS_FILTER_LENGTH + 1).fill('foobar'); - - expect(PathReporter.report(CasesFindRequestRt.decode({ reporters }))).toContain( - 'The length of the field reporters is too long. Array must be of length <= 100.' - ); + right: defaultResponse, }); }); }); @@ -478,6 +592,18 @@ describe('CasePatchRequestRt', () => { id: 'basic-case-id', version: 'WzQ3LDFd', description: 'Updated description', + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ], }; it('has expected attributes in request', () => { @@ -555,6 +681,44 @@ describe('CasePatchRequestRt', () => { PathReporter.report(CasePatchRequestRt.decode({ ...defaultRequest, category })) ).toContain('The length of the category is too long. The maximum length is 50.'); }); + + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }); + + expect( + PathReporter.report( + CasePatchRequestRt.decode({ + ...defaultRequest, + customFields, + }) + ) + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it(`throws an error when a text customFields is longer than ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}`, () => { + expect( + PathReporter.report( + CasePatchRequestRt.decode({ + ...defaultRequest, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['#'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1)], + }, + ], + }) + ) + ).toContain( + `The length of the value is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH}.` + ); + }); }); describe('CasesPatchRequestRt', () => { diff --git a/x-pack/plugins/cases/common/types/api/case/v1.ts b/x-pack/plugins/cases/common/types/api/case/v1.ts index 7a92c1f32ca12..f808080cc2f0d 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.ts @@ -21,6 +21,9 @@ import { MAX_BULK_GET_CASES, MAX_CATEGORY_FILTER_LENGTH, MAX_ASSIGNEES_PER_CASE, + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + MAX_CUSTOM_FIELD_TEXT_VALUE_ITEMS, } from '../../../constants'; import { limitedStringSchema, @@ -28,6 +31,7 @@ import { NonEmptyString, paginationSchema, } from '../../../schema'; +import { CaseCustomFieldToggleRt, CustomFieldTextTypeRt } from '../../domain'; import { CaseRt, CaseSettingsRt, @@ -40,10 +44,35 @@ import { CaseConnectorRt } from '../../domain/connector/v1'; import { CaseUserProfileRt, UserRt } from '../../domain/user/v1'; import { CasesStatusResponseRt } from '../stats/v1'; +const CaseCustomFieldWithValidationValueRt = limitedArraySchema({ + codec: limitedStringSchema({ + fieldName: 'value', + min: 0, + max: MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + }), + fieldName: 'value', + min: 0, + max: MAX_CUSTOM_FIELD_TEXT_VALUE_ITEMS, +}); + +const CaseCustomFieldTextWithValidationRt = rt.strict({ + key: rt.string, + type: CustomFieldTextTypeRt, + value: rt.union([CaseCustomFieldWithValidationValueRt, rt.null]), +}); + +const CustomFieldRt = rt.union([CaseCustomFieldTextWithValidationRt, CaseCustomFieldToggleRt]); + +const CustomFieldsRt = limitedArraySchema({ + codec: CustomFieldRt, + fieldName: 'customFields', + min: 0, + max: MAX_CUSTOM_FIELDS_PER_CASE, +}); + /** * Create case */ - export const CasePostRequestRt = rt.intersection([ rt.strict({ /** @@ -104,6 +133,10 @@ export const CasePostRequestRt = rt.intersection([ limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), rt.null, ]), + /** + * The list of custom field values of the case. + */ + customFields: CustomFieldsRt, }) ), ]); @@ -358,6 +391,10 @@ export const CasePatchRequestRt = rt.intersection([ limitedStringSchema({ fieldName: 'category', min: 1, max: MAX_CATEGORY_LENGTH }), rt.null, ]), + /** + * Custom fields of the case + */ + customFields: CustomFieldsRt, }) ), /** @@ -448,3 +485,4 @@ export type GetReportersResponse = rt.TypeOf; export type CasesBulkGetRequest = rt.TypeOf; export type CasesBulkGetResponse = rt.TypeOf; export type GetRelatedCasesByAlertResponse = rt.TypeOf; +export type CaseRequestCustomFields = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts index d5756a606d43b..b69e7701db69c 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts @@ -5,12 +5,23 @@ * 2.0. */ +import { PathReporter } from 'io-ts/lib/PathReporter'; +import { v4 as uuidv4 } from 'uuid'; +import { + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_CUSTOM_FIELD_KEY_LENGTH, + MAX_CUSTOM_FIELD_LABEL_LENGTH, +} from '../../../constants'; import { ConnectorTypes } from '../../domain/connector/v1'; +import { CustomFieldTypes } from '../../domain/custom_field/v1'; import { CaseConfigureRequestParamsRt, ConfigurationPatchRequestRt, ConfigurationRequestRt, GetConfigurationFindRequestRt, + CustomFieldConfigurationWithoutTypeRt, + TextCustomFieldConfigurationRt, + ToggleCustomFieldConfigurationRt, } from './v1'; describe('configure', () => { @@ -37,6 +48,47 @@ describe('configure', () => { }); }); + it('has expected attributes in request with customFields', () => { + const request = { + ...defaultRequest, + customFields: [ + { + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'toggle_custom_field', + label: 'Toggle custom field', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }; + const query = ConfigurationRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }); + + expect( + PathReporter.report(ConfigurationRequestRt.decode({ ...defaultRequest, customFields }))[0] + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}` + ); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -63,6 +115,49 @@ describe('configure', () => { }); }); + it('has expected attributes in request with customFields', () => { + const request = { + ...defaultRequest, + customFields: [ + { + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'toggle_custom_field', + label: 'Toggle custom field', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }; + const query = ConfigurationPatchRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { + const customFields = new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }); + + expect( + PathReporter.report( + ConfigurationPatchRequestRt.decode({ ...defaultRequest, customFields }) + )[0] + ).toContain( + `The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}` + ); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -120,4 +215,136 @@ describe('configure', () => { }); }); }); + + describe('CustomFieldConfigurationWithoutTypeRt', () => { + const defaultRequest = { + key: 'custom_field_key', + label: 'Custom field label', + required: false, + }; + + it('has expected attributes in request', () => { + const query = CustomFieldConfigurationWithoutTypeRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('limits key to 36 characters', () => { + const longKey = 'x'.repeat(MAX_CUSTOM_FIELD_KEY_LENGTH + 1); + + expect( + PathReporter.report( + CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key: longKey }) + ) + ).toContain('The length of the key is too long. The maximum length is 36.'); + }); + + it('returns an error if they key is not in the expected format', () => { + const key = 'Not a proper key'; + + expect( + PathReporter.report( + CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key }) + ) + ).toContain(`Key must be lower case, a-z, 0-9, '_', and '-' are allowed`); + }); + + it('accepts a uuid as a key', () => { + const key = uuidv4(); + + const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('accepts a slug as a key', () => { + const key = 'abc_key-1'; + + const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, key }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest, key }, + }); + }); + + it('limits label to 50 characters', () => { + const longLabel = 'x'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); + + expect( + PathReporter.report( + CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, label: longLabel }) + ) + ).toContain('The length of the label is too long. The maximum length is 50.'); + }); + }); + + describe('TextCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_text_custom_field', + label: 'Text Custom Field', + type: CustomFieldTypes.TEXT, + required: true, + }; + + it('has expected attributes in request', () => { + const query = TextCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TextCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); + + describe('ToggleCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_toggle_custom_field', + label: 'Toggle Custom Field', + type: CustomFieldTypes.TOGGLE, + required: false, + }; + + it('has expected attributes in request', () => { + const query = ToggleCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ToggleCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.ts b/x-pack/plugins/cases/common/types/api/configure/v1.ts index fffff5310f6af..8f98b760c9186 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.ts @@ -6,10 +6,74 @@ */ import * as rt from 'io-ts'; +import { + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_CUSTOM_FIELD_KEY_LENGTH, + MAX_CUSTOM_FIELD_LABEL_LENGTH, +} from '../../../constants'; +import { limitedArraySchema, limitedStringSchema, regexStringRt } from '../../../schema'; +import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../../domain'; import type { Configurations, Configuration } from '../../domain/configure/v1'; -import { ConfigurationBasicWithoutOwnerRt, CasesConfigureBasicRt } from '../../domain/configure/v1'; +import { ConfigurationBasicWithoutOwnerRt, ClosureTypeRt } from '../../domain/configure/v1'; +import { CaseConnectorRt } from '../../domain/connector/v1'; -export const ConfigurationRequestRt = CasesConfigureBasicRt; +export const CustomFieldConfigurationWithoutTypeRt = rt.strict({ + /** + * key of custom field + */ + key: regexStringRt({ + codec: limitedStringSchema({ fieldName: 'key', min: 1, max: MAX_CUSTOM_FIELD_KEY_LENGTH }), + pattern: '^[a-z0-9_-]+$', + message: `Key must be lower case, a-z, 0-9, '_', and '-' are allowed`, + }), + /** + * label of custom field + */ + label: limitedStringSchema({ fieldName: 'label', min: 1, max: MAX_CUSTOM_FIELD_LABEL_LENGTH }), + /** + * custom field options - required + */ + required: rt.boolean, +}); + +export const TextCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldTextTypeRt }), + CustomFieldConfigurationWithoutTypeRt, +]); + +export const ToggleCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldToggleTypeRt }), + CustomFieldConfigurationWithoutTypeRt, +]); + +export const CustomFieldsConfigurationRt = limitedArraySchema({ + codec: rt.union([TextCustomFieldConfigurationRt, ToggleCustomFieldConfigurationRt]), + min: 0, + max: MAX_CUSTOM_FIELDS_PER_CASE, + fieldName: 'customFields', +}); + +export const ConfigurationRequestRt = rt.intersection([ + rt.strict({ + /** + * The external connector + */ + connector: CaseConnectorRt, + /** + * Whether to close the case after it has been synced with the external system + */ + closure_type: ClosureTypeRt, + /** + * The plugin owner that manages this configuration + */ + owner: rt.string, + }), + rt.exact( + rt.partial({ + customFields: CustomFieldsConfigurationRt, + }) + ), +]); export const GetConfigurationFindRequestRt = rt.exact( rt.partial({ @@ -26,7 +90,13 @@ export const CaseConfigureRequestParamsRt = rt.strict({ }); export const ConfigurationPatchRequestRt = rt.intersection([ - rt.exact(rt.partial(ConfigurationBasicWithoutOwnerRt.type.props)), + rt.exact( + rt.partial({ + closure_type: ConfigurationBasicWithoutOwnerRt.type.props.closure_type, + connector: ConfigurationBasicWithoutOwnerRt.type.props.connector, + customFields: CustomFieldsConfigurationRt, + }) + ), rt.strict({ version: rt.string }), ]); diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.test.ts b/x-pack/plugins/cases/common/types/domain/case/v1.test.ts index ba04082519c8c..d7dc74f6c3cb4 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.test.ts @@ -74,6 +74,18 @@ const basicCase = { // damaged_raccoon uid assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], category: null, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ], }; describe('RelatedCaseRt', () => { @@ -170,6 +182,18 @@ describe('CaseAttributesRt', () => { updated_at: '2020-02-20T15:02:57.995Z', updated_by: null, category: null, + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ], }; it('has expected attributes in request', () => { diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.ts b/x-pack/plugins/cases/common/types/domain/case/v1.ts index e4e16b7fb33a7..d8da843e46a0c 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.ts @@ -11,6 +11,7 @@ import { ExternalServiceRt } from '../external_service/v1'; import { CaseAssigneesRt, UserRt } from '../user/v1'; import { CaseConnectorRt } from '../connector/v1'; import { AttachmentRt } from '../attachment/v1'; +import { CaseCustomFieldsRt } from '../custom_field/v1'; export { CaseStatuses }; @@ -92,6 +93,11 @@ const CaseBasicRt = rt.strict({ * The category of the case. */ category: rt.union([rt.string, rt.null]), + /** + * An array containing the possible, + * user-configured custom fields. + */ + customFields: CaseCustomFieldsRt, }); export const CaseAttributesRt = rt.intersection([ diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts index 70b181ef004e6..9af0d2b474b76 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts @@ -6,7 +6,14 @@ */ import { ConnectorTypes } from '../connector/v1'; -import { ConfigurationAttributesRt, ConfigurationRt } from './v1'; +import { CustomFieldTypes } from '../custom_field/v1'; +import { + ConfigurationAttributesRt, + ConfigurationRt, + CustomFieldConfigurationWithoutTypeRt, + TextCustomFieldConfigurationRt, + ToggleCustomFieldConfigurationRt, +} from './v1'; describe('configure', () => { const serviceNow = { @@ -23,10 +30,25 @@ describe('configure', () => { fields: null, }; + const textCustomField = { + key: 'text_custom_field', + label: 'Text custom field', + type: CustomFieldTypes.TEXT, + required: false, + }; + + const toggleCustomField = { + key: 'toggle_custom_field', + label: 'Toggle custom field', + type: CustomFieldTypes.TOGGLE, + required: false, + }; + describe('ConfigurationAttributesRt', () => { const defaultRequest = { connector: resilient, closure_type: 'close-by-user', + customFields: [textCustomField, toggleCustomField], owner: 'cases', created_at: '2020-02-19T23:06:33.798Z', created_by: { @@ -47,7 +69,10 @@ describe('configure', () => { expect(query).toStrictEqual({ _tag: 'Right', - right: defaultRequest, + right: { + ...defaultRequest, + customFields: [textCustomField, toggleCustomField], + }, }); }); @@ -56,7 +81,25 @@ describe('configure', () => { expect(query).toStrictEqual({ _tag: 'Right', - right: defaultRequest, + right: { + ...defaultRequest, + customFields: [textCustomField, toggleCustomField], + }, + }); + }); + + it('removes foo:bar attributes from custom fields', () => { + const query = ConfigurationAttributesRt.decode({ + ...defaultRequest, + customFields: [{ ...textCustomField, foo: 'bar' }, toggleCustomField], + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { + ...defaultRequest, + customFields: [textCustomField, toggleCustomField], + }, }); }); }); @@ -65,6 +108,7 @@ describe('configure', () => { const defaultRequest = { connector: serviceNow, closure_type: 'close-by-user', + customFields: [], created_at: '2020-02-19T23:06:33.798Z', created_by: { full_name: 'Leslie Knope', @@ -116,4 +160,84 @@ describe('configure', () => { }); }); }); + + describe('CustomFieldConfigurationWithoutTypeRt', () => { + const defaultRequest = { + key: 'custom_field_key', + label: 'Custom field label', + required: false, + }; + + it('has expected attributes in request', () => { + const query = CustomFieldConfigurationWithoutTypeRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CustomFieldConfigurationWithoutTypeRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); + + describe('TextCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_text_custom_field', + label: 'Text Custom Field', + type: CustomFieldTypes.TEXT, + required: true, + }; + + it('has expected attributes in request', () => { + const query = TextCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = TextCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); + + describe('ToggleCustomFieldConfigurationRt', () => { + const defaultRequest = { + key: 'my_toggle_custom_field', + label: 'Toggle Custom Field', + type: CustomFieldTypes.TOGGLE, + required: false, + }; + + it('has expected attributes in request', () => { + const query = ToggleCustomFieldConfigurationRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = ToggleCustomFieldConfigurationRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: { ...defaultRequest }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.ts index 664793c23b198..56a144c248c2a 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.ts @@ -8,8 +8,44 @@ import * as rt from 'io-ts'; import { CaseConnectorRt, ConnectorMappingsRt } from '../connector/v1'; import { UserRt } from '../user/v1'; +import { CustomFieldTextTypeRt, CustomFieldToggleTypeRt } from '../custom_field/v1'; -const ClosureTypeRt = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); +export const ClosureTypeRt = rt.union([ + rt.literal('close-by-user'), + rt.literal('close-by-pushing'), +]); + +export const CustomFieldConfigurationWithoutTypeRt = rt.strict({ + /** + * key of custom field + */ + key: rt.string, + /** + * label of custom field + */ + label: rt.string, + /** + * custom field options - required + */ + required: rt.boolean, +}); + +export const TextCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldTextTypeRt }), + CustomFieldConfigurationWithoutTypeRt, +]); + +export const ToggleCustomFieldConfigurationRt = rt.intersection([ + rt.strict({ type: CustomFieldToggleTypeRt }), + CustomFieldConfigurationWithoutTypeRt, +]); + +export const CustomFieldConfigurationRt = rt.union([ + TextCustomFieldConfigurationRt, + ToggleCustomFieldConfigurationRt, +]); + +export const CustomFieldsConfigurationRt = rt.array(CustomFieldConfigurationRt); export const ConfigurationBasicWithoutOwnerRt = rt.strict({ /** @@ -20,6 +56,10 @@ export const ConfigurationBasicWithoutOwnerRt = rt.strict({ * Whether to close the case after it has been synced with the external system */ closure_type: ClosureTypeRt, + /** + * The custom fields configured for the case + */ + customFields: CustomFieldsConfigurationRt, }); export const CasesConfigureBasicRt = rt.intersection([ @@ -57,6 +97,8 @@ export const ConfigurationRt = rt.intersection([ export const ConfigurationsRt = rt.array(ConfigurationRt); +export type CustomFieldsConfiguration = rt.TypeOf; +export type CustomFieldConfiguration = rt.TypeOf; export type ClosureType = rt.TypeOf; export type ConfigurationAttributes = rt.TypeOf; export type Configuration = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/domain/custom_field/latest.ts b/x-pack/plugins/cases/common/types/domain/custom_field/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/custom_field/latest.ts @@ -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 './v1'; diff --git a/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts b/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts new file mode 100644 index 0000000000000..2100b221239a9 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/custom_field/v1.test.ts @@ -0,0 +1,73 @@ +/* + * 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 { PathReporter } from 'io-ts/lib/PathReporter'; +import { CaseCustomFieldRt } from './v1'; + +describe('CaseCustomFieldRt', () => { + it.each([ + [ + 'type text value text', + { + key: 'string_custom_field_1', + type: 'text', + value: ['this is a text field value'], + }, + ], + [ + 'type text value null', + { + key: 'string_custom_field_2', + type: 'text', + value: null, + }, + ], + [ + 'type toggle value boolean', + { + key: 'toggle_custom_field_1', + type: 'toggle', + value: true, + }, + ], + [ + 'type toggle value null', + { + key: 'toggle_custom_field_2', + type: 'toggle', + value: null, + }, + ], + ])(`has expected attributes for customField with %s`, (_, customField) => { + const query = CaseCustomFieldRt.decode(customField); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: customField, + }); + }); + + it('fails if text type and value do not match expected attributes in request', () => { + const query = CaseCustomFieldRt.decode({ + key: 'text_custom_field_1', + type: 'text', + value: [1], + }); + + expect(PathReporter.report(query)[0]).toContain('Invalid value 1 supplied'); + }); + + it('fails if toggle type and value do not match expected attributes in request', () => { + const query = CaseCustomFieldRt.decode({ + key: 'list_custom_field_1', + type: 'toggle', + value: 'hello', + }); + + expect(PathReporter.report(query)[0]).toContain('Invalid value "hello" supplied'); + }); +}); diff --git a/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts b/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts new file mode 100644 index 0000000000000..44a8a01740ea9 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/custom_field/v1.ts @@ -0,0 +1,35 @@ +/* + * 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 * as rt from 'io-ts'; + +export enum CustomFieldTypes { + TEXT = 'text', + TOGGLE = 'toggle', +} + +export const CustomFieldTextTypeRt = rt.literal(CustomFieldTypes.TEXT); +export const CustomFieldToggleTypeRt = rt.literal(CustomFieldTypes.TOGGLE); + +const CaseCustomFieldTextRt = rt.strict({ + key: rt.string, + type: CustomFieldTextTypeRt, + value: rt.union([rt.array(rt.string), rt.null]), +}); + +export const CaseCustomFieldToggleRt = rt.strict({ + key: rt.string, + type: CustomFieldToggleTypeRt, + value: rt.union([rt.boolean, rt.null]), +}); + +export const CaseCustomFieldRt = rt.union([CaseCustomFieldTextRt, CaseCustomFieldToggleRt]); +export const CaseCustomFieldsRt = rt.array(CaseCustomFieldRt); + +export type CaseCustomFields = rt.TypeOf; +export type CaseCustomField = rt.TypeOf; +export type CaseCustomFieldToggle = rt.TypeOf; +export type CaseCustomFieldText = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/domain/index.ts b/x-pack/plugins/cases/common/types/domain/index.ts index c46b34d182877..ef317908b4627 100644 --- a/x-pack/plugins/cases/common/types/domain/index.ts +++ b/x-pack/plugins/cases/common/types/domain/index.ts @@ -7,6 +7,7 @@ // Latest export * from './configure/latest'; +export * from './custom_field/latest'; export * from './user_action/latest'; export * from './external_service/latest'; export * from './case/latest'; @@ -16,6 +17,7 @@ export * from './attachment/latest'; // V1 export * as configureDomainV1 from './configure/v1'; +export * as customFieldDomainV1 from './custom_field/v1'; export * as userActionDomainV1 from './user_action/v1'; export * as externalServiceDomainV1 from './external_service/v1'; export * as caseDomainV1 from './case/v1'; diff --git a/x-pack/plugins/cases/common/types/domain/user_action/action/v1.ts b/x-pack/plugins/cases/common/types/domain/user_action/action/v1.ts index 3cdb80ed534d1..22c51e1da4d10 100644 --- a/x-pack/plugins/cases/common/types/domain/user_action/action/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/user_action/action/v1.ts @@ -25,6 +25,7 @@ export const UserActionTypes = { create_case: 'create_case', delete_case: 'delete_case', category: 'category', + customFields: 'customFields', } as const; type UserActionActionTypeKeys = keyof typeof UserActionTypes; diff --git a/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.test.ts b/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.test.ts index 17e4777d18afa..b0e0ce59945be 100644 --- a/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.test.ts @@ -74,6 +74,33 @@ describe('Create case', () => { }); }); + it('customFields are decoded correctly', () => { + const customFields = [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ]; + + const defaultRequestWithCustomFields = { + ...defaultRequest, + payload: { ...defaultRequest.payload, customFields }, + }; + + const query = CreateCaseUserActionRt.decode(defaultRequestWithCustomFields); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequestWithCustomFields, + }); + }); + it('removes foo:bar attributes from request', () => { const query = CreateCaseUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -159,6 +186,33 @@ describe('Create case', () => { }); }); + it('customFields are decoded correctly', () => { + const customFields = [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_custom_field_key', + type: 'toggle', + value: true, + }, + ]; + + const defaultRequestWithCustomFields = { + ...defaultRequest, + payload: { ...defaultRequest.payload, customFields }, + }; + + const query = CreateCaseUserActionWithoutConnectorIdRt.decode(defaultRequestWithCustomFields); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequestWithCustomFields, + }); + }); + it('removes foo:bar attributes from request', () => { const query = CreateCaseUserActionWithoutConnectorIdRt.decode({ ...defaultRequest, diff --git a/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.ts b/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.ts index 51eba91624b85..0a0938c9e4b2a 100644 --- a/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/user_action/create_case/v1.ts @@ -13,6 +13,7 @@ import { ConnectorUserActionPayloadRt, ConnectorUserActionPayloadWithoutConnectorIdRt, } from '../connector/v1'; +import { CustomFieldsUserActionPayloadRt } from '../custom_fields/v1'; import { DescriptionUserActionPayloadRt } from '../description/v1'; import { SettingsUserActionPayloadRt } from '../settings/v1'; import { TagsUserActionPayloadRt } from '../tags/v1'; @@ -36,6 +37,7 @@ const CommonPayloadAttributesRt = rt.strict({ const OptionalPayloadAttributesRt = rt.exact( rt.partial({ category: CategoryUserActionPayloadRt.type.props.category, + customFields: CustomFieldsUserActionPayloadRt.type.props.customFields, }) ); diff --git a/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/latest.ts b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/latest.ts @@ -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 './v1'; diff --git a/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.test.ts b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.test.ts new file mode 100644 index 0000000000000..39d3f9a5f08c1 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { UserActionTypes } from '../action/v1'; +import { CustomFieldsUserActionPayloadRt, CustomFieldsUserActionRt } from './v1'; + +describe('Custom field', () => { + describe('CustomFieldsUserActionPayloadRt', () => { + const defaultRequest = { + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value'], + }, + ], + }; + + it('has expected attributes in request', () => { + const query = CustomFieldsUserActionPayloadRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CustomFieldsUserActionPayloadRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); + + describe('CustomFieldsUserActionRt', () => { + const defaultRequest = { + type: UserActionTypes.customFields, + payload: { + customFields: [ + { + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value'], + }, + ], + }, + }; + + it('has expected attributes in request', () => { + const query = CustomFieldsUserActionRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from request', () => { + const query = CustomFieldsUserActionRt.decode({ ...defaultRequest, foo: 'bar' }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from payload', () => { + const query = CustomFieldsUserActionRt.decode({ + ...defaultRequest, + payload: { ...defaultRequest.payload, foo: 'bar' }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + + it('removes foo:bar attributes from the field', () => { + const query = CustomFieldsUserActionRt.decode({ + ...defaultRequest, + payload: { + ...defaultRequest.payload, + customFields: [{ ...defaultRequest.payload.customFields[0], foo: 'bar' }], + }, + }); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.ts b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.ts new file mode 100644 index 0000000000000..35a0377e03d60 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/user_action/custom_fields/v1.ts @@ -0,0 +1,19 @@ +/* + * 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 * as rt from 'io-ts'; +import { CaseCustomFieldsRt } from '../../custom_field/v1'; +import { UserActionTypes } from '../action/v1'; + +export const CustomFieldsUserActionPayloadRt = rt.strict({ + customFields: CaseCustomFieldsRt, +}); + +export const CustomFieldsUserActionRt = rt.strict({ + type: rt.literal(UserActionTypes.customFields), + payload: CustomFieldsUserActionPayloadRt, +}); diff --git a/x-pack/plugins/cases/common/types/domain/user_action/v1.ts b/x-pack/plugins/cases/common/types/domain/user_action/v1.ts index 365e21e305996..fdef2a9530e54 100644 --- a/x-pack/plugins/cases/common/types/domain/user_action/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/user_action/v1.ts @@ -22,6 +22,7 @@ import { SeverityUserActionRt } from './severity/v1'; import { StatusUserActionRt } from './status/v1'; import { TagsUserActionRt } from './tags/v1'; import { TitleUserActionRt } from './title/v1'; +import { CustomFieldsUserActionRt } from './custom_fields/v1'; export { UserActionTypes, UserActionActions } from './action/v1'; export { StatusUserActionRt } from './status/v1'; @@ -59,6 +60,7 @@ const BasicUserActionsRt = rt.union([ AssigneesUserActionRt, DeleteCaseUserActionRt, CategoryUserActionRt, + CustomFieldsUserActionRt, ]); const CommonUserActionsWithIdsRt = rt.union([BasicUserActionsRt, CommentUserActionRt]); @@ -151,3 +153,4 @@ export type CreateCaseUserAction = UserAction >; +export type CustomFieldsUserAction = UserAction>; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index e2e453aaaf278..2a76e56a59fe0 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -25,6 +25,7 @@ import type { Attachment, ExternalReferenceAttachment, PersistableStateAttachment, + Configuration, } from '../types/domain'; import type { CasePatchRequest, @@ -110,6 +111,7 @@ export type CasesMetrics = SnakeToCamelCase; export type CaseUpdateRequest = SnakeToCamelCase; export type CaseConnectors = SnakeToCamelCase; export type CaseUsers = GetCaseUsersResponse; +export type CaseUICustomField = CaseUI['customFields'][number]; export interface ResolvedCase { case: CaseUI; @@ -118,6 +120,13 @@ export interface ResolvedCase { aliasPurpose?: ResolvedSimpleSavedObject['alias_purpose']; } +export type CasesConfigurationUI = Pick< + SnakeToCamelCase, + 'closureType' | 'connector' | 'mappings' | 'customFields' | 'id' | 'version' +>; + +export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number]; + export type SortOrder = 'asc' | 'desc'; export const SORT_ORDER_VALUES: SortOrder[] = ['asc', 'desc']; @@ -205,6 +214,7 @@ export type UpdateKey = keyof Pick< | 'severity' | 'assignees' | 'category' + | 'customFields' >; export interface UpdateByKey { diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index c3fef89e021dd..b36a745179833 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -4579,6 +4579,54 @@ ], "default": "low" }, + "custom_fields_property": { + "type": "array", + "description": "Values for custom fields of a case", + "minItems": 0, + "maxItems": 5, + "items": { + "type": "object", + "required": [ + "key", + "type", + "field" + ], + "properties": { + "key": { + "description": "The key identifying the custom field.", + "type": "string" + }, + "type": { + "description": "The type of the custom field. Should match the key and how the custom field was configured", + "type": "string" + }, + "field": { + "description": "An object containing the value of the field.", + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "description": "The value of the field.", + "nullable": true, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + } + } + } + } + } + } + }, "create_case_request": { "title": "Create case request", "description": "The create case API request body varies depending on the type of connector.", @@ -4652,6 +4700,9 @@ "description": "A title for the case.", "type": "string", "maxLength": 160 + }, + "customFields": { + "$ref": "#/components/schemas/custom_fields_property" } } }, @@ -5296,6 +5347,9 @@ "version": { "description": "The current version of the case. To determine this value, use the get case or find cases APIs.", "type": "string" + }, + "customFields": { + "$ref": "#/components/schemas/custom_fields_property" } } } diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index f818d279a2e27..ddee756120e01 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -2890,6 +2890,38 @@ components: - low - medium default: low + custom_fields_property: + type: array + description: Values for custom fields of a case + minItems: 0 + maxItems: 5 + items: + type: object + required: + - key + - type + - field + properties: + key: + description: The key identifying the custom field. + type: string + type: + description: The type of the custom field. Should match the key and how the custom field was configured + type: string + field: + description: An object containing the value of the field. + type: object + required: + - value + properties: + value: + description: The value of the field. + nullable: true + type: array + items: + anyOf: + - type: string + - type: boolean create_case_request: title: Create case request description: The create case API request body varies depending on the type of connector. @@ -2938,6 +2970,8 @@ components: description: A title for the case. type: string maxLength: 160 + customFields: + $ref: '#/components/schemas/custom_fields_property' case_response_closed_by_properties: title: Case response properties for closed_by type: object @@ -3403,6 +3437,8 @@ components: version: description: The current version of the case. To determine this value, use the get case or find cases APIs. type: string + customFields: + $ref: '#/components/schemas/custom_fields_property' searchFieldsType: type: string description: The fields to perform the `simple_query_string` parsed query against. diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/create_case_request.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/create_case_request.yaml index 1092975985dd0..7b3d8d8219ff7 100644 --- a/x-pack/plugins/cases/docs/openapi/components/schemas/create_case_request.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/create_case_request.yaml @@ -3,12 +3,12 @@ description: >- The create case API request body varies depending on the type of connector. type: object required: - - connector - - description - - owner - - settings - - tags - - title + - connector + - description + - owner + - settings + - tags + - title properties: assignees: $ref: 'assignees.yaml' @@ -45,4 +45,6 @@ properties: title: description: A title for the case. type: string - maxLength: 160 \ No newline at end of file + maxLength: 160 + customFields: + $ref: 'custom_fields_property.yaml' diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/custom_fields_property.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/custom_fields_property.yaml new file mode 100644 index 0000000000000..2b682960bd1f0 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/custom_fields_property.yaml @@ -0,0 +1,31 @@ +type: array +description: Values for custom fields of a case +minItems: 0 +maxItems: 5 +items: + type: object + required: + - key + - type + - field + properties: + key: + description: The key identifying the custom field. + type: string + type: + description: The type of the custom field. Should match the key and how the custom field was configured + type: string + field: + description: An object containing the value of the field. + type: object + required: + - value + properties: + value: + description: The value of the field. + nullable: true + type: array + items: + anyOf: + - type: string + - type: boolean diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/update_case_request.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/update_case_request.yaml index df3e7ab17d674..4bbac95630e47 100644 --- a/x-pack/plugins/cases/docs/openapi/components/schemas/update_case_request.yaml +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/update_case_request.yaml @@ -58,3 +58,5 @@ properties: version: description: The current version of the case. To determine this value, use the get case or find cases APIs. type: string + customFields: + $ref: 'custom_fields_property.yaml' diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 9dea7a3413f95..7295a08cfd51a 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -192,12 +192,8 @@ export const TITLE_REQUIRED = i18n.translate('xpack.cases.createCase.titleFieldR defaultMessage: 'A name is required.', }); -export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', { - defaultMessage: 'Configure cases', -}); - export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.cases.configureCasesButton', { - defaultMessage: 'Edit external connection', + defaultMessage: 'Settings', }); export const ADD_COMMENT = i18n.translate('xpack.cases.caseView.comment.addComment', { @@ -380,3 +376,12 @@ export const ADD_TAG_CUSTOM_OPTION_LABEL_COMBO_BOX = ADD_TAG_CUSTOM_OPTION_LABEL export const ADD_CATEGORY_CUSTOM_OPTION_LABEL_COMBO_BOX = ADD_CATEGORY_CUSTOM_OPTION_LABEL('{searchValue}'); + +export const EXPERIMENTAL_LABEL = i18n.translate('xpack.cases.badge.experimentalLabel', { + defaultMessage: 'Technical preview', +}); + +export const EXPERIMENTAL_DESC = i18n.translate('xpack.cases.badge.experimentalDesc', { + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index 7661242fe9153..d34be81d499ff 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -138,46 +138,6 @@ describe('AllCases', () => { }); }); - it('should not allow the user to enter configuration page with basic license', async () => { - useGetActionLicenseMock.mockReturnValue({ - ...defaultActionLicense, - data: { - id: '.jira', - name: 'Jira', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: false, - }, - }); - - const result = appMockRender.render(); - - await waitFor(() => { - expect(result.getByTestId('configure-case-button')).toBeDisabled(); - }); - }); - - it('should allow the user to enter configuration page with gold license and above', async () => { - useGetActionLicenseMock.mockReturnValue({ - ...defaultActionLicense, - data: { - id: '.jira', - name: 'Jira', - minimumLicenseRequired: 'gold', - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - }, - }); - - const result = appMockRender.render(); - - await waitFor(() => { - expect(result.getByTestId('configure-case-button')).not.toBeDisabled(); - }); - }); - it('should render the case callouts', async () => { const result = appMockRender.render(); await waitFor(() => { diff --git a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx index 59b26b921eae0..aafb287eed869 100644 --- a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx @@ -54,7 +54,6 @@ export const NavButtons: FunctionComponent = ({ actionsErrors }) => { {actionsErrors[0].description} : <>} titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index 68c51f78c868a..0ba1f2214bfc0 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -10,13 +10,14 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; import { isEqual } from 'lodash'; +import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration'; import { useGetCaseUsers } from '../../../containers/use_get_case_users'; import { useGetCaseConnectors } from '../../../containers/use_get_case_connectors'; import { useCasesFeatures } from '../../../common/use_cases_features'; import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile'; import { useGetSupportedActionConnectors } from '../../../containers/configure/use_get_supported_action_connectors'; import type { CaseSeverity, CaseStatuses } from '../../../../common/types/domain'; -import type { UseFetchAlertData } from '../../../../common/ui/types'; +import type { CaseUICustomField, UseFetchAlertData } from '../../../../common/ui/types'; import type { CaseUI } from '../../../../common'; import { EditConnector } from '../../edit_connector'; import type { CasesNavigation } from '../../links'; @@ -38,6 +39,7 @@ import { CaseViewTabs } from '../case_view_tabs'; import { Description } from '../../description'; import { EditCategory } from './edit_category'; import { parseCaseUsers } from '../../utils'; +import { CustomFields } from './custom_fields'; export const CaseViewActivity = ({ ruleDetailsNavigation, @@ -72,6 +74,8 @@ export const CaseViewActivity = ({ const { data: caseUsers, isLoading: isLoadingCaseUsers } = useGetCaseUsers(caseData.id); + const { data: casesConfiguration } = useGetCaseConfiguration(); + const { userProfiles, reporterAsArray } = parseCaseUsers({ caseUsers, createdBy: caseData.createdBy, @@ -148,6 +152,16 @@ export const CaseViewActivity = ({ [onUpdateField] ); + const onSubmitCustomFields = useCallback( + (customFields: CaseUICustomField[]) => { + onUpdateField({ + key: 'customFields', + value: customFields, + }); + }, + [onUpdateField] + ); + const handleUserActionsActivityChanged = useCallback( (params: UserActivityParams) => { setUserActivityQueryParams((oldParams) => ({ @@ -205,6 +219,7 @@ export const CaseViewActivity = ({ onRuleDetailsClick={ruleDetailsNavigation?.onClick} caseConnectors={caseConnectors} data={caseData} + casesConfiguration={casesConfiguration} actionsNavigation={actionsNavigation} onShowAlertDetails={onShowAlertDetails} onUpdateField={onUpdateField} @@ -283,6 +298,12 @@ export const CaseViewActivity = ({ key={caseData.connector.id} /> ) : null} + diff --git a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx new file mode 100644 index 0000000000000..ce532a41d64e9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.test.tsx @@ -0,0 +1,203 @@ +/* + * 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 { screen, waitFor, within } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../../common/mock'; +import { readCasesPermissions, createAppMockRenderer } from '../../../common/mock'; + +import { CustomFields } from './custom_fields'; +import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock'; +import userEvent from '@testing-library/user-event'; +import { CustomFieldTypes } from '../../../../common/types/domain'; + +describe('Case View Page files tab', () => { + const onSubmit = jest.fn(); + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the custom fields correctly', async () => { + appMockRender.render( + + ); + + expect(screen.getByTestId('case-custom-field-wrapper-test_key_1')).toBeInTheDocument(); + expect(screen.getByTestId('case-custom-field-wrapper-test_key_2')).toBeInTheDocument(); + }); + + it('should render the custom fields types when the custom fields are empty', async () => { + appMockRender.render( + + ); + + expect(screen.getByTestId('case-custom-field-wrapper-test_key_1')).toBeInTheDocument(); + expect(screen.getByTestId('case-custom-field-wrapper-test_key_2')).toBeInTheDocument(); + }); + + it('should not show the custom fields if the configuration is empty', async () => { + appMockRender.render( + + ); + + expect(screen.queryByTestId('case-custom-field-wrapper-test_key_1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('case-custom-field-wrapper-test_key_2')).not.toBeInTheDocument(); + }); + + it('should sort the custom fields correctly', async () => { + const reversedConfiguration = [...customFieldsConfigurationMock].reverse(); + + appMockRender.render( + + ); + + const customFields = screen.getAllByTestId('case-custom-field-wrapper', { exact: false }); + + expect(customFields.length).toBe(2); + + expect(within(customFields[0]).getByRole('heading')).toHaveTextContent('My test label 1'); + expect(within(customFields[1]).getByRole('heading')).toHaveTextContent('My test label 2'); + }); + + it('pass the permissions to custom fields correctly', async () => { + appMockRender = createAppMockRenderer({ permissions: readCasesPermissions() }); + + appMockRender.render( + + ); + + expect( + screen.queryByTestId('case-text-custom-field-edit-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('adds missing custom fields with no custom fields in the case', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith([ + { + type: CustomFieldTypes.TEXT, + key: 'test_key_1', + value: null, + }, + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: true }, + ]); + }); + }); + + it('adds missing custom fields with some custom fields in the case', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith([ + { + type: CustomFieldTypes.TEXT, + key: 'test_key_1', + value: null, + }, + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false }, + ]); + }); + }); + + it('removes extra custom fields', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith([ + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false }, + ]); + }); + }); + + it('updates an existing field correctly', async () => { + appMockRender.render( + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith([ + customFieldsMock[0], + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: false }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx new file mode 100644 index 0000000000000..5d178c2709f62 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/custom_fields.tsx @@ -0,0 +1,108 @@ +/* + * 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 } from 'react'; +import { sortBy } from 'lodash'; +import { EuiFlexItem } from '@elastic/eui'; +import type { + CasesConfigurationUI, + CasesConfigurationUICustomField, + CaseUICustomField, +} from '../../../../common/ui'; +import type { CaseUI } from '../../../../common'; +import { useCasesContext } from '../../cases_context/use_cases_context'; +import { builderMap as customFieldsBuilderMap } from '../../custom_fields/builder'; +import { addOrReplaceCustomField } from '../../custom_fields/utils'; +interface Props { + isLoading: boolean; + customFields: CaseUI['customFields']; + customFieldsConfiguration: CasesConfigurationUI['customFields']; + onSubmit: (customFields: CaseUICustomField[]) => void; +} + +const CustomFieldsComponent: React.FC = ({ + isLoading, + customFields, + customFieldsConfiguration, + onSubmit, +}) => { + const { permissions } = useCasesContext(); + const sortedCustomFieldsConfiguration = useMemo( + () => sortCustomFieldsByLabel(customFieldsConfiguration), + [customFieldsConfiguration] + ); + + const onSubmitCustomField = useCallback( + (customFieldToAdd) => { + const allCustomFields = createMissingAndRemoveExtraCustomFields( + customFields, + customFieldsConfiguration + ); + + const updatedCustomFields = addOrReplaceCustomField(allCustomFields, customFieldToAdd); + + onSubmit(updatedCustomFields); + }, + [customFields, customFieldsConfiguration, onSubmit] + ); + + const customFieldsComponents = sortedCustomFieldsConfiguration.map((customFieldConf) => { + const customFieldFactory = customFieldsBuilderMap[customFieldConf.type]; + const customFieldType = customFieldFactory().build(); + + const customField = customFields.find((field) => field.key === customFieldConf.key); + + const EditComponent = customFieldType.Edit; + + return ( + + + + ); + }); + + return <>{customFieldsComponents}; +}; + +CustomFieldsComponent.displayName = 'CustomFields'; + +export const CustomFields = React.memo(CustomFieldsComponent); + +const sortCustomFieldsByLabel = (customFieldsConfiguration: Props['customFieldsConfiguration']) => { + return sortBy(customFieldsConfiguration, (customFieldConf) => { + return customFieldConf.label; + }); +}; + +const createMissingAndRemoveExtraCustomFields = ( + customFields: CaseUICustomField[], + confCustomFields: CasesConfigurationUICustomField[] +): CaseUICustomField[] => { + const createdCustomFields: CaseUICustomField[] = confCustomFields.map((confCustomField) => { + const foundCustomField = customFields.find( + (customField) => customField.key === confCustomField.key + ); + + if (foundCustomField) { + return foundCustomField; + } + + return { key: confCustomField.key, type: confCustomField.type, value: null }; + }); + + return createdCustomFields; +}; diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index d0be88f729a69..d03b8426dac4a 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -49,6 +49,12 @@ export const REMOVED_FIELD = i18n.translate('xpack.cases.caseView.actionLabel.re defaultMessage: 'removed', }); +export const CHANGED_FIELD_TO_EMPTY = (field: string) => + i18n.translate('xpack.cases.caseView.actionLabel.changeFieldToEmpty', { + values: { field }, + defaultMessage: 'changed {field} to "None"', + }); + export const VIEW_INCIDENT = (incidentNumber: string) => i18n.translate('xpack.cases.caseView.actionLabel.viewIncident', { defaultMessage: 'View {incidentNumber}', diff --git a/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts b/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts index 03747ffd4c5fe..b2258549b242c 100644 --- a/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts +++ b/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts @@ -93,6 +93,12 @@ export const useOnUpdateField = ({ caseData }: { caseData: CaseUI }) => { callUpdate('assignees', assigneesUpdate); } break; + case 'customFields': + const customFields = getTypedPayload(value); + if (!deepEqual(caseData.customFields, value)) { + callUpdate('customFields', customFields); + } + break; default: return null; } diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index 28267d407cb3f..908d270d9cc1e 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -8,7 +8,6 @@ import type { ActionTypeConnector } from '../../../../common/types/domain'; import { ConnectorTypes } from '../../../../common/types/domain'; import type { ActionConnector } from '../../../containers/configure/types'; -import type { ReturnUseCaseConfigure } from '../../../containers/configure/use_configure'; import { connectorsMock, actionTypesMock } from '../../../common/mock/connectors'; export { mappings } from '../../../containers/configure/mock'; @@ -18,35 +17,28 @@ export const actionTypes: ActionTypeConnector[] = actionTypesMock; export const searchURL = '?timerange=(global:(linkTo:!(),timerange:(from:1585487656371,fromStr:now-24h,kind:relative,to:1585574056371,toStr:now)),timeline:(linkTo:!(),timerange:(from:1585227005527,kind:absolute,to:1585313405527)))'; -export const useCaseConfigureResponse: ReturnUseCaseConfigure = { - closureType: 'close-by-user', - connector: { - id: 'none', - name: 'none', - type: ConnectorTypes.none, - fields: null, - }, - currentConfiguration: { +export const useCaseConfigureResponse = { + data: { + closureType: 'close-by-user', connector: { + fields: null, id: 'none', name: 'none', type: ConnectorTypes.none, - fields: null, }, - closureType: 'close-by-user', + customFields: [], + mappings: [], + version: '', + id: '', }, - firstLoad: false, - loading: false, - mappings: [], - persistCaseConfigure: jest.fn(), - persistLoading: false, - refetchCaseConfigure: jest.fn(), - setClosureType: jest.fn(), - setConnector: jest.fn(), - setCurrentConfiguration: jest.fn(), - setMappings: jest.fn(), - version: '', - id: '', + isLoading: false, + refetch: jest.fn(), +}; + +export const usePersistConfigurationMockResponse = { + isLoading: false, + mutate: jest.fn(), + mutateAsync: jest.fn(), }; export const useConnectorsResponse = { diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx index 5272eaebad512..8b54575292d84 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -8,15 +8,19 @@ import React from 'react'; import type { ReactWrapper } from 'enzyme'; import { mount } from 'enzyme'; -import { waitFor } from '@testing-library/react'; +import { waitFor, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ConfigureCases } from '.'; -import { noUpdateCasesPermissions, TestProviders } from '../../common/mock'; +import { noUpdateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import type { AppMockRenderer } from '../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; import { useKibana } from '../../common/lib/kibana'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration'; import { connectors, @@ -24,24 +28,31 @@ import { useCaseConfigureResponse, useConnectorsResponse, useActionTypesResponse, + usePersistConfigurationMockResponse, } from './__mock__'; -import { ConnectorTypes } from '../../../common/types/domain'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { ConnectorTypes, CustomFieldTypes } from '../../../common/types/domain'; import { actionTypeRegistryMock } from '@kbn/triggers-actions-ui-plugin/public/application/action_type_registry.mock'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; +import { useLicense } from '../../common/use_license'; jest.mock('../../common/lib/kibana'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); -jest.mock('../../containers/configure/use_configure'); +jest.mock('../../containers/configure/use_get_case_configuration'); +jest.mock('../../containers/configure/use_persist_configuration'); jest.mock('../../containers/configure/use_action_types'); +jest.mock('../../common/use_license'); const useKibanaMock = useKibana as jest.Mocked; const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; -const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; +const usePersistConfigurationMock = usePersistConfiguration as jest.Mock; const useGetUrlSearchMock = jest.fn(); const useGetActionTypesMock = useGetActionTypes as jest.Mock; const getAddConnectorFlyoutMock = jest.fn(); const getEditConnectorFlyoutMock = jest.fn(); +const useLicenseMock = useLicense as jest.Mock; describe('ConfigureCases', () => { beforeAll(() => { @@ -59,12 +70,14 @@ describe('ConfigureCases', () => { beforeEach(() => { useGetActionTypesMock.mockImplementation(() => useActionTypesResponse); + useLicenseMock.mockReturnValue({ isAtLeastGold: () => true }); }); describe('rendering', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); + usePersistConfigurationMock.mockImplementation(() => usePersistConfigurationMockResponse); useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); useGetUrlSearchMock.mockImplementation(() => searchURL); @@ -100,25 +113,19 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - closureType: 'close-by-user', - connector: { - id: 'not-id', - name: 'unchanged', - type: ConnectorTypes.none, - fields: null, - }, - currentConfiguration: { + useGetCaseConfigurationMock.mockImplementation(() => ({ + data: { + ...useCaseConfigureResponse.data, + closureType: 'close-by-user', connector: { id: 'not-id', name: 'unchanged', type: ConnectorTypes.none, fields: null, }, - closureType: 'close-by-user', }, })); + useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, data: [] })); useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { @@ -145,26 +152,21 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mappings: [], - closureType: 'close-by-user', - connector: { - id: 'servicenow-1', - name: 'unchanged', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - currentConfiguration: { + data: { + ...useCaseConfigureResponse.data, + mappings: [], + closureType: 'close-by-user', connector: { id: 'servicenow-1', name: 'unchanged', type: ConnectorTypes.serviceNowITSM, fields: null, }, - closureType: 'close-by-user', }, })); + useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); @@ -226,24 +228,18 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: null, - closureType: 'close-by-user', - connector: { - id: 'resilient-2', - name: 'unchanged', - type: ConnectorTypes.resilient, - fields: null, - }, - currentConfiguration: { + data: { + ...useCaseConfigureResponse.data, + mapping: null, + closureType: 'close-by-user', connector: { - id: 'servicenow-1', + id: 'resilient-2', name: 'unchanged', - type: ConnectorTypes.serviceNowITSM, + type: ConnectorTypes.resilient, fields: null, }, - closureType: 'close-by-user', }, })); @@ -302,15 +298,22 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, }, - persistLoading: true, + })); + + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + isLoading: true, })); useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); @@ -350,13 +353,15 @@ describe('ConfigureCases', () => { let wrapper: ReactWrapper; beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - loading: true, + isLoading: true, })); + useGetConnectorsMock.mockImplementation(() => ({ ...useConnectorsResponse, })); + useGetUrlSearchMock.mockImplementation(() => searchURL); wrapper = mount(, { wrappingComponent: TestProviders, @@ -373,32 +378,29 @@ describe('ConfigureCases', () => { }); describe('connectors', () => { + const persistCaseConfigure = jest.fn(); let wrapper: ReactWrapper; - let persistCaseConfigure: jest.Mock; beforeEach(() => { - persistCaseConfigure = jest.fn(); - useCaseConfigureMock.mockImplementation(() => ({ - ...useCaseConfigureResponse, - mapping: null, - closureType: 'close-by-user', - connector: { - id: 'resilient-2', - name: 'My connector', - type: ConnectorTypes.resilient, - fields: null, - }, - currentConfiguration: { + useGetCaseConfigurationMock.mockImplementation(() => ({ + data: { + ...useCaseConfigureResponse.data, + mapping: null, + closureType: 'close-by-user', connector: { - id: 'My connector', + id: 'resilient-2', name: 'My connector', - type: ConnectorTypes.jira, + type: ConnectorTypes.resilient, fields: null, }, - closureType: 'close-by-user', }, - persistCaseConfigure, })); + + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); @@ -422,27 +424,36 @@ describe('ConfigureCases', () => { fields: null, }, closureType: 'close-by-user', + customFields: [], + id: '', + version: '', }); }); test('the text of the update button is changed successfully', () => { - useCaseConfigureMock + useGetCaseConfigurationMock .mockImplementationOnce(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'servicenow-1', - name: 'My connector', - type: ConnectorTypes.serviceNowITSM, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'servicenow-1', + name: 'My connector', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, }, })) .mockImplementation(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'resilient-2', - name: 'My Resilient connector', - type: ConnectorTypes.resilient, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'resilient-2', + name: 'My Resilient connector', + type: ConnectorTypes.resilient, + fields: null, + }, }, })); @@ -469,26 +480,24 @@ describe('ConfigureCases', () => { beforeEach(() => { persistCaseConfigure = jest.fn(); - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: null, - closureType: 'close-by-user', - connector: { - id: 'servicenow-1', - name: 'My connector', - type: ConnectorTypes.serviceNowITSM, - fields: null, - }, - currentConfiguration: { + data: { + ...useCaseConfigureResponse.data, + mapping: null, + closureType: 'close-by-user', connector: { - id: 'My connector', + id: 'servicenow-1', name: 'My connector', - type: ConnectorTypes.jira, + type: ConnectorTypes.serviceNowITSM, fields: null, }, - closureType: 'close-by-user', }, - persistCaseConfigure, + })); + + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, })); useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); @@ -511,32 +520,31 @@ describe('ConfigureCases', () => { fields: null, }, closureType: 'close-by-pushing', + customFields: [], + id: '', + version: '', }); }); }); describe('user interactions', () => { beforeEach(() => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - mapping: null, - closureType: 'close-by-user', - connector: { - id: 'resilient-2', - name: 'unchanged', - type: ConnectorTypes.resilient, - fields: null, - }, - currentConfiguration: { + data: { + ...useCaseConfigureResponse.data, + + mapping: null, + closureType: 'close-by-user', connector: { id: 'resilient-2', name: 'unchanged', - type: ConnectorTypes.serviceNowITSM, + type: ConnectorTypes.resilient, fields: null, }, - closureType: 'close-by-user', }, })); + useGetConnectorsMock.mockImplementation(() => useConnectorsResponse); useGetUrlSearchMock.mockImplementation(() => searchURL); }); @@ -597,4 +605,267 @@ describe('ConfigureCases', () => { ).toBeFalsy(); }); }); + + describe('custom fields', () => { + let appMockRender: AppMockRenderer; + let persistCaseConfigure: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + persistCaseConfigure = jest.fn(); + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + }); + + it('renders custom field group when no custom fields available', () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + }); + + it('renders custom field when available', () => { + const customFieldsMock: CustomFieldsConfiguration = [ + { + key: 'random_custom_key', + label: 'summary', + type: CustomFieldTypes.TEXT, + required: true, + }, + ]; + + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsMock, + }, + })); + + appMockRender.render(); + + expect( + screen.getByTestId(`custom-field-${customFieldsMock[0].label}-${customFieldsMock[0].type}`) + ).toBeInTheDocument(); + }); + + it('renders multiple custom field when available', () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsConfigurationMock, + }, + })); + + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + for (const field of customFieldsConfigurationMock) { + expect( + within(list).getByTestId(`custom-field-${field.label}-${field.type}`) + ).toBeInTheDocument(); + } + }); + + it('deletes a custom field correctly', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsConfigurationMock, + }, + })); + + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [{ ...customFieldsConfigurationMock[1] }], + id: '', + version: '', + }); + }); + }); + + it('updates a custom field correctly', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsConfigurationMock, + }, + })); + + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) + ); + + expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), '!!'); + + userEvent.click(screen.getByTestId('text-custom-field-options')); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [ + { + ...customFieldsConfigurationMock[0], + label: `${customFieldsConfigurationMock[0].label}!!`, + required: !customFieldsConfigurationMock[0].required, + }, + { ...customFieldsConfigurationMock[1] }, + ], + id: '', + version: '', + }); + }); + }); + + it('opens fly out for when click on add field', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + }); + + it('closes fly out for when click on cancel', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('custom-field-flyout-cancel')); + + expect(await screen.findByTestId('custom-fields-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + }); + + it('closes fly out for when click on save field', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(await screen.findByTestId('custom-field-flyout')).toBeInTheDocument(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [ + ...customFieldsConfigurationMock, + { + key: expect.anything(), + label: 'Summary', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + id: '', + version: '', + }); + }); + + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('custom-field-flyout')).not.toBeInTheDocument(); + }); + }); + + describe('rendering with license limitations', () => { + let appMockRender: AppMockRenderer; + let persistCaseConfigure: jest.Mock; + + beforeEach(() => { + // Default setup + jest.clearAllMocks(); + useGetConnectorsMock.mockImplementation(() => ({ useConnectorsResponse })); + appMockRender = createAppMockRenderer(); + persistCaseConfigure = jest.fn(); + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); + + // Updated + useLicenseMock.mockReturnValue({ isAtLeastGold: () => false }); + }); + + it('should not render connectors and closure options', () => { + appMockRender.render(); + expect(screen.queryByTestId('dropdown-connectors')).not.toBeInTheDocument(); + expect(screen.queryByTestId('closure-options-radio-group')).not.toBeInTheDocument(); + }); + + it('should render custom field section', () => { + appMockRender.render(); + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + }); + + describe('when the previously selected connector doesnt appear due to license downgrade or because it was deleted', () => { + beforeEach(() => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + data: { + ...useCaseConfigureResponse.data, + closureType: 'close-by-user', + connector: { + id: 'not-id', + name: 'unchanged', + type: ConnectorTypes.none, + fields: null, + }, + }, + })); + }); + + it('should not render the warning callout', () => { + expect(screen.queryByTestId('configure-cases-warning-callout')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index fcbc455345b21..42a93622f1a52 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -9,13 +9,14 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled, { css } from 'styled-components'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiCallOut, EuiLink, EuiPageBody } from '@elastic/eui'; +import { EuiCallOut, EuiFlexItem, EuiLink, EuiPageBody } from '@elastic/eui'; import type { ActionConnectorTableItem } from '@kbn/triggers-actions-ui-plugin/public/types'; import { CasesConnectorFeatureId } from '@kbn/actions-plugin/common'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; import { useKibana } from '../../common/lib/kibana'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import type { ClosureType } from '../../containers/configure/types'; @@ -29,7 +30,12 @@ import { HeaderPage } from '../header_page'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useCasesBreadcrumbs } from '../use_breadcrumbs'; import { CasesDeepLinkId } from '../../common/navigation'; +import { CustomFields } from '../custom_fields'; +import { CustomFieldFlyout } from '../custom_fields/flyout'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; +import { usePersistConfiguration } from '../../containers/configure/use_persist_configuration'; +import { addOrReplaceCustomField } from '../custom_fields/utils'; +import { useLicense } from '../../common/use_license'; const FormWrapper = styled.div` ${({ theme }) => css` @@ -53,6 +59,8 @@ export const ConfigureCases: React.FC = React.memo(() => { const { permissions } = useCasesContext(); const { triggersActionsUi } = useKibana().services; useCasesBreadcrumbs(CasesDeepLinkId.casesConfigure); + const license = useLicense(); + const hasMinimumLicensePermissions = license.isAtLeastGold(); const [connectorIsValid, setConnectorIsValid] = useState(true); const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); @@ -60,18 +68,29 @@ export const ConfigureCases: React.FC = React.memo(() => { const [editedConnectorItem, setEditedConnectorItem] = useState( null ); + const [customFieldFlyoutVisible, setCustomFieldFlyoutVisibility] = useState(false); + const [customFieldToEdit, setCustomFieldToEdit] = useState(null); const { - connector, - closureType, - loading: loadingCaseConfigure, - mappings, - persistLoading, - persistCaseConfigure, - refetchCaseConfigure, - setConnector, - setClosureType, - } = useCaseConfigure(); + data: { + id: configurationId, + version: configurationVersion, + closureType, + connector, + mappings, + customFields, + }, + isLoading: loadingCaseConfigure, + refetch: refetchCaseConfigure, + } = useGetCaseConfiguration(); + + const { + mutate: persistCaseConfigure, + mutateAsync: persistCaseConfigureAsync, + isLoading: isPersistingConfiguration, + } = usePersistConfiguration(); + + const isLoadingCaseConfiguration = loadingCaseConfigure || isPersistingConfiguration; const { isLoading: isLoadingConnectors, @@ -98,18 +117,31 @@ export const ConfigureCases: React.FC = React.memo(() => { async (createdConnector) => { const caseConnector = normalizeActionConnector(createdConnector); - await persistCaseConfigure({ + await persistCaseConfigureAsync({ connector: caseConnector, closureType, + customFields, + id: configurationId, + version: configurationVersion, }); + onConnectorUpdated(createdConnector); - setConnector(caseConnector); }, - [onConnectorUpdated, closureType, setConnector, persistCaseConfigure] + [ + persistCaseConfigureAsync, + closureType, + customFields, + configurationId, + configurationVersion, + onConnectorUpdated, + ] ); const isLoadingAny = - isLoadingConnectors || persistLoading || loadingCaseConfigure || isLoadingActionTypes; + isLoadingConnectors || + isPersistingConfiguration || + loadingCaseConfigure || + isLoadingActionTypes; const updateConnectorDisabled = isLoadingAny || !connectorIsValid || connector.id === 'none'; const onClickUpdateConnector = useCallback(() => { setEditFlyoutVisibility(true); @@ -133,24 +165,35 @@ export const ConfigureCases: React.FC = React.memo(() => { const caseConnector = actionConnector != null ? normalizeActionConnector(actionConnector) : getNoneConnector(); - setConnector(caseConnector); persistCaseConfigure({ connector: caseConnector, closureType, + customFields, + id: configurationId, + version: configurationVersion, }); }, - [connectors, closureType, persistCaseConfigure, setConnector] + [ + connectors, + persistCaseConfigure, + closureType, + customFields, + configurationId, + configurationVersion, + ] ); const onChangeClosureType = useCallback( (type: ClosureType) => { - setClosureType(type); persistCaseConfigure({ connector, + customFields, + id: configurationId, + version: configurationVersion, closureType: type, }); }, - [connector, persistCaseConfigure, setClosureType] + [configurationId, configurationVersion, connector, customFields, persistCaseConfigure] ); useEffect(() => { @@ -202,6 +245,88 @@ export const ConfigureCases: React.FC = React.memo(() => { [connector.id, editedConnectorItem, editFlyoutVisible] ); + const onAddCustomFields = useCallback(() => { + setCustomFieldFlyoutVisibility(true); + }, [setCustomFieldFlyoutVisibility]); + + const onDeleteCustomField = useCallback( + (key: string) => { + const remainingCustomFields = customFields.filter((field) => field.key !== key); + + persistCaseConfigure({ + connector, + customFields: [...remainingCustomFields], + id: configurationId, + version: configurationVersion, + closureType, + }); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + persistCaseConfigure, + ] + ); + + const onEditCustomField = useCallback( + (key: string) => { + const selectedCustomField = customFields.find((item) => item.key === key); + + if (selectedCustomField) { + setCustomFieldToEdit(selectedCustomField); + } + setCustomFieldFlyoutVisibility(true); + }, + [setCustomFieldFlyoutVisibility, setCustomFieldToEdit, customFields] + ); + + const onCloseAddFieldFlyout = useCallback(() => { + setCustomFieldFlyoutVisibility(false); + setCustomFieldToEdit(null); + }, [setCustomFieldFlyoutVisibility, setCustomFieldToEdit]); + + const onSaveCustomField = useCallback( + (customFieldData: CustomFieldConfiguration) => { + const updatedFields = addOrReplaceCustomField(customFields, customFieldData); + persistCaseConfigure({ + connector, + customFields: updatedFields, + id: configurationId, + version: configurationVersion, + closureType, + }); + + setCustomFieldFlyoutVisibility(false); + setCustomFieldToEdit(null); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + customFields, + persistCaseConfigure, + ] + ); + + const CustomFieldAddFlyout = customFieldFlyoutVisible ? ( + + ) : null; + return ( <> { /> - {!connectorIsValid && ( - - - - {i18n.LINK_APPROPRIATE_LICENSE} - - ), - }} + {hasMinimumLicensePermissions && ( + <> + {!connectorIsValid && ( + + + + {i18n.LINK_APPROPRIATE_LICENSE} + + ), + }} + /> + + + )} + + + + + - - + + )} - - - - + + + {ConnectorAddFlyout} {ConnectorEditFlyout} + {CustomFieldAddFlyout} diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index ada2690ab7b03..e10f6fcad2fb9 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -150,7 +150,7 @@ export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate( ); export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate('xpack.cases.configureCases.headerTitle', { - defaultMessage: 'Configure cases', + defaultMessage: 'Settings', }); export const CASES_WEBHOOK_MAPPINGS = i18n.translate( diff --git a/x-pack/plugins/cases/public/components/create/connector.test.tsx b/x-pack/plugins/cases/public/components/create/connector.test.tsx index b9e40df65c4a7..7bc4bfbde6162 100644 --- a/x-pack/plugins/cases/public/components/create/connector.test.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.test.tsx @@ -27,18 +27,18 @@ import { createAppMockRenderer, TestProviders, } from '../../common/mock'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; jest.mock('../connectors/resilient/use_get_incident_types'); jest.mock('../connectors/resilient/use_get_severity'); jest.mock('../connectors/servicenow/use_get_choices'); -jest.mock('../../containers/configure/use_configure'); +jest.mock('../../containers/configure/use_get_case_configuration'); const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; const useGetChoicesMock = useGetChoices as jest.Mock; -const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; const useGetIncidentTypesResponse = { isLoading: false, @@ -85,7 +85,7 @@ describe('Connector', () => { useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetChoicesMock.mockReturnValue(useGetChoicesResponse); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); }); it('it renders', async () => { diff --git a/x-pack/plugins/cases/public/components/create/connector.tsx b/x-pack/plugins/cases/public/components/create/connector.tsx index 7422e671fa4bb..39e04f7bc0be3 100644 --- a/x-pack/plugins/cases/public/components/create/connector.tsx +++ b/x-pack/plugins/cases/public/components/create/connector.tsx @@ -14,7 +14,7 @@ import type { ActionConnector } from '../../../common/types/domain'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; import { schema } from './schema'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { getConnectorById, getConnectorsFormValidators } from '../utils'; import { useApplicationCapabilities } from '../../common/lib/kibana'; import * as i18n from '../../common/translations'; @@ -29,7 +29,11 @@ interface Props { const ConnectorComponent: React.FC = ({ connectors, isLoading, isLoadingConnectors }) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const connector = getConnectorById(connectorId, connectors) ?? null; - const { connector: configurationConnector } = useCaseConfigure(); + + const { + data: { connector: configurationConnector }, + } = useGetCaseConfiguration(); + const { actions } = useApplicationCapabilities(); const { permissions } = useCasesContext(); const hasReadPermissions = permissions.connectors && actions.read; diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx new file mode 100644 index 0000000000000..06f6c17922221 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/custom_fields.test.tsx @@ -0,0 +1,110 @@ +/* + * 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 { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { CustomFields } from './custom_fields'; +import * as i18n from './translations'; + +describe('CustomFields', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render( + + + + ); + + expect(screen.getByText(i18n.ADDITIONAL_FIELDS)).toBeInTheDocument(); + expect(screen.getByTestId('create-case-custom-fields')).toBeInTheDocument(); + + for (const item of customFieldsConfigurationMock) { + expect( + screen.getByTestId(`${item.key}-${item.type}-create-custom-field`) + ).toBeInTheDocument(); + } + }); + + it('should not show the custom fields if the configuration is empty', async () => { + appMockRender.render( + + + + ); + + expect(screen.queryByText(i18n.ADDITIONAL_FIELDS)).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('create-custom-field', { exact: false }).length).toEqual(0); + }); + + it('should sort the custom fields correctly', async () => { + const reversedConfiguration = [...customFieldsConfigurationMock].reverse(); + + appMockRender.render( + + + + ); + + const customFieldsWrapper = await screen.findByTestId('create-case-custom-fields'); + + const customFields = customFieldsWrapper.querySelectorAll('.euiFormRow'); + + expect(customFields).toHaveLength(2); + + expect(customFields[0]).toHaveTextContent('My test label 1'); + expect(customFields[1]).toHaveTextContent('My test label 2'); + }); + + it('should update the custom fields', async () => { + appMockRender = createAppMockRenderer(); + + appMockRender.render( + + + + ); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + userEvent.type( + screen.getByTestId(`${textField.key}-${textField.type}-create-custom-field`), + 'hello' + ); + userEvent.click( + screen.getByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [textField.key]: 'hello', + [toggleField.key]: true, + }, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/custom_fields.tsx b/x-pack/plugins/cases/public/components/create/custom_fields.tsx new file mode 100644 index 0000000000000..cfc80c125a7b9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/custom_fields.tsx @@ -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 React, { useMemo } from 'react'; +import { sortBy } from 'lodash'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; + +import type { CasesConfigurationUI } from '../../../common/ui'; +import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; +import * as i18n from './translations'; + +interface Props { + isLoading: boolean; + customFieldsConfiguration: CasesConfigurationUI['customFields']; +} + +const CustomFieldsComponent: React.FC = ({ isLoading, customFieldsConfiguration }) => { + const sortedCustomFields = useMemo( + () => sortCustomFieldsByLabel(customFieldsConfiguration), + [customFieldsConfiguration] + ); + + const customFieldsComponents = sortedCustomFields.map( + (customField: CasesConfigurationUI['customFields'][number]) => { + const customFieldFactory = customFieldsBuilderMap[customField.type]; + const customFieldType = customFieldFactory().build(); + + const CreateComponent = customFieldType.Create; + + return ( + + ); + } + ); + + if (!customFieldsConfiguration.length) { + return null; + } + + return ( + + +

{i18n.ADDITIONAL_FIELDS}

+
+ + {customFieldsComponents} +
+ ); +}; + +CustomFieldsComponent.displayName = 'CustomFields'; + +export const CustomFields = React.memo(CustomFieldsComponent); + +const sortCustomFieldsByLabel = (configCustomFields: CasesConfigurationUI['customFields']) => { + return sortBy(configCustomFields, (configCustomField) => { + return configCustomField.label; + }); +}; diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index 20502756db502..e7bd2fc754f34 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -14,12 +14,12 @@ import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { NONE_CONNECTOR_ID } from '../../../common/constants'; import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import { connectorsMock } from '../../containers/mock'; +import { connectorsMock, customFieldsConfigurationMock } from '../../containers/mock'; import type { FormProps } from './schema'; import { schema } from './schema'; import type { CreateCaseFormProps } from './form'; import { CreateCaseForm } from './form'; -import { useCaseConfigure } from '../../containers/configure/use_configure'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { TestProviders } from '../../common/mock'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; @@ -28,13 +28,13 @@ import { useAvailableCasesOwners } from '../app/use_available_owners'; jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_get_supported_action_connectors'); -jest.mock('../../containers/configure/use_configure'); +jest.mock('../../containers/configure/use_get_case_configuration'); jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment'); jest.mock('../app/use_available_owners'); const useGetTagsMock = useGetTags as jest.Mock; const useGetConnectorsMock = useGetSupportedActionConnectors as jest.Mock; -const useCaseConfigureMock = useCaseConfigure as jest.Mock; +const useGetCaseConfigurationMock = useGetCaseConfiguration as jest.Mock; const useAvailableOwnersMock = useAvailableCasesOwners as jest.Mock; const initialCaseValue: FormProps = { @@ -45,6 +45,7 @@ const initialCaseValue: FormProps = { fields: null, syncAlerts: true, assignees: [], + customFields: {}, }; const casesFormProps: CreateCaseFormProps = { @@ -81,7 +82,7 @@ describe('CreateCaseForm', () => { useAvailableOwnersMock.mockReturnValue(['securitySolution', 'observability']); useGetTagsMock.mockReturnValue({ data: ['test'] }); useGetConnectorsMock.mockReturnValue({ isLoading: false, data: connectorsMock }); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); }); afterEach(() => { @@ -218,6 +219,30 @@ describe('CreateCaseForm', () => { expect(descriptionInput).toHaveValue(''); }); + it('should render custom fields when available', () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: customFieldsConfigurationMock, + }, + })); + + const result = render( + + + + ); + + expect(result.getByTestId('create-case-custom-fields')).toBeInTheDocument(); + + for (const item of customFieldsConfigurationMock) { + expect( + result.getByTestId(`${item.key}-${item.type}-create-custom-field`) + ).toBeInTheDocument(); + } + }); + it('should prefill the form when provided with initialValue', () => { const { getByTestId } = render( diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index 1c1668ac528fb..280adaa66aeec 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -19,6 +19,7 @@ import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_ import type { ActionConnector } from '../../../common/types/domain'; import type { CasePostRequest } from '../../../common/types/api'; +import type { CasesConfigurationUI } from '../../../common/ui'; import { Title } from './title'; import { Description, fieldName as descriptionFieldName } from './description'; import { Tags } from './tags'; @@ -44,6 +45,7 @@ import { Assignees } from './assignees'; import { useCancelCreationAction } from './use_cancel_creation_action'; import { CancelCreationConfirmationModal } from './cancel_creation_confirmation_modal'; import { Category } from './category'; +import { CustomFields } from './custom_fields'; interface ContainerProps { big?: boolean; @@ -64,6 +66,8 @@ const MySpinner = styled(EuiLoadingSpinner)` export interface CreateCaseFormFieldsProps { connectors: ActionConnector[]; + customFieldsConfiguration: CasesConfigurationUI['customFields']; + isLoadingCaseConfiguration: boolean; isLoadingConnectors: boolean; withSteps: boolean; owner: string[]; @@ -83,7 +87,15 @@ export interface CreateCaseFormProps extends Pick = React.memo( - ({ connectors, isLoadingConnectors, withSteps, owner, draftStorageKey }) => { + ({ + connectors, + isLoadingConnectors, + withSteps, + owner, + draftStorageKey, + customFieldsConfiguration, + isLoadingCaseConfiguration, + }) => { const { isSubmitting } = useFormContext(); const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); const availableOwners = useAvailableCasesOwners(); @@ -120,6 +132,12 @@ export const CreateCaseFormFields: React.FC = React.m + + + ), @@ -130,6 +148,8 @@ export const CreateCaseFormFields: React.FC = React.m canShowCaseSolutionSelection, availableOwners, draftStorageKey, + customFieldsConfiguration, + isLoadingCaseConfiguration, ] ); @@ -227,7 +247,9 @@ export const CreateCaseForm: React.FC = React.memo( > { useCreateAttachmentsMock.mockImplementation(() => ({ mutateAsync: createAttachments })); usePostPushToServiceMock.mockImplementation(() => defaultPostPushToService); useGetConnectorsMock.mockReturnValue(sampleConnectorData); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); @@ -429,16 +436,78 @@ describe.skip('Create case', () => { await waitForComponentToUpdate(); }); + it('should submit form with custom fields', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + customFields: [ + ...customFieldsConfigurationMock, + { + key: 'my_custom_field_key', + type: CustomFieldTypes.TEXT, + label: 'my custom field label', + required: false, + }, + ], + }, + })); + + appMockRender.render( + + + + + ); + + await waitForFormToRender(screen); + await fillFormReactTestingLib({ renderer: screen }); + + const textField = customFieldsConfigurationMock[0]; + const toggleField = customFieldsConfigurationMock[1]; + + expect(screen.getByTestId('create-case-custom-fields')).toBeInTheDocument(); + + userEvent.paste( + screen.getByTestId(`${textField.key}-${textField.type}-create-custom-field`), + 'My text test value 1' + ); + + userEvent.click( + screen.getByTestId(`${toggleField.key}-${toggleField.type}-create-custom-field`) + ); + + userEvent.click(screen.getByTestId('create-case-submit')); + + await waitFor(() => expect(postCase).toHaveBeenCalled()); + + expect(postCase).toBeCalledWith({ + request: { + ...sampleDataWithoutTags, + customFields: [ + ...customFieldsMock, + { + key: 'my_custom_field_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }, + }); + }); + it('should select the default connector set in the configuration', async () => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'servicenow-1', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'servicenow-1', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, }, - persistLoading: false, })); useGetConnectorsMock.mockReturnValue({ @@ -480,15 +549,17 @@ describe.skip('Create case', () => { }); it('should default to none if the default connector does not exist in connectors', async () => { - useCaseConfigureMock.mockImplementation(() => ({ + useGetCaseConfigurationMock.mockImplementation(() => ({ ...useCaseConfigureResponse, - connector: { - id: 'not-exist', - name: 'SN', - type: ConnectorTypes.serviceNowITSM, - fields: null, + data: { + ...useCaseConfigureResponse.data, + connector: { + id: 'not-exist', + name: 'SN', + type: ConnectorTypes.serviceNowITSM, + fields: null, + }, }, - persistLoading: false, })); useGetConnectorsMock.mockReturnValue({ diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 4df39cb2d52ac..2457b964ac3fc 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -15,7 +15,7 @@ import { getNoneConnector, normalizeActionConnector } from '../configure_cases/u import { usePostCase } from '../../containers/use_post_case'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import type { CaseUI } from '../../containers/types'; +import type { CaseUI, CaseUICustomField } from '../../containers/types'; import type { CasePostRequest } from '../../../common/types/api'; import type { UseCreateAttachments } from '../../containers/use_create_attachments'; import { useCreateAttachments } from '../../containers/use_create_attachments'; @@ -25,11 +25,13 @@ import { getConnectorById, getConnectorsFormDeserializer, getConnectorsFormSerializer, + convertCustomFieldValue, } from '../utils'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import type { CaseAttachmentsWithoutOwner } from '../../types'; import { useGetSupportedActionConnectors } from '../../containers/configure/use_get_supported_action_connectors'; import { useCreateCaseWithAttachmentsTransaction } from '../../common/apm/use_cases_transactions'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; const initialCaseValue: FormProps = { description: '', @@ -41,6 +43,7 @@ const initialCaseValue: FormProps = { syncAlerts: true, selectedOwner: null, assignees: [], + customFields: {}, }; interface Props { @@ -63,6 +66,10 @@ export const FormContext: React.FC = ({ }) => { const { data: connectors = [], isLoading: isLoadingConnectors } = useGetSupportedActionConnectors(); + const { + data: { customFields: customFieldsConfiguration }, + isLoading: isLoadingCaseConfiguration, + } = useGetCaseConfiguration(); const { owner, appId } = useCasesContext(); const { isSyncAlertsEnabled } = useCasesFeatures(); const { mutateAsync: postCase } = usePostCase(); @@ -89,6 +96,30 @@ export const FormContext: React.FC = ({ return formData; }; + const transformCustomFieldsData = useCallback( + (customFields: Record) => { + const transformedCustomFields: CaseUI['customFields'] = []; + + if (!customFields || !customFieldsConfiguration.length) { + return []; + } + + for (const [key, value] of Object.entries(customFields)) { + const configCustomField = customFieldsConfiguration.find((item) => item.key === key); + if (configCustomField) { + transformedCustomFields.push({ + key: configCustomField.key, + type: configCustomField.type, + value: convertCustomFieldValue(value), + } as CaseUICustomField); + } + } + + return transformedCustomFields; + }, + [customFieldsConfiguration] + ); + const submitCase = useCallback( async ( { @@ -100,7 +131,7 @@ export const FormContext: React.FC = ({ isValid ) => { if (isValid) { - const { selectedOwner, ...userFormData } = dataWithoutConnectorId; + const { selectedOwner, customFields, ...userFormData } = dataWithoutConnectorId; const caseConnector = getConnectorById(dataConnectorId, connectors); const defaultOwner = owner[0] ?? availableOwners[0]; @@ -110,6 +141,8 @@ export const FormContext: React.FC = ({ ? normalizeActionConnector(caseConnector, fields) : getNoneConnector(); + const transformedCustomFields = transformCustomFieldsData(customFields); + const trimmedData = trimUserFormData(userFormData); const theCase = await postCase({ @@ -118,6 +151,7 @@ export const FormContext: React.FC = ({ connector: connectorToUpdate, settings: { syncAlerts }, owner: selectedOwner ?? defaultOwner, + customFields: transformedCustomFields, }, }); @@ -159,6 +193,7 @@ export const FormContext: React.FC = ({ onSuccess, createAttachments, pushCaseToExternalService, + transformCustomFieldsData, ] ); @@ -175,10 +210,21 @@ export const FormContext: React.FC = ({ () => children != null ? React.Children.map(children, (child: React.ReactElement) => - React.cloneElement(child, { connectors, isLoadingConnectors }) + React.cloneElement(child, { + connectors, + isLoadingConnectors, + customFieldsConfiguration, + isLoadingCaseConfiguration, + }) ) : null, - [children, connectors, isLoadingConnectors] + [ + children, + connectors, + isLoadingConnectors, + customFieldsConfiguration, + isLoadingCaseConfiguration, + ] ); return (
{ beforeEach(() => { jest.clearAllMocks(); useGetConnectorsMock.mockReturnValue(sampleConnectorData); - useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse); + useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); useGetIssueTypesMock.mockReturnValue(useGetIssueTypesResponse); diff --git a/x-pack/plugins/cases/public/components/create/mock.ts b/x-pack/plugins/cases/public/components/create/mock.ts index ccb11aa0f93e7..0dc6168106786 100644 --- a/x-pack/plugins/cases/public/components/create/mock.ts +++ b/x-pack/plugins/cases/public/components/create/mock.ts @@ -28,6 +28,7 @@ export const sampleData: CasePostRequest = { }, owner: SECURITY_SOLUTION_OWNER, assignees: [], + customFields: [], category: null, }; diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index 778810b0db004..9d07efbf36111 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -72,11 +72,15 @@ export const schemaTags = { ], }; -export type FormProps = Omit & { +export type FormProps = Omit< + CasePostRequest, + 'connector' | 'settings' | 'owner' | 'customFields' +> & { connectorId: string; fields: ConnectorTypeFields['fields']; syncAlerts: boolean; selectedOwner?: string | null; + customFields: Record; }; export const schema: FormSchema = { diff --git a/x-pack/plugins/cases/public/components/create/translations.ts b/x-pack/plugins/cases/public/components/create/translations.ts index 4bb7471e1c648..473cc40a6a3f8 100644 --- a/x-pack/plugins/cases/public/components/create/translations.ts +++ b/x-pack/plugins/cases/public/components/create/translations.ts @@ -22,6 +22,10 @@ export const STEP_THREE_TITLE = i18n.translate('xpack.cases.create.stepThreeTitl defaultMessage: 'External Connector Fields', }); +export const ADDITIONAL_FIELDS = i18n.translate('xpack.cases.create.additionalFields', { + defaultMessage: 'Additional fields', +}); + export const SYNC_ALERTS_LABEL = i18n.translate('xpack.cases.create.syncAlertsLabel', { defaultMessage: 'Sync alert status with case status', }); diff --git a/x-pack/plugins/cases/public/components/custom_fields/builder.tsx b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx new file mode 100644 index 0000000000000..d2ee25d08bfa6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/builder.tsx @@ -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 type { CustomFieldBuilderMap } from './types'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { configureTextCustomFieldFactory } from './text/configure_text_field'; +import { configureToggleCustomFieldFactory } from './toggle/configure_toggle_field'; + +export const builderMap = Object.freeze({ + [CustomFieldTypes.TEXT]: configureTextCustomFieldFactory, + [CustomFieldTypes.TOGGLE]: configureToggleCustomFieldFactory, +} as const) as CustomFieldBuilderMap; diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx new file mode 100644 index 0000000000000..22672ab3bbfd5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx @@ -0,0 +1,148 @@ +/* + * 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 { screen, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; +import { customFieldsConfigurationMock } from '../../../containers/mock'; +import { CustomFieldsList } from '.'; + +describe('CustomFieldsList', () => { + let appMockRender: AppMockRenderer; + + const props = { + customFields: customFieldsConfigurationMock, + onDeleteCustomField: jest.fn(), + onEditCustomField: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument(); + }); + + it('shows CustomFieldsList correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument(); + + for (const field of customFieldsConfigurationMock) { + expect(screen.getByTestId(`custom-field-${field.label}-${field.type}`)).toBeInTheDocument(); + } + }); + + it('shows single CustomFieldsList correctly', async () => { + appMockRender.render( + + ); + + const list = screen.getByTestId('custom-fields-list'); + + expect(list).toBeInTheDocument(); + expect( + screen.getByTestId( + `custom-field-${customFieldsConfigurationMock[0].label}-${customFieldsConfigurationMock[0].type}` + ) + ).toBeInTheDocument(); + expect( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ).toBeInTheDocument(); + }); + + it('does not show any panel when custom fields', () => { + appMockRender.render(); + + expect(screen.queryAllByTestId(`custom-field-`, { exact: false })).toHaveLength(0); + }); + + describe('Delete', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows confirmation modal when deleting a field ', async () => { + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + }); + + it('calls onDeleteCustomField when confirm', async () => { + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(props.onDeleteCustomField).toHaveBeenCalledWith( + customFieldsConfigurationMock[0].key + ); + }); + }); + + it('does not call onDeleteCustomField when cancel', async () => { + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument(); + expect(props.onDeleteCustomField).not.toHaveBeenCalledWith(); + }); + }); + }); + + describe('Edit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls onEditCustomField correctly', async () => { + appMockRender.render(); + + const list = screen.getByTestId('custom-fields-list'); + + userEvent.click( + within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) + ); + + await waitFor(() => { + expect(props.onEditCustomField).toHaveBeenCalledWith(customFieldsConfigurationMock[0].key); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx new file mode 100644 index 0000000000000..32849a1e3ab52 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx @@ -0,0 +1,121 @@ +/* + * 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, useState } from 'react'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiIcon, + EuiButtonIcon, +} from '@elastic/eui'; + +import type { CustomFieldTypes, CustomFieldsConfiguration } from '../../../../common/types/domain'; +import { builderMap } from '../builder'; +import { DeleteConfirmationModal } from '../delete_confirmation_modal'; + +export interface Props { + customFields: CustomFieldsConfiguration; + onDeleteCustomField: (key: string) => void; + onEditCustomField: (key: string) => void; +} + +const CustomFieldsListComponent: React.FC = (props) => { + const { customFields, onDeleteCustomField, onEditCustomField } = props; + const [selectedItem, setSelectedItem] = useState(null); + + const renderTypeLabel = (type?: CustomFieldTypes) => { + const createdBuilder = type && builderMap[type]; + + return createdBuilder && createdBuilder().label; + }; + + const onConfirm = useCallback(() => { + if (selectedItem) { + onDeleteCustomField(selectedItem.key); + } + + setSelectedItem(null); + }, [onDeleteCustomField, setSelectedItem, selectedItem]); + + const onCancel = useCallback(() => { + setSelectedItem(null); + }, []); + + const showModal = Boolean(selectedItem); + + return customFields.length ? ( + <> + + + + {customFields.map((customField) => ( + + + + + + + + + + +

{customField.label}

+
+
+ {renderTypeLabel(customField.type)} +
+
+ + + + onEditCustomField(customField.key)} + /> + + + setSelectedItem(customField)} + /> + + + +
+
+ +
+ ))} +
+ {showModal && selectedItem ? ( + + ) : null} +
+ + ) : null; +}; + +CustomFieldsListComponent.displayName = 'CustomFieldsList'; + +export const CustomFieldsList = React.memo(CustomFieldsListComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.test.tsx new file mode 100644 index 0000000000000..8a2186fedbdc0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.test.tsx @@ -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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { DeleteConfirmationModal } from './delete_confirmation_modal'; + +describe('DeleteConfirmationModal', () => { + let appMock: AppMockRenderer; + const props = { + label: 'My custom field', + onCancel: jest.fn(), + onConfirm: jest.fn(), + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(); + + expect(result.getByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument(); + expect(result.getByText('Delete')).toBeInTheDocument(); + expect(result.getByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onConfirm', async () => { + const result = appMock.render(); + + expect(result.getByText('Delete')).toBeInTheDocument(); + userEvent.click(result.getByText('Delete')); + + expect(props.onConfirm).toHaveBeenCalled(); + }); + + it('calls onCancel', async () => { + const result = appMock.render(); + + expect(result.getByText('Cancel')).toBeInTheDocument(); + userEvent.click(result.getByText('Cancel')); + + expect(props.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.tsx b/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.tsx new file mode 100644 index 0000000000000..dc418961843a1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/delete_confirmation_modal.tsx @@ -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 { EuiConfirmModal } from '@elastic/eui'; +import * as i18n from './translations'; + +interface ConfirmDeleteCaseModalProps { + label: string; + onCancel: () => void; + onConfirm: () => void; +} + +const DeleteConfirmationModalComponent: React.FC = ({ + label, + onCancel, + onConfirm, +}) => { + return ( + + {i18n.DELETE_FIELD_DESCRIPTION} + + ); +}; +DeleteConfirmationModalComponent.displayName = 'DeleteConfirmationModal'; + +export const DeleteConfirmationModal = React.memo(DeleteConfirmationModalComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx new file mode 100644 index 0000000000000..ce7a9687b89ec --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/flyout.test.tsx @@ -0,0 +1,137 @@ +/* + * 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 { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { CustomFieldFlyout } from './flyout'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '../../../common/constants'; +import * as i18n from './translations'; + +describe('CustomFieldFlyout ', () => { + let appMockRender: AppMockRenderer; + + const props = { + onCloseFlyout: jest.fn(), + onSaveField: jest.fn(), + isLoading: false, + disabled: false, + customField: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-field-flyout-header')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-flyout-cancel')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-flyout-save')).toBeInTheDocument(); + }); + + it('calls onSaveField on save field', async () => { + appMockRender.render(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + userEvent.click(screen.getByTestId('text-custom-field-options')); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: true, + type: 'text', + }); + }); + }); + + it('shows error if field label is too long', async () => { + appMockRender.render(); + + const message = 'z'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1); + + userEvent.type(screen.getByTestId('custom-field-label-input'), message); + + await waitFor(() => { + expect( + screen.getByText(i18n.MAX_LENGTH_ERROR('field label', MAX_CUSTOM_FIELD_LABEL_LENGTH)) + ).toBeInTheDocument(); + }); + }); + + it('calls onSaveField with serialized data', async () => { + appMockRender.render(); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(props.onSaveField).toBeCalledWith({ + key: expect.anything(), + label: 'Summary', + required: false, + type: 'text', + }); + }); + }); + + it('does not call onSaveField when error', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('custom-field-flyout-save')); + + await waitFor(() => { + expect(screen.getByText(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL))).toBeInTheDocument(); + }); + + expect(props.onSaveField).not.toBeCalled(); + }); + + it('calls onCloseFlyout on cancel', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('custom-field-flyout-cancel')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('calls onCloseFlyout on close', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('euiFlyoutCloseButton')); + + await waitFor(() => { + expect(props.onCloseFlyout).toBeCalled(); + }); + }); + + it('renders flyout with data when customField value exist', async () => { + appMockRender.render( + + ); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[0].label + ); + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + expect(await screen.findByTestId('text-custom-field-options')).toHaveAttribute('checked'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx new file mode 100644 index 0000000000000..bf2013898e0c3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/flyout.tsx @@ -0,0 +1,106 @@ +/* + * 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, useState } from 'react'; +import { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, +} from '@elastic/eui'; +import type { CustomFieldFormState } from './form'; +import { CustomFieldsForm } from './form'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; + +import * as i18n from './translations'; + +export interface CustomFieldFlyoutProps { + disabled: boolean; + isLoading: boolean; + onCloseFlyout: () => void; + onSaveField: (data: CustomFieldConfiguration) => void; + customField: CustomFieldConfiguration | null; +} + +const CustomFieldFlyoutComponent: React.FC = ({ + onCloseFlyout, + onSaveField, + isLoading, + disabled, + customField, +}) => { + const dataTestSubj = 'custom-field-flyout'; + + const [formState, setFormState] = useState({ + isValid: undefined, + submit: async () => ({ + isValid: false, + data: { key: '', label: '', type: CustomFieldTypes.TEXT, required: false }, + }), + }); + + const { submit } = formState; + + const handleSaveField = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid) { + onSaveField(data); + } + }, [onSaveField, submit]); + + return ( + + + +

{i18n.ADD_CUSTOM_FIELD}

+
+
+ + + + + + + + {i18n.CANCEL} + + + + + + + {i18n.SAVE_FIELD} + + + + + +
+ ); +}; + +CustomFieldFlyoutComponent.displayName = 'CustomFieldFlyout'; + +export const CustomFieldFlyout = React.memo(CustomFieldFlyoutComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx new file mode 100644 index 0000000000000..5ff61d838a0d6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form.test.tsx @@ -0,0 +1,175 @@ +/* + * 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 { screen, fireEvent, waitFor, act } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import type { CustomFieldFormState } from './form'; +import { CustomFieldsForm } from './form'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import * as i18n from './translations'; +import userEvent from '@testing-library/user-event'; +import { customFieldsConfigurationMock } from '../../containers/mock'; + +describe('CustomFieldsForm ', () => { + let appMockRender: AppMockRenderer; + const onChange = jest.fn(); + + const props = { + onChange, + initialValue: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-field-label-input')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument(); + }); + + it('renders text as default custom field type', async () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument(); + expect(screen.getByText('Text')).toBeInTheDocument(); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('renders custom field type options', async () => { + appMockRender.render(); + + expect(screen.getByText('Text')).toBeInTheDocument(); + expect(screen.getByText('Toggle')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-type-selector')).not.toHaveAttribute('disabled'); + }); + + it('renders toggle custom field type', async () => { + appMockRender.render(); + + fireEvent.change(screen.getByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + expect(screen.getByTestId('toggle-custom-field-options')).toBeInTheDocument(); + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('serializes the data correctly if required is selected', async () => { + let formState: CustomFieldFormState; + + const onChangeState = (state: CustomFieldFormState) => (formState = state); + + appMockRender.render(); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + userEvent.click(screen.getByTestId('text-custom-field-options')); + + await act(async () => { + const { data } = await formState!.submit(); + + expect(data).toEqual({ + key: expect.anything(), + label: 'Summary', + required: true, + type: 'text', + }); + }); + }); + + it('serializes the data correctly if required is not selected', async () => { + let formState: CustomFieldFormState; + + const onChangeState = (state: CustomFieldFormState) => (formState = state); + + appMockRender.render(); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + userEvent.paste(screen.getByTestId('custom-field-label-input'), 'Summary'); + + await act(async () => { + const { data } = await formState!.submit(); + + expect(data).toEqual({ + key: expect.anything(), + label: 'Summary', + required: false, + type: 'text', + }); + }); + }); + + it('deserializes the data correctly if required is selected', async () => { + let formState: CustomFieldFormState; + + const onChangeState = (state: CustomFieldFormState) => (formState = state); + + appMockRender.render( + + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[0].label + ); + expect(await screen.findByTestId('text-custom-field-options')).toHaveAttribute('checked'); + + await act(async () => { + const { data } = await formState!.submit(); + + expect(data).toEqual(customFieldsConfigurationMock[0]); + }); + }); + + it('deserializes the data correctly if required not selected', async () => { + let formState: CustomFieldFormState; + + const onChangeState = (state: CustomFieldFormState) => (formState = state); + + appMockRender.render( + + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + expect(await screen.findByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + + expect(await screen.findByTestId('custom-field-label-input')).toHaveAttribute( + 'value', + customFieldsConfigurationMock[1].label + ); + expect(await screen.findByTestId('text-custom-field-options')).not.toHaveAttribute('checked'); + + await act(async () => { + const { data } = await formState!.submit(); + + expect(data).toEqual(customFieldsConfigurationMock[1]); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form.tsx b/x-pack/plugins/cases/public/components/custom_fields/form.tsx new file mode 100644 index 0000000000000..f4e8568281af1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form.tsx @@ -0,0 +1,92 @@ +/* + * 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 { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import React, { useEffect, useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import type { CustomFieldsConfigurationFormProps } from './schema'; +import { schema } from './schema'; +import { FormFields } from './form_fields'; +import type { CustomFieldConfiguration } from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; + +export interface CustomFieldFormState { + isValid: boolean | undefined; + submit: FormHook['submit']; +} + +interface Props { + onChange: (state: CustomFieldFormState) => void; + initialValue: CustomFieldConfiguration | null; +} + +// Form -> API +const formSerializer = ({ + key, + label, + type, + options, +}: CustomFieldsConfigurationFormProps): CustomFieldConfiguration => { + return { + key, + label, + type, + required: options?.required ? options.required : false, + }; +}; + +// API -> Form +const formDeserializer = ({ + key, + label, + type, + required, +}: CustomFieldConfiguration): CustomFieldsConfigurationFormProps => { + return { + key, + options: { required: Boolean(required) }, + label, + type, + }; +}; + +const FormComponent: React.FC = ({ onChange, initialValue }) => { + const keyDefaultValue = useMemo(() => uuidv4(), []); + + const { form } = useForm({ + defaultValue: initialValue ?? { + key: keyDefaultValue, + label: '', + type: CustomFieldTypes.TEXT, + required: false, + }, + options: { stripEmptyFields: false }, + schema, + serializer: formSerializer, + deserializer: formDeserializer, + }); + + const { submit, isValid, isSubmitting } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid, submit }); + } + }, [onChange, isValid, submit]); + + return ( + + + + ); +}; + +FormComponent.displayName = 'CustomFieldsForm'; + +export const CustomFieldsForm = React.memo(FormComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx new file mode 100644 index 0000000000000..5f7c4be9873f2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form_fields.test.tsx @@ -0,0 +1,74 @@ +/* + * 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 { screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { FormFields } from './form_fields'; + +describe('FormFields ', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render( + + + + ); + + expect(screen.getByTestId('custom-field-label-input')).toBeInTheDocument(); + expect(screen.getByTestId('custom-field-type-selector')).toBeInTheDocument(); + }); + + it('disables field type selector on edit mode', async () => { + appMockRender.render( + + + + ); + + expect(screen.getByTestId('custom-field-type-selector')).toHaveAttribute('disabled'); + }); + + it('submit data correctly', async () => { + appMockRender.render( + + + + ); + + userEvent.type(screen.getByTestId('custom-field-label-input'), 'hello'); + + fireEvent.change(screen.getByTestId('custom-field-type-selector'), { + target: { value: CustomFieldTypes.TOGGLE }, + }); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + label: 'hello', + type: 'toggle', + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx b/x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx new file mode 100644 index 0000000000000..4ae51002e9587 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/form_fields.tsx @@ -0,0 +1,95 @@ +/* + * 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, { memo, useCallback, useMemo, useState } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + TextField, + SelectField, + HiddenField, +} from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { EuiSelectOption } from '@elastic/eui'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { builderMap } from './builder'; + +interface FormFieldsProps { + isSubmitting?: boolean; + isEditMode?: boolean; +} + +const fieldTypeSelectOptions = (): EuiSelectOption[] => { + const options = []; + + for (const [id, builder] of Object.entries(builderMap)) { + const createdBuilder = builder(); + options.push({ value: id, text: createdBuilder.label }); + } + + return options; +}; + +const FormFieldsComponent: React.FC = ({ isSubmitting, isEditMode }) => { + const [selectedType, setSelectedType] = useState(CustomFieldTypes.TEXT); + + const handleTypeChange = useCallback( + (e) => { + setSelectedType(e.target.value); + }, + [setSelectedType] + ); + + const builtCustomField = useMemo(() => { + const builder = builderMap[selectedType]; + + if (builder == null) { + return null; + } + + const customFieldBuilder = builder(); + + return customFieldBuilder.build(); + }, [selectedType]); + + const Configure = builtCustomField?.Configure; + const options = fieldTypeSelectOptions(); + + return ( + <> + + + + {Configure ? : null} + + ); +}; + +FormFieldsComponent.displayName = 'FormFields'; + +export const FormFields = memo(FormFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx new file mode 100644 index 0000000000000..d81e31ba69d6d --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/index.test.tsx @@ -0,0 +1,113 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { screen, waitFor } from '@testing-library/dom'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { customFieldsConfigurationMock } from '../../containers/mock'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants'; +import { CustomFields } from '.'; +import * as i18n from './translations'; + +describe('CustomFields', () => { + let appMockRender: AppMockRenderer; + + const props = { + disabled: false, + isLoading: false, + handleAddCustomField: jest.fn(), + handleDeleteCustomField: jest.fn(), + handleEditCustomField: jest.fn(), + customFields: [], + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); + expect(screen.getByTestId('add-custom-field')).toBeInTheDocument(); + }); + + it('renders custom fields correctly', () => { + appMockRender.render( + + ); + + expect(screen.getByTestId('add-custom-field')).toBeInTheDocument(); + expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument(); + }); + + it('renders loading state correctly', () => { + appMockRender.render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('renders disabled state correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('add-custom-field')).toHaveAttribute('disabled'); + }); + + it('calls onChange on add option click', async () => { + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + expect(props.handleAddCustomField).toBeCalled(); + }); + + it('calls handleEditCustomField on edit option click', async () => { + appMockRender.render( + + ); + + userEvent.click( + screen.getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`) + ); + + expect(props.handleEditCustomField).toBeCalledWith(customFieldsConfigurationMock[0].key); + }); + + it('shows the experimental badge', () => { + appMockRender.render(); + + expect(screen.getByTestId('case-experimental-badge')).toBeInTheDocument(); + }); + + it('shows error when custom fields reaches the limit', async () => { + const generatedMockCustomFields = []; + + for (let i = 0; i < 8; i++) { + generatedMockCustomFields.push({ + key: `field_key_${i + 1}`, + label: `My custom label ${i + 1}`, + type: CustomFieldTypes.TEXT, + required: false, + }); + } + const customFields = [...customFieldsConfigurationMock, ...generatedMockCustomFields]; + + appMockRender.render(); + + userEvent.click(screen.getByTestId('add-custom-field')); + + await waitFor(() => { + expect(screen.getByText(i18n.MAX_CUSTOM_FIELD_LIMIT(MAX_CUSTOM_FIELDS_PER_CASE))); + expect(screen.getByTestId('add-custom-field')).toHaveAttribute('disabled'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/index.tsx new file mode 100644 index 0000000000000..b2fea5b7eee43 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/index.tsx @@ -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, { useState, useCallback } from 'react'; +import { + EuiButtonEmpty, + EuiPanel, + EuiDescribedFormGroup, + EuiSpacer, + EuiText, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import * as i18n from './translations'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import { MAX_CUSTOM_FIELDS_PER_CASE } from '../../../common/constants'; +import { CustomFieldsList } from './custom_fields_list'; +import { ExperimentalBadge } from '../experimental_badge/experimental_badge'; + +export interface Props { + customFields: CustomFieldsConfiguration; + disabled: boolean; + isLoading: boolean; + handleAddCustomField: () => void; + handleDeleteCustomField: (key: string) => void; + handleEditCustomField: (key: string) => void; +} +const CustomFieldsComponent: React.FC = ({ + disabled, + isLoading, + handleAddCustomField, + handleDeleteCustomField, + handleEditCustomField, + customFields, +}) => { + const { permissions } = useCasesContext(); + const canAddCustomFields = permissions.create && permissions.update; + const [error, setError] = useState(false); + + const onAddCustomField = useCallback(() => { + if (customFields.length === MAX_CUSTOM_FIELDS_PER_CASE && !error) { + setError(true); + return; + } + + handleAddCustomField(); + setError(false); + }, [handleAddCustomField, setError, customFields, error]); + + const onEditCustomField = useCallback( + (key: string) => { + setError(false); + handleEditCustomField(key); + }, + [setError, handleEditCustomField] + ); + + if (customFields.length < MAX_CUSTOM_FIELDS_PER_CASE && error) { + setError(false); + } + + return canAddCustomFields ? ( + + {i18n.TITLE} + + + + + } + description={

{i18n.DESCRIPTION}

} + data-test-subj="custom-fields-form-group" + > + + {customFields.length ? ( + <> + + {error ? ( + + + + {i18n.MAX_CUSTOM_FIELD_LIMIT(MAX_CUSTOM_FIELDS_PER_CASE)} + + + + ) : null} + + ) : null} + + {!customFields.length ? ( + + + {i18n.NO_CUSTOM_FIELDS} + + + + ) : null} + + + + {i18n.ADD_CUSTOM_FIELD} + + + + +
+ ) : null; +}; +CustomFieldsComponent.displayName = 'CustomFields'; + +export const CustomFields = React.memo(CustomFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/schema.tsx b/x-pack/plugins/cases/public/components/custom_fields/schema.tsx new file mode 100644 index 0000000000000..622ff1cb1673a --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/schema.tsx @@ -0,0 +1,54 @@ +/* + * 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 { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import * as i18n from './translations'; +import type { CustomFieldTypes } from '../../../common/types/domain'; +import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '../../../common/constants'; + +const { emptyField, maxLengthField } = fieldValidators; + +export interface CustomFieldsConfigurationFormProps { + key: string; + label: string; + type: CustomFieldTypes; + options?: { + required?: boolean; + }; +} + +export const schema = { + key: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)), + }, + ], + }, + label: { + label: i18n.FIELD_LABEL, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)), + }, + { + validator: maxLengthField({ + length: MAX_CUSTOM_FIELD_LABEL_LENGTH, + message: i18n.MAX_LENGTH_ERROR('field label', MAX_CUSTOM_FIELD_LABEL_LENGTH), + }), + }, + ], + }, + type: { + label: i18n.FIELD_TYPE, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.FIELD_LABEL)), + }, + ], + }, +}; diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/config.ts b/x-pack/plugins/cases/public/components/custom_fields/text/config.ts new file mode 100644 index 0000000000000..b318ebd1b3439 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/config.ts @@ -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 type { FieldConfig } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../../common/constants'; +import { MAX_LENGTH_ERROR, REQUIRED_FIELD } from '../translations'; + +const { emptyField } = fieldValidators; + +export const getTextFieldConfig = ({ + required, + label, +}: { + required: boolean; + label: string; +}): FieldConfig => { + const validators = []; + + if (required) { + validators.push({ + validator: emptyField(REQUIRED_FIELD(label)), + }); + } + + return { + validations: [ + ...validators, + { + validator: ({ value }) => { + if (value == null) { + return; + } + + if (value.length > MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH) { + return { + message: MAX_LENGTH_ERROR(label, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH), + }; + } + }, + }, + ], + }; +}; diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx new file mode 100644 index 0000000000000..4ca8cbc1e8663 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure.test.tsx @@ -0,0 +1,56 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FormTestComponent } from '../../../common/test_utils'; +import * as i18n from '../translations'; +import { Configure } from './configure'; + +describe('Configure ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('updates field options correctly', async () => { + render( + + + + ); + + userEvent.click(screen.getByText(i18n.FIELD_OPTION_REQUIRED)); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + options: { + required: true, + }, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx new file mode 100644 index 0000000000000..5a03bcd046630 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure.tsx @@ -0,0 +1,35 @@ +/* + * 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 { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { CheckBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; +import * as i18n from '../translations'; + +const ConfigureComponent: CustomFieldType['Configure'] = () => { + return ( + <> + + + ); +}; + +ConfigureComponent.displayName = 'Configure'; + +export const Configure = React.memo(ConfigureComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.ts b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.ts new file mode 100644 index 0000000000000..0b5a4966ee730 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.test.ts @@ -0,0 +1,24 @@ +/* + * 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 { configureTextCustomFieldFactory } from './configure_text_field'; + +describe('configureTextCustomFieldFactory ', () => { + const builder = configureTextCustomFieldFactory(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + expect(builder).toEqual({ + id: 'text', + label: 'Text', + build: expect.any(Function), + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts new file mode 100644 index 0000000000000..0081a2449a3f8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/configure_text_field.ts @@ -0,0 +1,26 @@ +/* + * 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 { CustomFieldFactory } from '../types'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import * as i18n from '../translations'; +import { Edit } from './edit'; +import { View } from './view'; +import { Configure } from './configure'; +import { Create } from './create'; + +export const configureTextCustomFieldFactory: CustomFieldFactory = () => ({ + id: CustomFieldTypes.TEXT, + label: i18n.TEXT_LABEL, + build: () => ({ + Configure, + Edit, + View, + Create, + }), +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx new file mode 100644 index 0000000000000..1d1768a7c1c7c --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.test.tsx @@ -0,0 +1,184 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FormTestComponent } from '../../../common/test_utils'; +import { Create } from './create'; +import { customFieldsConfigurationMock } from '../../../containers/mock'; +import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../../common/constants'; + +describe('Create ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customFieldConfiguration = customFieldsConfigurationMock[0]; + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`) + ).toBeInTheDocument(); + }); + + it('renders loading state correctly', async () => { + render( + + + + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('disables the text when loading', async () => { + render( + + + + ); + + expect( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`) + ).toHaveAttribute('disabled'); + }); + + it('updates the value correctly', async () => { + render( + + + + ); + + userEvent.type( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`), + 'this is a sample text!' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [customFieldConfiguration.key]: 'this is a sample text!', + }, + }, + true + ); + }); + }); + + it('shows error when text is too long', async () => { + render( + + + + ); + + const sampleText = 'a'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1); + + userEvent.paste( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`), + sampleText + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect( + screen.getByText( + `The length of the ${customFieldConfiguration.label} is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH} characters.` + ) + ).toBeInTheDocument(); + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('shows error when text is too long and field is optional', async () => { + render( + + + + ); + + const sampleText = 'a'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1); + + userEvent.paste( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`), + sampleText + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect( + screen.getByText( + `The length of the ${customFieldConfiguration.label} is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH} characters.` + ) + ).toBeInTheDocument(); + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('shows error when text is required but is empty', async () => { + render( + + + + ); + + userEvent.paste( + screen.getByTestId(`${customFieldConfiguration.key}-text-create-custom-field`), + '' + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect( + screen.getByText(`${customFieldConfiguration.label} is required.`) + ).toBeInTheDocument(); + expect(onSubmit).toHaveBeenCalledWith({}, false); + }); + }); + + it('does not show error when text is not required but is empty', async () => { + render( + + + + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({}, true); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx new file mode 100644 index 0000000000000..195e55073dd71 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/create.tsx @@ -0,0 +1,42 @@ +/* + * 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 { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; +import { getTextFieldConfig } from './config'; + +const CreateComponent: CustomFieldType['Create'] = ({ + customFieldConfiguration, + isLoading, +}) => { + const { key, label, required } = customFieldConfiguration; + const config = getTextFieldConfig({ required, label }); + + return ( + + ); +}; + +CreateComponent.displayName = 'Create'; + +export const Create = React.memo(CreateComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/edit.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/edit.test.tsx new file mode 100644 index 0000000000000..54cdce7e79289 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/edit.test.tsx @@ -0,0 +1,381 @@ +/* + * 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, waitFor } from '@testing-library/react'; + +import { FormTestComponent } from '../../../common/test_utils'; +import { Edit } from './edit'; +import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock'; +import userEvent from '@testing-library/user-event'; +import { MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH } from '../../../../common/constants'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; + +describe('Edit ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = customFieldsMock[0] as CaseCustomFieldText; + const customFieldConfiguration = customFieldsConfigurationMock[0]; + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByTestId('case-text-custom-field-test_key_1')).toBeInTheDocument(); + expect(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')).toBeInTheDocument(); + expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect(screen.getByText('My text test value 1')).toBeInTheDocument(); + }); + + it('does not shows the edit button if the user does not have permissions', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-text-custom-field-edit-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('does not shows the edit button when loading', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-text-custom-field-edit-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('shows the loading spinner when loading', async () => { + render( + + + + ); + + expect(screen.getByTestId('case-text-custom-field-loading-test_key_1')).toBeInTheDocument(); + }); + + it('shows the no value text if the custom field is undefined', async () => { + render( + + + + ); + + expect(screen.getByText('No "My test label 1" added')).toBeInTheDocument(); + }); + + it('shows the no value text if the the value is null', async () => { + render( + + + + ); + + expect(screen.getByText('No "My test label 1" added')).toBeInTheDocument(); + }); + + it('does not show the value when the custom field is undefined', async () => { + render( + + + + ); + + expect(screen.queryByTestId('text-custom-field-view-test_key_1')).not.toBeInTheDocument(); + }); + + it('does not show the value when the value is null', async () => { + render( + + + + ); + + expect(screen.queryByTestId('text-custom-field-view-test_key_1')).not.toBeInTheDocument(); + }); + + it('does not show the form when the user does not have permissions', async () => { + render( + + + + ); + + expect( + screen.queryByTestId('case-text-custom-field-form-field-test_key_1') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('case-text-custom-field-cancel-button-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('calls onSubmit when changing value', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.paste(screen.getByTestId('case-text-custom-field-form-field-test_key_1'), '!!!'); + + await waitFor(() => { + expect( + screen.getByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeDisabled(); + }); + + userEvent.click(screen.getByTestId('case-text-custom-field-submit-button-test_key_1')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: ['My text test value 1!!!'], + }); + }); + }); + + it('sets the value to null if the text field is empty', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1')); + + await waitFor(() => { + expect( + screen.getByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeDisabled(); + }); + + userEvent.click(screen.getByTestId('case-text-custom-field-submit-button-test_key_1')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + value: null, + }); + }); + }); + + it('hides the form when clicking the cancel button', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + + expect(screen.getByTestId('case-text-custom-field-form-field-test_key_1')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('case-text-custom-field-cancel-button-test_key_1')); + + expect( + screen.queryByTestId('case-text-custom-field-form-field-test_key_1') + ).not.toBeInTheDocument(); + }); + + it('reset to initial value when canceling', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.paste(screen.getByTestId('case-text-custom-field-form-field-test_key_1'), '!!!'); + + await waitFor(() => { + expect( + screen.getByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeDisabled(); + }); + + userEvent.click(screen.getByTestId('case-text-custom-field-cancel-button-test_key_1')); + + expect( + screen.queryByTestId('case-text-custom-field-form-field-test_key_1') + ).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + expect(screen.getByTestId('case-text-custom-field-form-field-test_key_1')).toHaveValue( + 'My text test value 1' + ); + }); + + it('shows validation error if the field is required', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1')); + + await waitFor(() => { + expect(screen.getByText('My test label 1 is required.')).toBeInTheDocument(); + }); + }); + + it('does not shows a validation error if the field is not required', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1')); + + await waitFor(() => { + expect( + screen.getByTestId('case-text-custom-field-submit-button-test_key_1') + ).not.toBeDisabled(); + }); + + expect(screen.queryByText('My test label 1 is required.')).not.toBeInTheDocument(); + }); + + it('shows validation error if the field is too long', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('case-text-custom-field-edit-button-test_key_1')); + userEvent.clear(screen.getByTestId('case-text-custom-field-form-field-test_key_1')); + userEvent.paste( + screen.getByTestId('case-text-custom-field-form-field-test_key_1'), + 'a'.repeat(MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH + 1) + ); + + await waitFor(() => { + expect( + screen.getByText( + `The length of the My test label 1 is too long. The maximum length is ${MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH} characters.` + ) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/edit.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/edit.tsx new file mode 100644 index 0000000000000..1fa016c6a1c9e --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/edit.tsx @@ -0,0 +1,218 @@ +/* + * 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 { isEmpty } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiText, +} from '@elastic/eui'; +import type { FormHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { useForm, UseField, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import type { CasesConfigurationUICustomField } from '../../../../common/ui'; +import type { CustomFieldType } from '../types'; +import { View } from './view'; +import { CANCEL, EDIT_CUSTOM_FIELDS_ARIA_LABEL, NO_CUSTOM_FIELD_SET, SAVE } from '../translations'; +import { getTextFieldConfig } from './config'; + +interface FormState { + isValid: boolean | undefined; + submit: FormHook<{ value: string }>['submit']; +} + +interface FormWrapper { + initialValue: string; + isLoading: boolean; + customFieldConfiguration: CasesConfigurationUICustomField; + onChange: (state: FormState) => void; +} + +const FormWrapperComponent: React.FC = ({ + initialValue, + customFieldConfiguration, + isLoading, + onChange, +}) => { + const { form } = useForm({ + defaultValue: { value: initialValue }, + }); + + const { submit, isValid: isFormValid } = form; + + useEffect(() => { + onChange({ isValid: isFormValid, submit }); + }, [isFormValid, onChange, submit]); + + const formFieldConfig = getTextFieldConfig({ + required: customFieldConfiguration.required, + label: customFieldConfiguration.label, + }); + + return ( +
+ + + ); +}; + +FormWrapperComponent.displayName = 'FormWrapper'; + +const EditComponent: CustomFieldType['Edit'] = ({ + customField, + customFieldConfiguration, + onSubmit, + isLoading, + canUpdate, +}) => { + const [isEdit, setIsEdit] = useState(false); + + const [formState, setFormState] = useState({ + isValid: undefined, + submit: async () => ({ isValid: false, data: { value: '' } }), + }); + + const onEdit = () => { + setIsEdit(true); + }; + + const onCancel = () => { + setIsEdit(false); + }; + + const onSubmitCustomField = async () => { + const { isValid, data } = await formState.submit(); + + if (isValid) { + const value = isEmpty(data.value) ? null : [data.value]; + + onSubmit({ + ...customField, + key: customField?.key ?? customFieldConfiguration.key, + type: CustomFieldTypes.TEXT, + value, + }); + } + + setIsEdit(false); + }; + + const initialValue = customField?.value?.[0] ?? ''; + const title = customFieldConfiguration.label; + const isTextFieldValid = formState.isValid; + const isCustomFieldValueDefined = !isEmpty(customField?.value); + + return ( + <> + + + +

{title}

+
+
+ {isLoading && ( + + )} + {!isLoading && canUpdate && ( + + + + )} +
+ + + {!isCustomFieldValueDefined && !isEdit && ( +

{NO_CUSTOM_FIELD_SET(customFieldConfiguration.label)}

+ )} + {!isEdit && isCustomFieldValueDefined && ( + + + + )} + {isEdit && canUpdate && ( + + + + + + + + + {SAVE} + + + + + {CANCEL} + + + + + + )} +
+ + ); +}; + +EditComponent.displayName = 'Edit'; + +export const Edit = React.memo(EditComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/view.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/view.test.tsx new file mode 100644 index 0000000000000..bfad8aeda8bf7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/view.test.tsx @@ -0,0 +1,30 @@ +/* + * 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 { CustomFieldTypes } from '../../../../common/types/domain'; +import { View } from './view'; + +describe('View ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = { + type: CustomFieldTypes.TEXT as const, + key: 'test_key_1', + value: ['My text test value'], + }; + + it('renders correctly', async () => { + render(); + + expect(screen.getByText('My text test value')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/text/view.tsx b/x-pack/plugins/cases/public/components/custom_fields/text/view.tsx new file mode 100644 index 0000000000000..fa58b8fa6f9d5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/text/view.tsx @@ -0,0 +1,22 @@ +/* + * 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 { EuiText } from '@elastic/eui'; +import type { CaseCustomFieldText } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const ViewComponent: CustomFieldType['View'] = ({ customField }) => { + const value = customField?.value?.[0] ?? '-'; + + return {value}; +}; + +ViewComponent.displayName = 'View'; + +export const View = React.memo(ViewComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.test.tsx new file mode 100644 index 0000000000000..4ca8cbc1e8663 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.test.tsx @@ -0,0 +1,56 @@ +/* + * 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, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FormTestComponent } from '../../../common/test_utils'; +import * as i18n from '../translations'; +import { Configure } from './configure'; + +describe('Configure ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(i18n.FIELD_OPTION_REQUIRED)).toBeInTheDocument(); + }); + + it('updates field options correctly', async () => { + render( + + + + ); + + userEvent.click(screen.getByText(i18n.FIELD_OPTION_REQUIRED)); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toBeCalledWith( + { + options: { + required: true, + }, + }, + true + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.tsx new file mode 100644 index 0000000000000..a363d8c35eb45 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure.tsx @@ -0,0 +1,35 @@ +/* + * 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 { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { CheckBoxField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; +import * as i18n from '../translations'; + +const ConfigureComponent: CustomFieldType['Configure'] = () => { + return ( + <> + + + ); +}; + +ConfigureComponent.displayName = 'Configure'; + +export const Configure = React.memo(ConfigureComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_text_field.test.ts b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_text_field.test.ts new file mode 100644 index 0000000000000..c32dac659adfe --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_text_field.test.ts @@ -0,0 +1,24 @@ +/* + * 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 { configureToggleCustomFieldFactory } from './configure_toggle_field'; + +describe('configureToggleCustomFieldFactory ', () => { + const builder = configureToggleCustomFieldFactory(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + expect(builder).toEqual({ + id: 'toggle', + label: 'Toggle', + build: expect.any(Function), + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts new file mode 100644 index 0000000000000..00f103fcfdd6a --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/configure_toggle_field.ts @@ -0,0 +1,26 @@ +/* + * 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 { CustomFieldFactory } from '../types'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import * as i18n from '../translations'; +import { Edit } from './edit'; +import { View } from './view'; +import { Configure } from './configure'; +import { Create } from './create'; + +export const configureToggleCustomFieldFactory: CustomFieldFactory = () => ({ + id: CustomFieldTypes.TOGGLE, + label: i18n.TOGGLE_LABEL, + build: () => ({ + Configure, + Edit, + View, + Create, + }), +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx new file mode 100644 index 0000000000000..3a09b3f5b17cb --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.test.tsx @@ -0,0 +1,96 @@ +/* + * 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, waitFor } from '@testing-library/react'; + +import { FormTestComponent } from '../../../common/test_utils'; +import { Create } from './create'; +import { customFieldsConfigurationMock } from '../../../containers/mock'; +import userEvent from '@testing-library/user-event'; + +describe('Create ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customFieldConfiguration = customFieldsConfigurationMock[1]; + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect( + screen.getByTestId(`${customFieldConfiguration.key}-toggle-create-custom-field`) + ).toBeInTheDocument(); + expect(screen.getByRole('switch')).not.toBeChecked(); + }); + + it('updates the value correctly', async () => { + render( + + + + ); + + userEvent.click(screen.getByRole('switch')); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + // data, isValid + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [customFieldConfiguration.key]: true, + }, + }, + true + ); + }); + }); + + it('sets value to false by default', async () => { + render( + + + + ); + + userEvent.click(screen.getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + customFields: { + [customFieldConfiguration.key]: false, + }, + }, + true + ); + }); + }); + + it('disables the toggle when loading', async () => { + render( + + + + ); + + expect(screen.getByRole('switch')).toBeDisabled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx new file mode 100644 index 0000000000000..169d3f48d6710 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/create.tsx @@ -0,0 +1,39 @@ +/* + * 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 { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const CreateComponent: CustomFieldType['Create'] = ({ + customFieldConfiguration, + isLoading, +}) => { + const { key, label } = customFieldConfiguration; + + return ( + + ); +}; + +CreateComponent.displayName = 'Create'; + +export const Create = React.memo(CreateComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.test.tsx new file mode 100644 index 0000000000000..1af31cf13dd54 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.test.tsx @@ -0,0 +1,123 @@ +/* + * 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, waitFor } from '@testing-library/react'; + +import { FormTestComponent } from '../../../common/test_utils'; +import { Edit } from './edit'; +import { customFieldsMock, customFieldsConfigurationMock } from '../../../containers/mock'; +import userEvent from '@testing-library/user-event'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; + +describe('Edit ', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = customFieldsMock[1] as CaseCustomFieldToggle; + const customFieldConfiguration = customFieldsConfigurationMock[1]; + + it('renders correctly', async () => { + render( + + + + ); + + expect(screen.getByText(customFieldConfiguration.label)).toBeInTheDocument(); + expect(screen.getByTestId('case-toggle-custom-field-test_key_2')).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeChecked(); + }); + + it('calls onSubmit when changing value', async () => { + render( + + + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ ...customField, value: false }); + }); + }); + + it('disables the toggle if the user does not have permissions', async () => { + render( + + + + ); + + expect(screen.getByRole('switch')).toBeDisabled(); + }); + + it('disables the toggle when loading', async () => { + render( + + + + ); + + expect(screen.getByRole('switch')).toBeDisabled(); + }); + + it('sets the configuration key and the initial value if the custom field is undefined', async () => { + render( + + + + ); + + userEvent.click(screen.getByRole('switch')); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith({ + ...customField, + key: customFieldConfiguration.key, + /** + * Initial value is false when the custom field is undefined. + * By clicking to the switch it is set to true + */ + value: true, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.tsx new file mode 100644 index 0000000000000..1bb3677c2bccb --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/edit.tsx @@ -0,0 +1,84 @@ +/* + * 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 { Form, UseField, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; + +import { ToggleField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiText } from '@elastic/eui'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import { CustomFieldTypes } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const EditComponent: CustomFieldType['Edit'] = ({ + customField, + customFieldConfiguration, + onSubmit, + isLoading, + canUpdate, +}) => { + const initialValue = Boolean(customField?.value); + const title = customFieldConfiguration.label; + + const { form } = useForm<{ value: boolean }>({ + defaultValue: { value: initialValue }, + }); + + const onSubmitCustomField = async () => { + const { isValid, data } = await form.submit(); + + if (isValid) { + onSubmit({ + ...customField, + key: customField?.key ?? customFieldConfiguration.key, + type: CustomFieldTypes.TOGGLE, + value: data.value, + }); + } + }; + + return ( + <> + + + +

{title}

+
+
+
+ + +
+ + +
+ + ); +}; + +EditComponent.displayName = 'Edit'; + +export const Edit = React.memo(EditComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/view.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/view.test.tsx new file mode 100644 index 0000000000000..33836b6784990 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/view.test.tsx @@ -0,0 +1,30 @@ +/* + * 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 { CustomFieldTypes } from '../../../../common/types/domain'; +import { View } from './view'; + +describe('View ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const customField = { + type: CustomFieldTypes.TOGGLE as const, + key: 'test_key_1', + value: true, + }; + + it('renders correctly', async () => { + render(); + + expect(screen.getByTestId('toggle-custom-field-view-test_key_1')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/toggle/view.tsx b/x-pack/plugins/cases/public/components/custom_fields/toggle/view.tsx new file mode 100644 index 0000000000000..13af4b529fff5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/toggle/view.tsx @@ -0,0 +1,27 @@ +/* + * 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 { EuiIcon } from '@elastic/eui'; +import type { CaseCustomFieldToggle } from '../../../../common/types/domain'; +import type { CustomFieldType } from '../types'; + +const ViewComponent: CustomFieldType['View'] = ({ customField }) => { + const value = Boolean(customField?.value); + const iconType = value ? 'check' : 'empty'; + + return ( + + {value} + + ); +}; + +ViewComponent.displayName = 'View'; + +export const View = React.memo(ViewComponent); diff --git a/x-pack/plugins/cases/public/components/custom_fields/translations.ts b/x-pack/plugins/cases/public/components/custom_fields/translations.ts new file mode 100644 index 0000000000000..d0a68d81becc2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/translations.ts @@ -0,0 +1,102 @@ +/* + * 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 * from '../../common/translations'; + +export const TITLE = i18n.translate('xpack.cases.customFields.title', { + defaultMessage: 'Custom Fields', +}); + +export const DESCRIPTION = i18n.translate('xpack.cases.customFields.description', { + defaultMessage: 'Add more optional and required fields for customized case collaboration.', +}); + +export const NO_CUSTOM_FIELDS = i18n.translate('xpack.cases.customFields.noCustomFields', { + defaultMessage: 'You do not have any fields yet', +}); + +export const ADD_CUSTOM_FIELD = i18n.translate('xpack.cases.customFields.addCustomField', { + defaultMessage: 'Add field', +}); + +export const MAX_CUSTOM_FIELD_LIMIT = (maxCustomFields: number) => + i18n.translate('xpack.cases.customFields.maxCustomFieldLimit', { + values: { maxCustomFields }, + defaultMessage: 'Maximum number of {maxCustomFields} custom fields reached.', + }); + +export const SAVE_FIELD = i18n.translate('xpack.cases.customFields.saveField', { + defaultMessage: 'Save field', +}); + +export const FIELD_LABEL = i18n.translate('xpack.cases.customFields.fieldLabel', { + defaultMessage: 'Field label', +}); + +export const FIELD_LABEL_HELP_TEXT = i18n.translate('xpack.cases.customFields.fieldLabelHelpText', { + defaultMessage: '50 characters max', +}); + +export const TEXT_LABEL = i18n.translate('xpack.cases.customFields.textLabel', { + defaultMessage: 'Text', +}); + +export const TOGGLE_LABEL = i18n.translate('xpack.cases.customFields.toggleLabel', { + defaultMessage: 'Toggle', +}); + +export const FIELD_TYPE = i18n.translate('xpack.cases.customFields.fieldType', { + defaultMessage: 'Field type', +}); + +export const FIELD_OPTIONS = i18n.translate('xpack.cases.customFields.fieldOptions', { + defaultMessage: 'Options', +}); + +export const FIELD_OPTION_REQUIRED = i18n.translate( + 'xpack.cases.customFields.fieldOptions.Required', + { + defaultMessage: 'Make this field required', + } +); + +export const REQUIRED_FIELD = (fieldName: string): string => + i18n.translate('xpack.cases.customFields.requiredField', { + values: { fieldName }, + defaultMessage: '{fieldName} is required.', + }); + +export const EDIT_CUSTOM_FIELDS_ARIA_LABEL = (customFieldLabel: string) => + i18n.translate('xpack.cases.caseView.editCustomFieldsAriaLabel', { + values: { customFieldLabel }, + defaultMessage: 'click to edit {customFieldLabel}', + }); + +export const NO_CUSTOM_FIELD_SET = (customFieldLabel: string) => + i18n.translate('xpack.cases.caseView.noCustomFieldSet', { + values: { customFieldLabel }, + defaultMessage: 'No "{customFieldLabel}" added', + }); + +export const DELETE_FIELD_TITLE = (fieldName: string) => + i18n.translate('xpack.cases.customFields.deleteField', { + values: { fieldName }, + defaultMessage: 'Delete field "{fieldName}"?', + }); + +export const DELETE_FIELD_DESCRIPTION = i18n.translate( + 'xpack.cases.customFields.deleteFieldDescription', + { + defaultMessage: 'The field will be removed from all cases and data will be lost.', + } +); + +export const DELETE = i18n.translate('xpack.cases.customFields.fieldOptions.Delete', { + defaultMessage: 'Delete', +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/types.ts b/x-pack/plugins/cases/public/components/custom_fields/types.ts new file mode 100644 index 0000000000000..e4cddebe59821 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/types.ts @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import type { CustomFieldTypes } from '../../../common/types/domain'; +import type { CasesConfigurationUICustomField, CaseUICustomField } from '../../containers/types'; + +export interface CustomFieldType { + Configure: React.FC; + View: React.FC<{ + customField?: T; + }>; + Edit: React.FC<{ + customField?: T; + customFieldConfiguration: CasesConfigurationUICustomField; + onSubmit: (customField: T) => void; + isLoading: boolean; + canUpdate: boolean; + }>; + Create: React.FC<{ + customFieldConfiguration: CasesConfigurationUICustomField; + isLoading: boolean; + }>; +} + +export type CustomFieldFactory = () => { + id: string; + label: string; + build: () => CustomFieldType; +}; + +export type CustomFieldBuilderMap = { + readonly [key in CustomFieldTypes]: CustomFieldFactory; +}; diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts new file mode 100644 index 0000000000000..6f89b633eed0a --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.test.ts @@ -0,0 +1,148 @@ +/* + * 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 { addOrReplaceCustomField } from './utils'; +import { customFieldsConfigurationMock, customFieldsMock } from '../../containers/mock'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import type { CaseUICustomField } from '../../../common/ui'; + +describe('addOrReplaceCustomField ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds new custom field correctly', async () => { + const fieldToAdd: CaseUICustomField = { + key: 'my_test_key', + type: CustomFieldTypes.TEXT, + value: ['my_test_value'], + }; + const res = addOrReplaceCustomField(customFieldsMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsMock, fieldToAdd], + ` + Array [ + Object { + "key": "test_key_1", + "type": "text", + "value": Array [ + "My text test value 1", + ], + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + Object { + "key": "my_test_key", + "type": "text", + "value": Array [ + "my_test_value", + ], + }, + ] + ` + ); + }); + + it('updates existing custom field correctly', async () => { + const fieldToUpdate = { + ...customFieldsMock[0], + field: { value: ['My text test value 1!!!'] }, + }; + + const res = addOrReplaceCustomField(customFieldsMock, fieldToUpdate as CaseUICustomField); + expect(res).toMatchInlineSnapshot( + [{ ...fieldToUpdate }, { ...customFieldsMock[1] }], + ` + Array [ + Object { + "field": Object { + "value": Array [ + "My text test value 1!!!", + ], + }, + "key": "test_key_1", + "type": "text", + "value": Array [ + "My text test value 1", + ], + }, + Object { + "key": "test_key_2", + "type": "toggle", + "value": true, + }, + ] + ` + ); + }); + + it('adds new custom field configuration correctly', async () => { + const fieldToAdd = { + key: 'my_test_key', + type: CustomFieldTypes.TEXT, + label: 'my_test_label', + required: true, + }; + const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToAdd); + expect(res).toMatchInlineSnapshot( + [...customFieldsConfigurationMock, fieldToAdd], + ` + Array [ + Object { + "key": "test_key_1", + "label": "My test label 1", + "required": true, + "type": "text", + }, + Object { + "key": "test_key_2", + "label": "My test label 2", + "required": false, + "type": "toggle", + }, + Object { + "key": "my_test_key", + "label": "my_test_label", + "required": true, + "type": "text", + }, + ] + ` + ); + }); + + it('updates existing custom field config correctly', async () => { + const fieldToUpdate = { + ...customFieldsConfigurationMock[0], + label: `${customFieldsConfigurationMock[0].label}!!!`, + }; + + const res = addOrReplaceCustomField(customFieldsConfigurationMock, fieldToUpdate); + expect(res).toMatchInlineSnapshot( + [{ ...fieldToUpdate }, { ...customFieldsConfigurationMock[1] }], + ` + Array [ + Object { + "key": "test_key_1", + "label": "My test label 1!!!", + "required": true, + "type": "text", + }, + Object { + "key": "test_key_2", + "label": "My test label 2", + "required": false, + "type": "toggle", + }, + ] + ` + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/custom_fields/utils.ts b/x-pack/plugins/cases/public/components/custom_fields/utils.ts new file mode 100644 index 0000000000000..18906f338fc42 --- /dev/null +++ b/x-pack/plugins/cases/public/components/custom_fields/utils.ts @@ -0,0 +1,27 @@ +/* + * 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 const addOrReplaceCustomField = ( + customFields: T[], + customFieldToAdd: T +): T[] => { + const foundCustomFieldIndex = customFields.findIndex( + (customField) => customField.key === customFieldToAdd.key + ); + + if (foundCustomFieldIndex === -1) { + return [...customFields, customFieldToAdd]; + } + + return customFields.map((customField) => { + if (customField.key !== customFieldToAdd.key) { + return customField; + } + + return customFieldToAdd; + }); +}; diff --git a/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.test.tsx b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.test.tsx new file mode 100644 index 0000000000000..a13aea90e8e6e --- /dev/null +++ b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { screen } from '@testing-library/dom'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { ExperimentalBadge } from './experimental_badge'; + +describe('ExperimentalBadge', () => { + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders the experimental badge', () => { + appMockRenderer.render(); + + expect(screen.getByTestId('case-experimental-badge')).toBeInTheDocument(); + }); + + it('renders the title correctly', () => { + appMockRenderer.render(); + + expect(screen.getByText('Technical preview')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx new file mode 100644 index 0000000000000..f20d821b95844 --- /dev/null +++ b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx @@ -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 type { EuiBetaBadgeProps } from '@elastic/eui'; +import { EuiBetaBadge } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { EXPERIMENTAL_LABEL, EXPERIMENTAL_DESC } from '../../common/translations'; + +interface Props { + icon?: boolean; + size?: EuiBetaBadgeProps['size']; +} + +const ExperimentalBadgeComponent: React.FC = ({ icon = false, size = 's' }) => { + const props: EuiBetaBadgeProps = { + label: EXPERIMENTAL_LABEL, + size, + ...(icon && { iconType: 'beaker' }), + tooltipContent: EXPERIMENTAL_DESC, + tooltipPosition: 'bottom' as const, + 'data-test-subj': 'case-experimental-badge', + }; + + return ( + + ); +}; + +ExperimentalBadgeComponent.displayName = 'ExperimentalBadge'; + +export const ExperimentalBadge = React.memo(ExperimentalBadgeComponent); diff --git a/x-pack/plugins/cases/public/components/header_page/translations.ts b/x-pack/plugins/cases/public/components/header_page/translations.ts index 358f667bba367..b50db9b092ada 100644 --- a/x-pack/plugins/cases/public/components/header_page/translations.ts +++ b/x-pack/plugins/cases/public/components/header_page/translations.ts @@ -23,15 +23,6 @@ export const EDIT_TITLE_ARIA = (title: string) => defaultMessage: 'You can edit {title} by clicking', }); -export const EXPERIMENTAL_LABEL = i18n.translate('xpack.cases.header.badge.experimentalLabel', { - defaultMessage: 'Technical preview', -}); - -export const EXPERIMENTAL_DESC = i18n.translate('xpack.cases.header.badge.experimentalDesc', { - defaultMessage: - 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will take a best effort approach to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', -}); - export const BETA_LABEL = i18n.translate('xpack.cases.header.badge.betaLabel', { defaultMessage: 'Beta', }); diff --git a/x-pack/plugins/cases/public/components/links/index.test.tsx b/x-pack/plugins/cases/public/components/links/index.test.tsx index 9f15ec7329b1f..81fd9217f19de 100644 --- a/x-pack/plugins/cases/public/components/links/index.test.tsx +++ b/x-pack/plugins/cases/public/components/links/index.test.tsx @@ -23,7 +23,6 @@ jest.mock('../../common/navigation/hooks'); describe('Configuration button', () => { let wrapper: ReactWrapper; const props: ConfigureCaseButtonProps = { - isDisabled: false, label: 'My label', msgTooltip: <>, showToolTip: false, diff --git a/x-pack/plugins/cases/public/components/links/index.tsx b/x-pack/plugins/cases/public/components/links/index.tsx index 2916aebd2d32f..f09a9f852a28f 100644 --- a/x-pack/plugins/cases/public/components/links/index.tsx +++ b/x-pack/plugins/cases/public/components/links/index.tsx @@ -66,7 +66,6 @@ export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; export interface ConfigureCaseButtonProps { - isDisabled: boolean; label: string; msgTooltip: JSX.Element; showToolTip: boolean; @@ -76,7 +75,6 @@ export interface ConfigureCaseButtonProps { // TODO: Fix this manually. Issue #123375 // eslint-disable-next-line react/display-name const ConfigureCaseButtonComponent: React.FC = ({ - isDisabled, label, msgTooltip, showToolTip, @@ -98,14 +96,14 @@ const ConfigureCaseButtonComponent: React.FC = ({ onClick={navigateToConfigureCasesClick} href={getConfigureCasesUrl()} iconType="controlsHorizontal" - isDisabled={isDisabled} + isDisabled={false} aria-label={label} data-test-subj="configure-case-button" > {label} ), - [label, isDisabled, navigateToConfigureCasesClick, getConfigureCasesUrl] + [label, navigateToConfigureCasesClick, getConfigureCasesUrl] ); return showToolTip ? ( diff --git a/x-pack/plugins/cases/public/components/user_actions/builder.tsx b/x-pack/plugins/cases/public/components/user_actions/builder.tsx index 25b4b849d871e..b0b3a9f7a7de9 100644 --- a/x-pack/plugins/cases/public/components/user_actions/builder.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/builder.tsx @@ -18,6 +18,7 @@ import { createTitleUserActionBuilder } from './title'; import { createCaseUserActionBuilder } from './create_case'; import type { UserActionBuilderMap } from './types'; import { createCategoryUserActionBuilder } from './category'; +import { createCustomFieldsUserActionBuilder } from './custom_fields/custom_fields'; export const builderMap: UserActionBuilderMap = { create_case: createCaseUserActionBuilder, @@ -32,4 +33,5 @@ export const builderMap: UserActionBuilderMap = { settings: createSettingsUserActionBuilder, assignees: createAssigneesUserActionBuilder, category: createCategoryUserActionBuilder, + customFields: createCustomFieldsUserActionBuilder, }; diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx index 3576364f8ecd7..68bd8134c0e62 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx @@ -248,6 +248,7 @@ const getCreateCommentUserAction = ({ export const createCommentUserActionBuilder: UserActionBuilder = ({ appId, caseData, + casesConfiguration, userProfiles, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, @@ -293,6 +294,7 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({ const commentAction = getCreateCommentUserAction({ appId, caseData, + casesConfiguration, userProfiles, userAction: commentUserAction, externalReferenceAttachmentTypeRegistry, diff --git a/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx new file mode 100644 index 0000000000000..b496fc28b133b --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.test.tsx @@ -0,0 +1,147 @@ +/* + * 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 { EuiCommentList } from '@elastic/eui'; +import { render, screen } from '@testing-library/react'; + +import { getUserAction } from '../../../containers/mock'; +import { TestProviders } from '../../../common/mock'; +import { createCustomFieldsUserActionBuilder } from './custom_fields'; +import { getMockBuilderArgs } from '../mock'; +import { + CustomFieldTypes, + UserActionActions, + UserActionTypes, +} from '../../../../common/types/domain'; + +jest.mock('../../../common/lib/kibana'); +jest.mock('../../../common/navigation/hooks'); + +describe('createCustomFieldsUserActionBuilder ', () => { + const builderArgs = getMockBuilderArgs(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly when a custom field is updated', () => { + const userAction = getUserAction('customFields', UserActionActions.update); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + + render( + + + + ); + + expect( + screen.getByText('changed My test label 1 to "My text test value 1"') + ).toBeInTheDocument(); + }); + + it('renders correctly when a custom field is updated to an empty value: null', () => { + const userAction = getUserAction('customFields', UserActionActions.update, { + payload: { + customFields: [ + { + type: CustomFieldTypes.TEXT, + key: 'test_key_1', + value: null, + }, + ], + }, + }); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + + render( + + + + ); + + expect(screen.getByText('changed My test label 1 to "None"')).toBeInTheDocument(); + }); + + it('renders correctly when a custom field is updated to an empty value: empty array', () => { + const userAction = getUserAction('customFields', UserActionActions.update, { + payload: { + customFields: [ + { + type: CustomFieldTypes.TEXT, + key: 'test_key_1', + value: [], + }, + ], + }, + }); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction, + }); + + const createdUserAction = builder.build(); + + render( + + + + ); + + expect(screen.getByText('changed My test label 1 to "None"')).toBeInTheDocument(); + }); + + it('renders correctly the label when the configuration is not found', () => { + const userAction = getUserAction('customFields', UserActionActions.update); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction, + casesConfiguration: { ...builderArgs.casesConfiguration, customFields: [] }, + }); + + const createdUserAction = builder.build(); + + render( + + + + ); + + expect(screen.getByText('changed Unknown to "My text test value 1"')).toBeInTheDocument(); + }); + + it('does not build any user actions if the payload is an empty array', () => { + const userAction = getUserAction('customFields', UserActionActions.update); + + const builder = createCustomFieldsUserActionBuilder({ + ...builderArgs, + userAction: { + ...userAction, + type: UserActionTypes.customFields, + payload: { customFields: [] }, + }, + casesConfiguration: { ...builderArgs.casesConfiguration, customFields: [] }, + }); + + const createdUserAction = builder.build(); + expect(createdUserAction).toEqual([]); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx new file mode 100644 index 0000000000000..8f006ec157083 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/custom_fields/custom_fields.tsx @@ -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 type { CasesConfigurationUICustomField, CaseUICustomField } from '../../../../common/ui'; +import type { SnakeToCamelCase } from '../../../../common/types'; +import type { CustomFieldsUserAction } from '../../../../common/types/domain'; +import { createCommonUpdateUserActionBuilder } from '../common'; +import type { UserActionBuilder } from '../types'; +import * as i18n from '../translations'; + +const getLabelTitle = ( + customField: CaseUICustomField, + customFieldConfiguration?: CasesConfigurationUICustomField +) => { + const customFieldValue = customField.value; + const label = customFieldConfiguration?.label ?? customFieldConfiguration?.key ?? i18n.UNKNOWN; + + if ( + customFieldValue == null || + (Array.isArray(customFieldValue) && customFieldValue.length === 0) + ) { + return i18n.CHANGED_FIELD_TO_EMPTY(label); + } + + const value = Array.isArray(customFieldValue) ? customFieldValue[0] : customFieldValue; + + return `${i18n.CHANGED_FIELD.toLowerCase()} ${label} ${i18n.TO} "${value}"`; +}; + +export const createCustomFieldsUserActionBuilder: UserActionBuilder = ({ + userAction, + handleOutlineComment, + userProfiles, + casesConfiguration, +}) => ({ + build: () => { + const customFieldsUserAction = userAction as SnakeToCamelCase; + + if (customFieldsUserAction.payload.customFields.length === 0) { + return []; + } + + const customField = customFieldsUserAction.payload.customFields[0]; + const customFieldConfiguration = casesConfiguration.customFields.find( + (configCustomField) => configCustomField.key === customField.key + ); + + const label = getLabelTitle(customField, customFieldConfiguration); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + userProfiles, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/mock.ts b/x-pack/plugins/cases/public/components/user_actions/mock.ts index 90d4223db4f7d..427a5caf36c81 100644 --- a/x-pack/plugins/cases/public/components/user_actions/mock.ts +++ b/x-pack/plugins/cases/public/components/user_actions/mock.ts @@ -13,6 +13,7 @@ import { getCaseConnectorsMockResponse } from '../../common/mock/connectors'; import { basicCase, getUserAction } from '../../containers/mock'; import { userProfiles, userProfilesMap } from '../../containers/user_profiles/api.mock'; import type { UserActionBuilderArgs } from './types'; +import { casesConfigurationsMock } from '../../containers/configure/mock'; export const getMockBuilderArgs = (): UserActionBuilderArgs => { const userAction = getUserAction('title', UserActionActions.update); @@ -63,6 +64,7 @@ export const getMockBuilderArgs = (): UserActionBuilderArgs => { externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, caseData: basicCase, + casesConfiguration: casesConfigurationsMock, comments: basicCase.comments, index: 0, alertData, diff --git a/x-pack/plugins/cases/public/components/user_actions/types.ts b/x-pack/plugins/cases/public/components/user_actions/types.ts index fc9c81f15bb2b..8e1377c4f0f28 100644 --- a/x-pack/plugins/cases/public/components/user_actions/types.ts +++ b/x-pack/plugins/cases/public/components/user_actions/types.ts @@ -15,6 +15,7 @@ import type { AttachmentUI, UseFetchAlertData, CaseUserActionsStats, + CasesConfigurationUI, } from '../../containers/types'; import type { AddCommentRefObject } from '../add_comment'; import type { UserActionMarkdownRefObject } from './markdown_form'; @@ -31,6 +32,7 @@ export interface UserActionTreeProps { userProfiles: Map; currentUserProfile: CurrentUserProfile; data: CaseUI; + casesConfiguration: CasesConfigurationUI; getRuleDetailsHref?: RuleDetailsNavigation['href']; actionsNavigation?: ActionsNavigation; onRuleDetailsClick?: RuleDetailsNavigation['onClick']; @@ -51,6 +53,7 @@ export type SupportedUserActionTypes = keyof Omit< export interface UserActionBuilderArgs { appId?: string; caseData: CaseUI; + casesConfiguration: CasesConfigurationUI; userProfiles: Map; currentUserProfile: CurrentUserProfile; externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; diff --git a/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx index a46a73c811c91..3b21d68ac43af 100644 --- a/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/user_actions_list.tsx @@ -81,6 +81,7 @@ export const UserActionsList = React.memo( userProfiles, currentUserProfile, data: caseData, + casesConfiguration, getRuleDetailsHref, actionsNavigation, onRuleDetailsClick, @@ -129,6 +130,7 @@ export const UserActionsList = React.memo( const userActionBuilder = builder({ appId, caseData, + casesConfiguration, caseConnectors, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, @@ -156,14 +158,15 @@ export const UserActionsList = React.memo( return [...comments, ...userActionBuilder.build()]; }, []); }, [ + caseUserActions, appId, + caseData, + casesConfiguration, caseConnectors, - caseUserActions, - userProfiles, - currentUserProfile, externalReferenceAttachmentTypeRegistry, persistableStateAttachmentTypeRegistry, - caseData, + userProfiles, + currentUserProfile, commentRefs, manageMarkdownEditIds, selectedOutlineCommentId, diff --git a/x-pack/plugins/cases/public/components/utils.test.ts b/x-pack/plugins/cases/public/components/utils.test.ts index 79a86e00cd7a6..c430181e3b947 100644 --- a/x-pack/plugins/cases/public/components/utils.test.ts +++ b/x-pack/plugins/cases/public/components/utils.test.ts @@ -20,6 +20,7 @@ import { parseURL, stringifyToURL, parseCaseUsers, + convertCustomFieldValue, } from './utils'; describe('Utils', () => { @@ -505,4 +506,30 @@ describe('Utils', () => { ]); }); }); + + describe('convertCustomFieldValue ', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns array of string when value is string', async () => { + expect(convertCustomFieldValue('my text value')).toMatchInlineSnapshot(` + Array [ + "my text value", + ] + `); + }); + + it('returns null when value is empty string', async () => { + expect(convertCustomFieldValue('')).toMatchInlineSnapshot('null'); + }); + + it('returns value as it is when value is true', async () => { + expect(convertCustomFieldValue(true)).toMatchInlineSnapshot('true'); + }); + + it('returns value as it is when value is false', async () => { + expect(convertCustomFieldValue(false)).toMatchInlineSnapshot('false'); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/utils.ts b/x-pack/plugins/cases/public/components/utils.ts index 1b0fbcd43ca1b..471f8ff544ed7 100644 --- a/x-pack/plugins/cases/public/components/utils.ts +++ b/x-pack/plugins/cases/public/components/utils.ts @@ -6,6 +6,7 @@ */ import type { IconType } from '@elastic/eui'; +import { isEmpty } from 'lodash'; import type { FieldConfig, ValidationConfig, @@ -225,3 +226,15 @@ export const parseCaseUsers = ({ return { userProfiles, reporterAsArray }; }; + +export const convertCustomFieldValue = (value: string | boolean) => { + let fieldValue = null; + + if (!isEmpty(value) && typeof value === 'string') { + fieldValue = [value]; + } else if (typeof value === 'boolean') { + fieldValue = value; + } + + return fieldValue; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts index bac2713c75135..550e9769c7ddc 100644 --- a/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/__mocks__/api.ts @@ -7,9 +7,8 @@ import type { ActionConnector, ActionTypeConnector } from '../../../../common/types/domain'; -import type { ApiProps } from '../../types'; -import type { CaseConfigure } from '../types'; -import { caseConfigurationCamelCaseResponseMock } from '../mock'; +import type { ApiProps, CasesConfigurationUI } from '../../types'; +import { casesConfigurationsMock } from '../mock'; import { actionTypesMock, connectorsMock } from '../../../common/mock/connectors'; import type { ConfigurationPatchRequest, ConfigurationRequest } from '../../../../common/types/api'; @@ -17,18 +16,18 @@ export const getSupportedActionConnectors = async ({ signal, }: ApiProps): Promise => Promise.resolve(connectorsMock); -export const getCaseConfigure = async ({ signal }: ApiProps): Promise => - Promise.resolve(caseConfigurationCamelCaseResponseMock); +export const getCaseConfigure = async ({ signal }: ApiProps): Promise => + Promise.resolve(casesConfigurationsMock); export const postCaseConfigure = async ( caseConfiguration: ConfigurationRequest, signal: AbortSignal -): Promise => Promise.resolve(caseConfigurationCamelCaseResponseMock); +): Promise => Promise.resolve(casesConfigurationsMock); export const patchCaseConfigure = async ( caseConfiguration: ConfigurationPatchRequest, signal: AbortSignal -): Promise => Promise.resolve(caseConfigurationCamelCaseResponseMock); +): Promise => Promise.resolve(casesConfigurationsMock); export const fetchActionTypes = async ({ signal }: ApiProps): Promise => Promise.resolve(actionTypesMock); diff --git a/x-pack/plugins/cases/public/containers/configure/api.test.ts b/x-pack/plugins/cases/public/containers/configure/api.test.ts index 844a23ab17f2b..435feee55c895 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.test.ts @@ -13,9 +13,9 @@ import { fetchActionTypes, } from './api'; import { - caseConfigurationMock, - caseConfigurationResposeMock, - caseConfigurationCamelCaseResponseMock, + caseConfigurationRequest, + caseConfigurationResponseMock, + casesConfigurationsMock, } from './mock'; import { ConnectorTypes } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; @@ -53,7 +53,7 @@ describe('Case Configuration API', () => { describe('fetch configuration', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue([caseConfigurationResposeMock]); + fetchMock.mockResolvedValue([caseConfigurationResponseMock]); }); test('check url, method, signal', async () => { @@ -72,7 +72,7 @@ describe('Case Configuration API', () => { signal: abortCtrl.signal, owner: [SECURITY_SOLUTION_OWNER], }); - expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + expect(resp).toEqual(casesConfigurationsMock); }); test('return null on empty response', async () => { @@ -88,56 +88,46 @@ describe('Case Configuration API', () => { describe('create configuration', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue(caseConfigurationResposeMock); + fetchMock.mockResolvedValue(caseConfigurationResponseMock); }); test('check url, body, method, signal', async () => { - await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); + await postCaseConfigure(caseConfigurationRequest); expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure', { body: '{"connector":{"id":"123","name":"My connector","type":".jira","fields":null},"owner":"securitySolution","closure_type":"close-by-user"}', method: 'POST', - signal: abortCtrl.signal, }); }); test('happy path', async () => { - const resp = await postCaseConfigure(caseConfigurationMock, abortCtrl.signal); - expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + const resp = await postCaseConfigure(caseConfigurationRequest); + expect(resp).toEqual(casesConfigurationsMock); }); }); describe('update configuration', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue(caseConfigurationResposeMock); + fetchMock.mockResolvedValue(caseConfigurationResponseMock); }); test('check url, body, method, signal', async () => { - await patchCaseConfigure( - '123', - { - connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, - version: 'WzHJ12', - }, - abortCtrl.signal - ); + await patchCaseConfigure('123', { + connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, + version: 'WzHJ12', + }); expect(fetchMock).toHaveBeenCalledWith('/api/cases/configure/123', { body: '{"connector":{"id":"456","name":"My Connector 2","type":".none","fields":null},"version":"WzHJ12"}', method: 'PATCH', - signal: abortCtrl.signal, }); }); test('happy path', async () => { - const resp = await patchCaseConfigure( - '123', - { - connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, - version: 'WzHJ12', - }, - abortCtrl.signal - ); - expect(resp).toEqual(caseConfigurationCamelCaseResponseMock); + const resp = await patchCaseConfigure('123', { + connector: { id: '456', name: 'My Connector 2', type: ConnectorTypes.none, fields: null }, + version: 'WzHJ12', + }); + expect(resp).toEqual(casesConfigurationsMock); }); }); diff --git a/x-pack/plugins/cases/public/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts index f1d86cb090234..975cec77a3a1b 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -7,21 +7,26 @@ import { isEmpty } from 'lodash/fp'; import { CasesConnectorFeatureId } from '@kbn/actions-plugin/common'; -import type { ConfigurationPatchRequest, ConfigurationRequest } from '../../../common/types/api'; +import type { SnakeToCamelCase } from '../../../common/types'; +import type { + ConfigurationPatchRequest, + ConfigurationRequest, + CreateConfigureResponse, + GetConfigureResponse, + UpdateConfigureResponse, +} from '../../../common/types/api'; import type { ActionConnector, ActionTypeConnector, Configuration, - Configurations, } from '../../../common/types/domain'; import { getAllConnectorTypesUrl } from '../../../common/utils/connectors_api'; import { getCaseConfigurationDetailsUrl } from '../../../common/api'; import { CASE_CONFIGURE_CONNECTORS_URL, CASE_CONFIGURE_URL } from '../../../common/constants'; import { KibanaServices } from '../../common/lib/kibana'; import { convertToCamelCase, convertArrayToCamelCase } from '../../api/utils'; -import type { ApiProps } from '../types'; +import type { ApiProps, CasesConfigurationUI } from '../types'; import { decodeCaseConfigurationsResponse, decodeCaseConfigureResponse } from '../utils'; -import type { CaseConfigure } from './types'; export const getSupportedActionConnectors = async ({ signal, @@ -37,8 +42,8 @@ export const getSupportedActionConnectors = async ({ export const getCaseConfigure = async ({ signal, owner, -}: ApiProps & { owner: string[] }): Promise => { - const response = await KibanaServices.get().http.fetch(CASE_CONFIGURE_URL, { +}: ApiProps & { owner: string[] }): Promise => { + const response = await KibanaServices.get().http.fetch(CASE_CONFIGURE_URL, { method: 'GET', signal, query: { ...(owner.length > 0 ? { owner } : {}) }, @@ -47,7 +52,12 @@ export const getCaseConfigure = async ({ if (!isEmpty(response)) { const decodedConfigs = decodeCaseConfigurationsResponse(response); if (Array.isArray(decodedConfigs) && decodedConfigs.length > 0) { - return convertToCamelCase(decodedConfigs[0]); + const configuration = convertToCamelCase< + GetConfigureResponse[number], + SnakeToCamelCase + >(decodedConfigs[0]); + + return convertConfigureResponseToCasesConfigure(configuration); } } @@ -55,31 +65,42 @@ export const getCaseConfigure = async ({ }; export const postCaseConfigure = async ( - caseConfiguration: ConfigurationRequest, - signal: AbortSignal -): Promise => { - const response = await KibanaServices.get().http.fetch(CASE_CONFIGURE_URL, { - method: 'POST', - body: JSON.stringify(caseConfiguration), - signal, - }); - return convertToCamelCase(decodeCaseConfigureResponse(response)); + caseConfiguration: ConfigurationRequest +): Promise => { + const response = await KibanaServices.get().http.fetch( + CASE_CONFIGURE_URL, + { + method: 'POST', + body: JSON.stringify(caseConfiguration), + } + ); + + const configuration = convertToCamelCase< + CreateConfigureResponse, + SnakeToCamelCase + >(decodeCaseConfigureResponse(response)); + + return convertConfigureResponseToCasesConfigure(configuration); }; export const patchCaseConfigure = async ( id: string, - caseConfiguration: ConfigurationPatchRequest, - signal: AbortSignal -): Promise => { - const response = await KibanaServices.get().http.fetch( + caseConfiguration: ConfigurationPatchRequest +): Promise => { + const response = await KibanaServices.get().http.fetch( getCaseConfigurationDetailsUrl(id), { method: 'PATCH', body: JSON.stringify(caseConfiguration), - signal, } ); - return convertToCamelCase(decodeCaseConfigureResponse(response)); + + const configuration = convertToCamelCase< + UpdateConfigureResponse, + SnakeToCamelCase + >(decodeCaseConfigureResponse(response)); + + return convertConfigureResponseToCasesConfigure(configuration); }; export const fetchActionTypes = async ({ signal }: ApiProps): Promise => { @@ -90,3 +111,11 @@ export const fetchActionTypes = async ({ signal }: ApiProps): Promise +): CasesConfigurationUI => { + const { id, version, mappings, customFields, closureType, connector } = configuration; + + return { id, version, mappings, customFields, closureType, connector }; +}; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index b01b6196a78b3..79e423e42db89 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -9,7 +9,9 @@ import type { ConfigurationRequest } from '../../../common/types/api'; import type { Configuration } from '../../../common/types/domain'; import { ConnectorTypes } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; -import type { CaseConfigure, CaseConnectorMapping } from './types'; +import type { CaseConnectorMapping } from './types'; +import type { CasesConfigurationUI } from '../types'; +import { customFieldsConfigurationMock } from '../mock'; export const mappings: CaseConnectorMapping[] = [ { @@ -29,7 +31,7 @@ export const mappings: CaseConnectorMapping[] = [ }, ]; -export const caseConfigurationResposeMock: Configuration = { +export const caseConfigurationResponseMock: Configuration = { id: '123', created_at: '2020-04-06T13:03:18.657Z', created_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, @@ -46,9 +48,10 @@ export const caseConfigurationResposeMock: Configuration = { updated_by: { username: 'elastic', full_name: 'Elastic', email: 'elastic@elastic.co' }, owner: SECURITY_SOLUTION_OWNER, version: 'WzHJ12', + customFields: customFieldsConfigurationMock, }; -export const caseConfigurationMock: ConfigurationRequest = { +export const caseConfigurationRequest: ConfigurationRequest = { connector: { id: '123', name: 'My connector', @@ -59,10 +62,8 @@ export const caseConfigurationMock: ConfigurationRequest = { closure_type: 'close-by-user', }; -export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { +export const casesConfigurationsMock: CasesConfigurationUI = { id: '123', - createdAt: '2020-04-06T13:03:18.657Z', - createdBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, connector: { id: '123', name: 'My connector', @@ -70,10 +71,7 @@ export const caseConfigurationCamelCaseResponseMock: CaseConfigure = { fields: null, }, closureType: 'close-by-pushing', - error: null, mappings: [], - updatedAt: '2020-04-06T14:03:18.657Z', - updatedBy: { username: 'elastic', fullName: 'Elastic', email: 'elastic@elastic.co' }, version: 'WzHJ12', - owner: SECURITY_SOLUTION_OWNER, + customFields: customFieldsConfigurationMock, }; diff --git a/x-pack/plugins/cases/public/containers/configure/types.ts b/x-pack/plugins/cases/public/containers/configure/types.ts index 4c9b7368d5bd0..5019b63c076b0 100644 --- a/x-pack/plugins/cases/public/containers/configure/types.ts +++ b/x-pack/plugins/cases/public/containers/configure/types.ts @@ -7,15 +7,14 @@ import type { ClosureType, - ConfigurationAttributes, ActionConnector, ActionTypeConnector, CaseConnector, ConnectorMappingTarget, ConnectorMappingSource, ConnectorMappingActionType, + CustomFieldsConfiguration, } from '../../../common/types/domain'; -import type { CaseUser } from '../types'; export type { ActionConnector, @@ -25,6 +24,7 @@ export type { ConnectorMappingSource, ConnectorMappingTarget, ClosureType, + CustomFieldsConfiguration, }; export interface CaseConnectorMapping { @@ -32,17 +32,3 @@ export interface CaseConnectorMapping { source: ConnectorMappingSource; target: string; } - -export interface CaseConfigure { - id: string; - closureType: ClosureType; - connector: ConfigurationAttributes['connector']; - createdAt: string; - createdBy: CaseUser; - error: string | null; - mappings: CaseConnectorMapping[]; - updatedAt: string; - updatedBy: CaseUser; - version: string; - owner: string; -} diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx deleted file mode 100644 index 8919c829585cf..0000000000000 --- a/x-pack/plugins/cases/public/containers/configure/use_configure.test.tsx +++ /dev/null @@ -1,354 +0,0 @@ -/* - * 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 { renderHook, act } from '@testing-library/react-hooks'; -import type { ReturnUseCaseConfigure, ConnectorConfiguration } from './use_configure'; -import { initialState, useCaseConfigure } from './use_configure'; -import { mappings, caseConfigurationCamelCaseResponseMock } from './mock'; -import * as api from './api'; -import { ConnectorTypes } from '../../../common/types/domain'; -import { TestProviders } from '../../common/mock'; - -const mockErrorToast = jest.fn(); -const mockSuccessToast = jest.fn(); -jest.mock('./api'); -jest.mock('../../common/lib/kibana', () => { - const originalModule = jest.requireActual('../../common/lib/kibana'); - return { - ...originalModule, - useToasts: () => { - return { - addError: mockErrorToast, - addSuccess: mockSuccessToast, - }; - }, - }; -}); -const configuration: ConnectorConfiguration = { - connector: { - id: '456', - name: 'My connector 2', - type: ConnectorTypes.none, - fields: null, - }, - closureType: 'close-by-pushing', -}; - -describe('useConfigure', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - test('init', async () => { - const { result } = renderHook(() => useCaseConfigure(), { - wrapper: ({ children }) => {children}, - }); - - await act(async () => - expect(result.current).toEqual({ - ...initialState, - refetchCaseConfigure: result.current.refetchCaseConfigure, - persistCaseConfigure: result.current.persistCaseConfigure, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setConnector: result.current.setConnector, - setClosureType: result.current.setClosureType, - setMappings: result.current.setMappings, - }) - ); - }); - - test('fetch case configuration', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(result.current).toEqual({ - ...initialState, - closureType: caseConfigurationCamelCaseResponseMock.closureType, - connector: caseConfigurationCamelCaseResponseMock.connector, - currentConfiguration: { - closureType: caseConfigurationCamelCaseResponseMock.closureType, - connector: caseConfigurationCamelCaseResponseMock.connector, - }, - mappings: [], - firstLoad: true, - loading: false, - persistCaseConfigure: result.current.persistCaseConfigure, - refetchCaseConfigure: result.current.refetchCaseConfigure, - setClosureType: result.current.setClosureType, - setConnector: result.current.setConnector, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setMappings: result.current.setMappings, - version: caseConfigurationCamelCaseResponseMock.version, - id: caseConfigurationCamelCaseResponseMock.id, - }); - }); - }); - - test('refetch case configuration', async () => { - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - result.current.refetchCaseConfigure(); - expect(spyOnGetCaseConfigure).toHaveBeenCalledTimes(2); - }); - }); - - test('correctly sets mappings', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(result.current.mappings).toEqual([]); - result.current.setMappings(mappings); - expect(result.current.mappings).toEqual(mappings); - }); - }); - - test('set isLoading to true when fetching case configuration', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - result.current.refetchCaseConfigure(); - - expect(result.current.loading).toBe(true); - }); - }); - - test('persist case configuration', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - result.current.persistCaseConfigure(configuration); - expect(result.current.persistLoading).toBeTruthy(); - }); - }); - - test('save case configuration - postCaseConfigure', async () => { - // When there is no version, a configuration is created. Otherwise is updated. - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - version: '', - }) - ); - - const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); - spyOnPostCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - ...configuration, - }) - ); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(mockErrorToast).not.toHaveBeenCalled(); - - result.current.persistCaseConfigure(configuration); - - expect(result.current.connector.id).toEqual('123'); - await waitForNextUpdate(); - expect(result.current.connector.id).toEqual('456'); - }); - }); - - test('Displays error when present - getCaseConfigure', async () => { - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - error: 'uh oh homeboy', - version: '', - }) - ); - - await act(async () => { - const { waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(mockErrorToast).toHaveBeenCalled(); - }); - }); - - test('Displays error when present - postCaseConfigure', async () => { - // When there is no version, a configuration is created. Otherwise is updated. - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - version: '', - }) - ); - - const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); - spyOnPostCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - ...configuration, - error: 'uh oh homeboy', - }) - ); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - expect(mockErrorToast).not.toHaveBeenCalled(); - - result.current.persistCaseConfigure(configuration); - expect(mockErrorToast).not.toHaveBeenCalled(); - await waitForNextUpdate(); - expect(mockErrorToast).toHaveBeenCalled(); - }); - }); - - test('save case configuration - patchCaseConfigure', async () => { - const spyOnPatchCaseConfigure = jest.spyOn(api, 'patchCaseConfigure'); - spyOnPatchCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - ...configuration, - }) - ); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - await waitForNextUpdate(); - - result.current.persistCaseConfigure(configuration); - - expect(result.current.connector.id).toEqual('123'); - await waitForNextUpdate(); - expect(result.current.connector.id).toEqual('456'); - }); - }); - - test('unhappy path - fetch case configuration', async () => { - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => { - throw new Error('Something went wrong'); - }); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - - await waitForNextUpdate(); - - expect(result.current).toEqual({ - ...initialState, - loading: false, - persistCaseConfigure: result.current.persistCaseConfigure, - persistLoading: false, - refetchCaseConfigure: result.current.refetchCaseConfigure, - setClosureType: result.current.setClosureType, - setConnector: result.current.setConnector, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setMappings: result.current.setMappings, - }); - }); - }); - - test('unhappy path - persist case configuration', async () => { - const spyOnGetCaseConfigure = jest.spyOn(api, 'getCaseConfigure'); - spyOnGetCaseConfigure.mockImplementation(() => - Promise.resolve({ - ...caseConfigurationCamelCaseResponseMock, - version: '', - id: '', - }) - ); - const spyOnPostCaseConfigure = jest.spyOn(api, 'postCaseConfigure'); - spyOnPostCaseConfigure.mockImplementation(() => { - throw new Error('Something went wrong'); - }); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook( - () => useCaseConfigure(), - { - wrapper: ({ children }) => {children}, - } - ); - - await waitForNextUpdate(); - - result.current.persistCaseConfigure(configuration); - - expect(result.current).toEqual({ - ...initialState, - closureType: caseConfigurationCamelCaseResponseMock.closureType, - connector: caseConfigurationCamelCaseResponseMock.connector, - currentConfiguration: { - closureType: caseConfigurationCamelCaseResponseMock.closureType, - connector: caseConfigurationCamelCaseResponseMock.connector, - }, - firstLoad: true, - loading: false, - mappings: [], - persistCaseConfigure: result.current.persistCaseConfigure, - refetchCaseConfigure: result.current.refetchCaseConfigure, - setClosureType: result.current.setClosureType, - setConnector: result.current.setConnector, - setCurrentConfiguration: result.current.setCurrentConfiguration, - setMappings: result.current.setMappings, - }); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/containers/configure/use_configure.tsx b/x-pack/plugins/cases/public/containers/configure/use_configure.tsx deleted file mode 100644 index 12168b1b9c5c5..0000000000000 --- a/x-pack/plugins/cases/public/containers/configure/use_configure.tsx +++ /dev/null @@ -1,389 +0,0 @@ -/* - * 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 { useEffect, useCallback, useReducer, useRef } from 'react'; -import { ConnectorTypes } from '../../../common/types/domain'; -import { getCaseConfigure, patchCaseConfigure, postCaseConfigure } from './api'; - -import * as i18n from './translations'; -import type { ClosureType, CaseConfigure, CaseConnector, CaseConnectorMapping } from './types'; -import { useToasts } from '../../common/lib/kibana'; -import { useCasesContext } from '../../components/cases_context/use_cases_context'; - -export type ConnectorConfiguration = { connector: CaseConnector } & { - closureType: CaseConfigure['closureType']; -}; - -export interface State extends ConnectorConfiguration { - currentConfiguration: ConnectorConfiguration; - firstLoad: boolean; - loading: boolean; - mappings: CaseConnectorMapping[]; - persistLoading: boolean; - version: string; - id: string; -} -export type Action = - | { - type: 'setCurrentConfiguration'; - currentConfiguration: ConnectorConfiguration; - } - | { - type: 'setConnector'; - connector: CaseConnector; - } - | { - type: 'setLoading'; - payload: boolean; - } - | { - type: 'setFirstLoad'; - payload: boolean; - } - | { - type: 'setPersistLoading'; - payload: boolean; - } - | { - type: 'setVersion'; - payload: string; - } - | { - type: 'setID'; - payload: string; - } - | { - type: 'setClosureType'; - closureType: ClosureType; - } - | { - type: 'setMappings'; - mappings: CaseConnectorMapping[]; - }; - -export const configureCasesReducer = (state: State, action: Action) => { - switch (action.type) { - case 'setLoading': - return { - ...state, - loading: action.payload, - }; - case 'setFirstLoad': - return { - ...state, - firstLoad: action.payload, - }; - case 'setPersistLoading': - return { - ...state, - persistLoading: action.payload, - }; - case 'setVersion': - return { - ...state, - version: action.payload, - }; - case 'setID': - return { - ...state, - id: action.payload, - }; - case 'setCurrentConfiguration': { - return { - ...state, - currentConfiguration: { ...action.currentConfiguration }, - }; - } - case 'setConnector': { - return { - ...state, - connector: action.connector, - }; - } - case 'setClosureType': { - return { - ...state, - closureType: action.closureType, - }; - } - case 'setMappings': { - return { - ...state, - mappings: action.mappings, - }; - } - default: - return state; - } -}; - -export interface ReturnUseCaseConfigure extends State { - persistCaseConfigure: ({ connector, closureType }: ConnectorConfiguration) => unknown; - refetchCaseConfigure: () => void; - setClosureType: (closureType: ClosureType) => void; - setConnector: (connector: CaseConnector) => void; - setCurrentConfiguration: (configuration: ConnectorConfiguration) => void; - setMappings: (newMapping: CaseConnectorMapping[]) => void; -} - -export const initialState: State = { - closureType: 'close-by-user', - connector: { - fields: null, - id: 'none', - name: 'none', - type: ConnectorTypes.none, - }, - currentConfiguration: { - closureType: 'close-by-user', - connector: { - fields: null, - id: 'none', - name: 'none', - type: ConnectorTypes.none, - }, - }, - firstLoad: false, - loading: true, - mappings: [], - persistLoading: false, - version: '', - id: '', -}; - -export const useCaseConfigure = (): ReturnUseCaseConfigure => { - const { owner } = useCasesContext(); - const [state, dispatch] = useReducer(configureCasesReducer, initialState); - const toasts = useToasts(); - const setCurrentConfiguration = useCallback((configuration: ConnectorConfiguration) => { - dispatch({ - currentConfiguration: configuration, - type: 'setCurrentConfiguration', - }); - }, []); - - const setConnector = useCallback((connector: CaseConnector) => { - dispatch({ - connector, - type: 'setConnector', - }); - }, []); - - const setClosureType = useCallback((closureType: ClosureType) => { - dispatch({ - closureType, - type: 'setClosureType', - }); - }, []); - - const setMappings = useCallback((mappings: CaseConnectorMapping[]) => { - dispatch({ - mappings, - type: 'setMappings', - }); - }, []); - - const setLoading = useCallback((isLoading: boolean) => { - dispatch({ - payload: isLoading, - type: 'setLoading', - }); - }, []); - - const setFirstLoad = useCallback((isFirstLoad: boolean) => { - dispatch({ - payload: isFirstLoad, - type: 'setFirstLoad', - }); - }, []); - - const setPersistLoading = useCallback((isPersistLoading: boolean) => { - dispatch({ - payload: isPersistLoading, - type: 'setPersistLoading', - }); - }, []); - - const setVersion = useCallback((version: string) => { - dispatch({ - payload: version, - type: 'setVersion', - }); - }, []); - - const setID = useCallback((id: string) => { - dispatch({ - payload: id, - type: 'setID', - }); - }, []); - - const isCancelledRefetchRef = useRef(false); - const abortCtrlRefetchRef = useRef(new AbortController()); - - const isCancelledPersistRef = useRef(false); - const abortCtrlPersistRef = useRef(new AbortController()); - - const refetchCaseConfigure = useCallback(async () => { - try { - isCancelledRefetchRef.current = false; - abortCtrlRefetchRef.current.abort(); - abortCtrlRefetchRef.current = new AbortController(); - - setLoading(true); - const res = await getCaseConfigure({ - signal: abortCtrlRefetchRef.current.signal, - owner, - }); - - if (!isCancelledRefetchRef.current) { - if (res != null) { - setConnector(res.connector); - if (setClosureType != null) { - setClosureType(res.closureType); - } - setVersion(res.version); - setID(res.id); - setMappings(res.mappings); - - if (!state.firstLoad) { - setFirstLoad(true); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, - }, - }); - } - } - if (res.error != null) { - toasts.addError(new Error(res.error), { - title: i18n.ERROR_TITLE, - }); - } - } - setLoading(false); - } - } catch (error) { - if (!isCancelledRefetchRef.current) { - if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); - } - setLoading(false); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.firstLoad]); - - const persistCaseConfigure = useCallback( - async ({ connector, closureType }: ConnectorConfiguration) => { - try { - isCancelledPersistRef.current = false; - abortCtrlPersistRef.current.abort(); - abortCtrlPersistRef.current = new AbortController(); - setPersistLoading(true); - - const connectorObj = { - connector, - closure_type: closureType, - }; - - const res = - state.version.length === 0 - ? await postCaseConfigure( - // The first owner will be used for case creation - { ...connectorObj, owner: owner[0] }, - abortCtrlPersistRef.current.signal - ) - : await patchCaseConfigure( - state.id, - { - ...connectorObj, - version: state.version, - }, - abortCtrlPersistRef.current.signal - ); - if (!isCancelledPersistRef.current) { - setConnector(res.connector); - if (setClosureType) { - setClosureType(res.closureType); - } - setVersion(res.version); - setID(res.id); - setMappings(res.mappings); - if (setCurrentConfiguration != null) { - setCurrentConfiguration({ - closureType: res.closureType, - connector: { - ...res.connector, - }, - }); - } - if (res.error != null) { - toasts.addError(new Error(res.error), { - title: i18n.ERROR_TITLE, - }); - } - toasts.addSuccess(i18n.SUCCESS_CONFIGURE); - setPersistLoading(false); - } - } catch (error) { - if (!isCancelledPersistRef.current) { - if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { - title: i18n.ERROR_TITLE, - } - ); - } - setConnector(state.currentConfiguration.connector); - setPersistLoading(false); - } - } - }, - [ - setPersistLoading, - state.version, - state.id, - state.currentConfiguration.connector, - owner, - setConnector, - setClosureType, - setVersion, - setID, - setMappings, - setCurrentConfiguration, - toasts, - ] - ); - - useEffect(() => { - refetchCaseConfigure(); - return () => { - isCancelledRefetchRef.current = true; - abortCtrlRefetchRef.current.abort(); - isCancelledPersistRef.current = true; - abortCtrlPersistRef.current.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { - ...state, - refetchCaseConfigure, - persistCaseConfigure, - setCurrentConfiguration, - setConnector, - setClosureType, - setMappings, - }; -}; diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.test.tsx new file mode 100644 index 0000000000000..1cb8685e26cbf --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.test.tsx @@ -0,0 +1,113 @@ +/* + * 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-hooks'; +import { useGetCaseConfiguration } from './use_get_case_configuration'; +import * as api from './api'; +import { waitFor } from '@testing-library/dom'; +import { useToasts } from '../../common/lib/kibana'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +jest.mock('./api'); +jest.mock('../../common/lib/kibana'); + +describe('Use get case configuration hook', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('calls the api when invoked with the correct parameters', async () => { + const spy = jest.spyOn(api, 'getCaseConfigure'); + + const { waitForNextUpdate } = renderHook(() => useGetCaseConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + await waitForNextUpdate(); + + expect(spy).toHaveBeenCalledWith({ + owner: ['securitySolution'], + signal: expect.any(AbortSignal), + }); + }); + + it('shows a toast error when the api return an error', async () => { + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addError }); + + const spy = jest.spyOn(api, 'getCaseConfigure').mockRejectedValue(new Error('error')); + + const { waitForNextUpdate } = renderHook(() => useGetCaseConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + await waitForNextUpdate(); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith({ + owner: ['securitySolution'], + signal: expect.any(AbortSignal), + }); + + expect(addError).toHaveBeenCalled(); + }); + }); + + it('returns the default if the response is null', async () => { + const spy = jest.spyOn(api, 'getCaseConfigure'); + spy.mockResolvedValue(null); + + const { result, waitForNextUpdate } = renderHook(() => useGetCaseConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + await waitForNextUpdate(); + + expect(result.current.data).toEqual({ + closureType: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + id: '', + mappings: [], + version: '', + }); + }); + + it('sets the initial data correctly', async () => { + const spy = jest.spyOn(api, 'getCaseConfigure'); + // @ts-expect-error: no need to define all properties + spy.mockResolvedValue({ id: 'my-new-configuration' }); + + const { result, waitForNextUpdate } = renderHook(() => useGetCaseConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + await waitForNextUpdate(); + + /** + * Ensures that the initial data are returned + * before fetching + */ + // @ts-expect-error: data are defined + expect(result.all[0].data).toEqual({ + closureType: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + id: '', + mappings: [], + version: '', + }); + + /** + * The response after fetching + */ + // @ts-expect-error: data are defined + expect(result.all[1].data).toEqual({ id: 'my-new-configuration' }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.tsx b/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.tsx new file mode 100644 index 0000000000000..9cb839587d2a1 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_get_case_configuration.tsx @@ -0,0 +1,57 @@ +/* + * 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 { useQuery } from '@tanstack/react-query'; +import { ConnectorTypes } from '../../../common'; +import * as i18n from './translations'; +import { getCaseConfigure } from './api'; +import type { ServerError } from '../../types'; +import { casesQueriesKeys } from '../constants'; +import type { CasesConfigurationUI } from '../types'; +import { useCasesToast } from '../../common/use_cases_toast'; +import { useCasesContext } from '../../components/cases_context/use_cases_context'; + +const initialConfiguration: CasesConfigurationUI = { + closureType: 'close-by-user', + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + mappings: [], + version: '', + id: '', +}; + +const transformConfiguration = (data: CasesConfigurationUI | null): CasesConfigurationUI => { + if (data) { + return data; + } + + return initialConfiguration; +}; + +export const useGetCaseConfiguration = () => { + const { owner } = useCasesContext(); + const { showErrorToast } = useCasesToast(); + + return useQuery( + casesQueriesKeys.configuration(), + ({ signal }) => getCaseConfigure({ owner, signal }), + { + select: transformConfiguration, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + initialData: initialConfiguration, + } + ); +}; + +export type UseGetCaseConfiguration = ReturnType; diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx new file mode 100644 index 0000000000000..b8a4ee93be1f8 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { usePersistConfiguration } from './use_persist_configuration'; +import * as api from './api'; +import { useToasts } from '../../common/lib/kibana'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { ConnectorTypes } from '../../../common'; +import { casesQueriesKeys } from '../constants'; + +jest.mock('./api'); +jest.mock('../../common/lib/kibana'); + +const useToastMock = useToasts as jest.Mock; + +describe('useCreateAttachments', () => { + const addError = jest.fn(); + const addSuccess = jest.fn(); + + useToastMock.mockReturnValue({ + addError, + addSuccess, + }); + + const request = { + closureType: 'close-by-user' as const, + connector: { + fields: null, + id: 'none', + name: 'none', + type: ConnectorTypes.none, + }, + customFields: [], + version: '', + id: '', + }; + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('calls postCaseConfigure when the id is empty', async () => { + const spyPost = jest.spyOn(api, 'postCaseConfigure'); + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate({ ...request, version: 'test' }); + }); + + await waitForNextUpdate(); + + expect(spyPatch).not.toHaveBeenCalled(); + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + owner: 'securitySolution', + }); + }); + + it('calls postCaseConfigure when the version is empty', async () => { + const spyPost = jest.spyOn(api, 'postCaseConfigure'); + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate({ ...request, id: 'test' }); + }); + + await waitForNextUpdate(); + + expect(spyPatch).not.toHaveBeenCalled(); + expect(spyPost).toHaveBeenCalledWith({ + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + owner: 'securitySolution', + }); + }); + + it('calls patchCaseConfigure when the id and the version are not empty', async () => { + const spyPost = jest.spyOn(api, 'postCaseConfigure'); + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate({ ...request, id: 'test-id', version: 'test-version' }); + }); + + await waitForNextUpdate(); + + expect(spyPost).not.toHaveBeenCalled(); + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: [], + version: 'test-version', + }); + }); + + it('invalidates the queries correctly', async () => { + const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(request); + }); + + await waitForNextUpdate(); + + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.configuration()); + }); + + it('shows the success toaster', async () => { + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(request); + }); + + await waitForNextUpdate(); + + expect(addSuccess).toHaveBeenCalled(); + }); + + it('shows a toast error when the api return an error', async () => { + jest + .spyOn(api, 'postCaseConfigure') + .mockRejectedValue(new Error('useCreateAttachments: Test error')); + + const { waitForNextUpdate, result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(request); + }); + + await waitForNextUpdate(); + + expect(addError).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx new file mode 100644 index 0000000000000..85615f07954ad --- /dev/null +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx @@ -0,0 +1,60 @@ +/* + * 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, useQueryClient } from '@tanstack/react-query'; +import { isEmpty } from 'lodash'; +import { postCaseConfigure, patchCaseConfigure } from './api'; +import * as i18n from './translations'; +import type { ServerError } from '../../types'; +import { useCasesToast } from '../../common/use_cases_toast'; +import { casesMutationsKeys, casesQueriesKeys } from '../constants'; +import { useCasesContext } from '../../components/cases_context/use_cases_context'; +import type { SnakeToCamelCase } from '../../../common/types'; +import type { ConfigurationRequest } from '../../../common/types/api'; + +type Request = Omit, 'owner'> & { + id: string; + version: string; +}; + +export const usePersistConfiguration = () => { + const queryClient = useQueryClient(); + const { owner } = useCasesContext(); + const { showErrorToast, showSuccessToast } = useCasesToast(); + + return useMutation( + ({ id, version, closureType, customFields, connector }: Request) => { + if (isEmpty(id) || isEmpty(version)) { + return postCaseConfigure({ + closure_type: closureType, + connector, + customFields: customFields ?? [], + owner: owner[0], + }); + } + + return patchCaseConfigure(id, { + version, + closure_type: closureType, + connector, + customFields: customFields ?? [], + }); + }, + { + mutationKey: casesMutationsKeys.persistCaseConfiguration, + onSuccess: () => { + queryClient.invalidateQueries(casesQueriesKeys.configuration()); + showSuccessToast(i18n.SUCCESS_CONFIGURE); + }, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + } + ); +}; + +export type UsePersistConfiguration = ReturnType; diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index 172f7ae67f084..7c39e76590fb4 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -47,6 +47,7 @@ export const casesQueriesKeys = { categories: () => [...casesQueriesKeys.all, 'categories'] as const, alertFeatureIds: (alertRegistrationContexts: string[]) => [...casesQueriesKeys.alerts, 'features', alertRegistrationContexts] as const, + configuration: () => [...casesQueriesKeys.all, 'configuration'] as const, }; export const casesMutationsKeys = { @@ -59,4 +60,5 @@ export const casesMutationsKeys = { deleteComment: ['delete-comment'] as const, deleteFileAttachment: ['delete-file-attachment'] as const, bulkCreateAttachments: ['bulk-create-attachments'] as const, + persistCaseConfiguration: ['persist-case-configuration'] as const, }; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 920ce0ec8b283..7c4265462b21c 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -25,6 +25,7 @@ import { ConnectorTypes, AttachmentType, ExternalReferenceStorageType, + CustomFieldTypes, } from '../../common/types/domain'; import type { ActionLicense, CaseUI, CasesStatus, UserActionUI } from './types'; @@ -42,6 +43,8 @@ import type { CasesFindResponseUI, CasesUI, AttachmentUI, + CaseUICustomField, + CasesConfigurationUICustomField, } from '../../common/ui/types'; import { CaseMetricsFeature } from '../../common/types/api'; import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; @@ -247,6 +250,7 @@ export const basicCase: CaseUI = { // damaged_raccoon uid assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], category: null, + customFields: [], }; export const basicFileMock: FileJSON = { @@ -364,6 +368,7 @@ export const mockCase: CaseUI = { }, assignees: [], category: null, + customFields: [], }; export const basicCasePost: CaseUI = { @@ -537,6 +542,7 @@ export const basicCaseSnake: Case = { updated_at: basicUpdatedAt, updated_by: elasticUserSnake, owner: SECURITY_SOLUTION_OWNER, + customFields: [], } as Case; export const caseWithAlertsSnake = { @@ -745,6 +751,15 @@ export const getUserAction = ( }, ...overrides, }; + case UserActionTypes.customFields: + return { + ...commonProperties, + type: UserActionTypes.customFields, + payload: { + customFields: customFieldsMock, + }, + ...overrides, + }; default: return { @@ -1131,3 +1146,13 @@ export const getCaseUsersMockResponse = (): CaseUsers => { ], }; }; + +export const customFieldsMock: CaseUICustomField[] = [ + { type: CustomFieldTypes.TEXT, key: 'test_key_1', value: ['My text test value 1'] }, + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', value: true }, +]; + +export const customFieldsConfigurationMock: CasesConfigurationUICustomField[] = [ + { type: CustomFieldTypes.TEXT, key: 'test_key_1', label: 'My test label 1', required: true }, + { type: CustomFieldTypes.TOGGLE, key: 'test_key_2', label: 'My test label 2', required: false }, +]; diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts index e2e0e6a7f8ec4..bbe91b1ad791e 100644 --- a/x-pack/plugins/cases/server/client/cases/client.ts +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -102,13 +102,13 @@ export const createCasesSubClient = ( casesClientInternal: CasesClientInternal ): CasesSubClient => { const casesSubClient: CasesSubClient = { - create: (data: CasePostRequest) => create(data, clientArgs), + create: (data: CasePostRequest) => create(data, clientArgs, casesClient), find: (params: CasesFindRequest) => find(params, clientArgs), get: (params: GetParams) => get(params, clientArgs), resolve: (params: GetParams) => resolve(params, clientArgs), bulkGet: (params) => bulkGet(params, clientArgs), push: (params: PushParams) => push(params, clientArgs, casesClient), - update: (cases: CasesPatchRequest) => update(cases, clientArgs), + update: (cases: CasesPatchRequest) => update(cases, clientArgs, casesClient), delete: (ids: string[]) => deleteCases(ids, clientArgs), getTags: (params: AllTagsFindRequest) => getTags(params, clientArgs), getCategories: (params: AllCategoriesFindRequest) => getCategories(params, clientArgs), diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts index b53d12980c78a..ae31b77058e1e 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -11,12 +11,22 @@ import { MAX_LENGTH_PER_TAG, MAX_TITLE_LENGTH, MAX_ASSIGNEES_PER_CASE, + MAX_CUSTOM_FIELDS_PER_CASE, } from '../../../common/constants'; +import type { CasePostRequest } from '../../../common'; import { SECURITY_SOLUTION_OWNER } from '../../../common'; import { mockCases } from '../../mocks'; -import { createCasesClientMockArgs } from '../mocks'; +import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; import { create } from './create'; -import { CaseSeverity, CaseStatuses, ConnectorTypes } from '../../../common/types/domain'; +import { + CaseSeverity, + CaseStatuses, + ConnectorTypes, + CustomFieldTypes, +} from '../../../common/types/domain'; + +import type { CaseCustomFields } from '../../../common/types/domain'; +import { omit } from 'lodash'; describe('create', () => { const theCase = { @@ -36,6 +46,8 @@ describe('create', () => { }; const caseSO = mockCases[0]; + const casesClientMock = createCasesClientMock(); + casesClientMock.configure.get = jest.fn().mockResolvedValue([]); describe('Assignees', () => { const clientArgs = createCasesClientMockArgs(); @@ -46,7 +58,7 @@ describe('create', () => { }); it('notifies single assignees', async () => { - await create(theCase, clientArgs); + await create(theCase, clientArgs, casesClientMock); expect(clientArgs.services.notificationService.notifyAssignees).toHaveBeenCalledWith({ assignees: theCase.assignees, @@ -55,7 +67,11 @@ describe('create', () => { }); it('notifies multiple assignees', async () => { - await create({ ...theCase, assignees: [{ uid: '1' }, { uid: '2' }] }, clientArgs); + await create( + { ...theCase, assignees: [{ uid: '1' }, { uid: '2' }] }, + clientArgs, + casesClientMock + ); expect(clientArgs.services.notificationService.notifyAssignees).toHaveBeenCalledWith({ assignees: [{ uid: '1' }, { uid: '2' }], @@ -64,7 +80,7 @@ describe('create', () => { }); it('does not notify when there are no assignees', async () => { - await create({ ...theCase, assignees: [] }, clientArgs); + await create({ ...theCase, assignees: [] }, clientArgs, casesClientMock); expect(clientArgs.services.notificationService.notifyAssignees).not.toHaveBeenCalled(); }); @@ -75,7 +91,8 @@ describe('create', () => { ...theCase, assignees: [{ uid: '1' }, { uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.notificationService.notifyAssignees).toHaveBeenCalledWith({ @@ -87,7 +104,7 @@ describe('create', () => { it('should throw an error if the assignees array length is too long', async () => { const assignees = Array(MAX_ASSIGNEES_PER_CASE + 1).fill({ uid: 'foo' }); - await expect(create({ ...theCase, assignees }, clientArgs)).rejects.toThrow( + await expect(create({ ...theCase, assignees }, clientArgs, casesClientMock)).rejects.toThrow( `Failed to create case: Error: The length of the field assignees is too long. Array must be of length <= ${MAX_ASSIGNEES_PER_CASE}.` ); }); @@ -104,7 +121,7 @@ describe('create', () => { it('should throw an error when an excess field exists', async () => { await expect( // @ts-expect-error foo is an invalid field - create({ ...theCase, foo: 'bar' }, clientArgs) + create({ ...theCase, foo: 'bar' }, clientArgs, casesClientMock) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Failed to create case: Error: invalid keys \\"foo\\""` ); @@ -121,7 +138,7 @@ describe('create', () => { it(`should not throw an error if the title is non empty and less than ${MAX_TITLE_LENGTH} characters`, async () => { await expect( - create({ ...theCase, title: 'This is a test case!!' }, clientArgs) + create({ ...theCase, title: 'This is a test case!!' }, clientArgs, casesClientMock) ).resolves.not.toThrow(); }); @@ -133,7 +150,8 @@ describe('create', () => { title: 'This is a very long title with more than one hundred and sixty characters!! To confirm the maximum limit error thrown for more than one hundred and sixty characters!!', }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to create case: Error: The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}.` @@ -141,19 +159,19 @@ describe('create', () => { }); it('should throw an error if the title is an empty string', async () => { - await expect(create({ ...theCase, title: '' }, clientArgs)).rejects.toThrow( + await expect(create({ ...theCase, title: '' }, clientArgs, casesClientMock)).rejects.toThrow( 'Failed to create case: Error: The title field cannot be an empty string.' ); }); it('should throw an error if the title is a string with empty characters', async () => { - await expect(create({ ...theCase, title: ' ' }, clientArgs)).rejects.toThrow( - 'Failed to create case: Error: The title field cannot be an empty string.' - ); + await expect( + create({ ...theCase, title: ' ' }, clientArgs, casesClientMock) + ).rejects.toThrow('Failed to create case: Error: The title field cannot be an empty string.'); }); it('should trim title', async () => { - await create({ ...theCase, title: 'title with spaces ' }, clientArgs); + await create({ ...theCase, title: 'title with spaces ' }, clientArgs, casesClientMock); expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( expect.objectContaining({ @@ -170,6 +188,7 @@ describe('create', () => { duration: null, status: CaseStatuses.open, category: null, + customFields: [], }, id: expect.any(String), refresh: false, @@ -188,7 +207,11 @@ describe('create', () => { it(`should not throw an error if the description is non empty and less than ${MAX_DESCRIPTION_LENGTH} characters`, async () => { await expect( - create({ ...theCase, description: 'This is a test description!!' }, clientArgs) + create( + { ...theCase, description: 'This is a test description!!' }, + clientArgs, + casesClientMock + ) ).resolves.not.toThrow(); }); @@ -197,19 +220,25 @@ describe('create', () => { .fill('x') .toString(); - await expect(create({ ...theCase, description }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, description }, clientArgs, casesClientMock) + ).rejects.toThrow( `Failed to create case: Error: The length of the description is too long. The maximum length is ${MAX_DESCRIPTION_LENGTH}.` ); }); it('should throw an error if the description is an empty string', async () => { - await expect(create({ ...theCase, description: '' }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, description: '' }, clientArgs, casesClientMock) + ).rejects.toThrow( 'Failed to create case: Error: The description field cannot be an empty string.' ); }); it('should throw an error if the description is a string with empty characters', async () => { - await expect(create({ ...theCase, description: ' ' }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, description: ' ' }, clientArgs, casesClientMock) + ).rejects.toThrow( 'Failed to create case: Error: The description field cannot be an empty string.' ); }); @@ -217,7 +246,8 @@ describe('create', () => { it('should trim description', async () => { await create( { ...theCase, description: 'this is a description with spaces!! ' }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( @@ -235,6 +265,7 @@ describe('create', () => { duration: null, status: CaseStatuses.open, category: null, + customFields: [], }, id: expect.any(String), refresh: false, @@ -252,31 +283,35 @@ describe('create', () => { }); it('should not throw an error if the tags array is empty', async () => { - await expect(create({ ...theCase, tags: [] }, clientArgs)).resolves.not.toThrow(); + await expect( + create({ ...theCase, tags: [] }, clientArgs, casesClientMock) + ).resolves.not.toThrow(); }); it('should not throw an error if the tags array has non empty string within limit', async () => { - await expect(create({ ...theCase, tags: ['abc'] }, clientArgs)).resolves.not.toThrow(); + await expect( + create({ ...theCase, tags: ['abc'] }, clientArgs, casesClientMock) + ).resolves.not.toThrow(); }); it('should throw an error if the tags array length is too long', async () => { const tags = Array(MAX_TAGS_PER_CASE + 1).fill('foo'); - await expect(create({ ...theCase, tags }, clientArgs)).rejects.toThrow( + await expect(create({ ...theCase, tags }, clientArgs, casesClientMock)).rejects.toThrow( `Failed to create case: Error: The length of the field tags is too long. Array must be of length <= ${MAX_TAGS_PER_CASE}.` ); }); it('should throw an error if the tags array has empty string', async () => { - await expect(create({ ...theCase, tags: [''] }, clientArgs)).rejects.toThrow( + await expect(create({ ...theCase, tags: [''] }, clientArgs, casesClientMock)).rejects.toThrow( 'Failed to create case: Error: The tag field cannot be an empty string.' ); }); it('should throw an error if the tags array has string with empty characters', async () => { - await expect(create({ ...theCase, tags: [' '] }, clientArgs)).rejects.toThrow( - 'Failed to create case: Error: The tag field cannot be an empty string.' - ); + await expect( + create({ ...theCase, tags: [' '] }, clientArgs, casesClientMock) + ).rejects.toThrow('Failed to create case: Error: The tag field cannot be an empty string.'); }); it('should throw an error if the tag length is too long', async () => { @@ -284,13 +319,15 @@ describe('create', () => { .fill('f') .toString(); - await expect(create({ ...theCase, tags: [tag] }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, tags: [tag] }, clientArgs, casesClientMock) + ).rejects.toThrow( `Failed to create case: Error: The length of the tag is too long. The maximum length is ${MAX_LENGTH_PER_TAG}.` ); }); it('should trim tags', async () => { - await create({ ...theCase, tags: ['pepsi ', 'coke'] }, clientArgs); + await create({ ...theCase, tags: ['pepsi ', 'coke'] }, clientArgs, casesClientMock); expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( expect.objectContaining({ @@ -307,6 +344,7 @@ describe('create', () => { duration: null, status: CaseStatuses.open, category: null, + customFields: [], }, id: expect.any(String), refresh: false, @@ -324,32 +362,39 @@ describe('create', () => { }); it('should not throw an error if the category is null', async () => { - await expect(create({ ...theCase, category: null }, clientArgs)).resolves.not.toThrow(); + await expect( + create({ ...theCase, category: null }, clientArgs, casesClientMock) + ).resolves.not.toThrow(); }); it('should throw an error if the category length is too long', async () => { await expect( create( { ...theCase, category: 'A very long category with more than fifty characters!' }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow('Failed to create case: Error: The length of the category is too long.'); }); it('should throw an error if the category is an empty string', async () => { - await expect(create({ ...theCase, category: '' }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, category: '' }, clientArgs, casesClientMock) + ).rejects.toThrow( 'Failed to create case: Error: The category field cannot be an empty string.,Invalid value "" supplied to "category"' ); }); it('should throw an error if the category is a string with empty characters', async () => { - await expect(create({ ...theCase, category: ' ' }, clientArgs)).rejects.toThrow( + await expect( + create({ ...theCase, category: ' ' }, clientArgs, casesClientMock) + ).rejects.toThrow( 'Failed to create case: Error: The category field cannot be an empty string.,Invalid value " " supplied to "category"' ); }); it('should trim category', async () => { - await create({ ...theCase, category: 'reporting ' }, clientArgs); + await create({ ...theCase, category: 'reporting ' }, clientArgs, casesClientMock); expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( expect.objectContaining({ @@ -365,6 +410,7 @@ describe('create', () => { external_service: null, duration: null, status: CaseStatuses.open, + customFields: [], }, id: expect.any(String), refresh: false, @@ -372,4 +418,376 @@ describe('create', () => { ); }); }); + + describe('Custom Fields', () => { + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + + const casesClient = createCasesClientMock(); + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + const theCustomFields: CaseCustomFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should create customFields correctly', async () => { + await expect( + create( + { + ...theCase, + customFields: theCustomFields, + }, + clientArgs, + casesClient + ) + ).resolves.not.toThrow(); + + expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + ...theCase, + closed_by: null, + closed_at: null, + category: null, + created_at: expect.any(String), + created_by: expect.any(Object), + updated_at: null, + updated_by: null, + external_service: null, + duration: null, + status: CaseStatuses.open, + customFields: theCustomFields, + }, + id: expect.any(String), + refresh: false, + }) + ); + }); + + it('should not throw an error and fill out missing customFields when they are undefined', async () => { + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + await expect(create({ ...theCase }, clientArgs, casesClient)).resolves.not.toThrow(); + + expect(clientArgs.services.caseService.postNewCase).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + ...theCase, + closed_by: null, + closed_at: null, + category: null, + created_at: expect.any(String), + created_by: expect.any(Object), + updated_at: null, + updated_by: null, + external_service: null, + duration: null, + status: CaseStatuses.open, + customFields: [ + { key: 'first_key', type: 'text', value: null }, + { key: 'second_key', type: 'toggle', value: null }, + ], + }, + id: expect.any(String), + refresh: false, + }) + ); + }); + + it('should throw an error when required customFields are undefined', async () => { + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: theCase.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + await expect( + create({ ...theCase }, clientArgs, casesClient) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: Missing required custom fields: first_key"` + ); + }); + + it('throws error when the customFields array is too long', async () => { + await expect( + create( + { + ...theCase, + customFields: Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill(theCustomFields[0]), + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: The length of the field customFields is too long. Array must be of length <= 10."` + ); + }); + + it('throws with duplicated customFields keys', async () => { + await expect( + create( + { + ...theCase, + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: Invalid duplicated custom field keys in request: duplicated_key"` + ); + }); + + it('throws error when customFields keys are not present in configuration', async () => { + await expect( + create( + { + ...theCase, + customFields: [ + { + key: 'missing_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: Invalid custom field keys: missing_key"` + ); + }); + + it('throws error when required custom fields are missing', async () => { + await expect( + create( + { + ...theCase, + customFields: [ + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: Missing required custom fields: first_key"` + ); + }); + + it('throws when the customField types do not match the configuration', async () => { + await expect( + create( + { + ...theCase, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + value: ['foobar'], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to create case: Error: The following custom fields have the wrong type in the request: first_key,second_key"` + ); + }); + }); + + describe('User actions', () => { + const caseWithOnlyRequiredFields = omit(theCase, [ + 'assignees', + 'category', + 'severity', + 'customFields', + ]) as CasePostRequest; + + const caseWithOptionalFields: CasePostRequest = { + ...theCase, + category: 'My category', + severity: CaseSeverity.CRITICAL, + customFields: [ + { + key: 'first_customField_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_customField_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const casesClient = createCasesClientMock(); + const clientArgs = createCasesClientMockArgs(); + clientArgs.services.caseService.postNewCase.mockResolvedValue(caseSO); + + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: caseWithOptionalFields.owner, + customFields: [ + { + key: 'first_customField_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_customField_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + + it('should create a user action with defaults correctly', async () => { + await create(caseWithOnlyRequiredFields, clientArgs, casesClient); + + expect(clientArgs.services.userActionService.creator.createUserAction).toHaveBeenCalledWith({ + caseId: 'mock-id-1', + owner: 'securitySolution', + payload: { + assignees: [], + category: null, + connector: { fields: null, id: '.none', name: 'None', type: '.none' }, + customFields: [], + description: 'testing sir', + owner: 'securitySolution', + settings: { syncAlerts: true }, + severity: 'low', + tags: [], + title: 'My Case', + }, + type: 'create_case', + user: { + email: 'damaged_raccoon@elastic.co', + full_name: 'Damaged Raccoon', + profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + username: 'damaged_raccoon', + }, + }); + }); + + it('should create a user action with optional fields set correctly', async () => { + await create(caseWithOptionalFields, clientArgs, casesClient); + + expect(clientArgs.services.userActionService.creator.createUserAction).toHaveBeenCalledWith({ + caseId: 'mock-id-1', + owner: 'securitySolution', + payload: { + assignees: [{ uid: '1' }], + category: 'My category', + connector: { fields: null, id: '.none', name: 'None', type: '.none' }, + customFields: caseWithOptionalFields.customFields, + description: 'testing sir', + owner: 'securitySolution', + settings: { syncAlerts: true }, + severity: 'critical', + tags: [], + title: 'My Case', + }, + type: 'create_case', + user: { + email: 'damaged_raccoon@elastic.co', + full_name: 'Damaged Raccoon', + profile_uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0', + username: 'damaged_raccoon', + }, + }); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/cases/create.ts b/x-pack/plugins/cases/server/client/cases/create.ts index f1ea52dcb45c9..d48eaa080dee8 100644 --- a/x-pack/plugins/cases/server/client/cases/create.ts +++ b/x-pack/plugins/cases/server/client/cases/create.ts @@ -16,18 +16,24 @@ import { decodeWithExcessOrThrow } from '../../../common/api'; import { Operations } from '../../authorization'; import { createCaseError } from '../../common/error'; import { flattenCaseSavedObject, transformNewCase } from '../../common/utils'; -import type { CasesClientArgs } from '..'; +import type { CasesClient, CasesClientArgs } from '..'; import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; import { decodeOrThrow } from '../../../common/api/runtime_types'; import type { CasePostRequest } from '../../../common/types/api'; import { CasePostRequestRt } from '../../../common/types/api'; +import {} from '../utils'; +import { validateCustomFields } from './validators'; +import { fillMissingCustomFields } from './utils'; /** * Creates a new case. * - * @ignore */ -export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs): Promise => { +export const create = async ( + data: CasePostRequest, + clientArgs: CasesClientArgs, + casesClient: CasesClient +): Promise => { const { services: { caseService, userActionService, licensingService, notificationService }, user, @@ -37,6 +43,15 @@ export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs) try { const query = decodeWithExcessOrThrow(CasePostRequestRt)(data); + const configurations = await casesClient.configure.get({ owner: data.owner }); + const customFieldsConfiguration = configurations[0]?.customFields; + + const customFieldsValidationParams = { + requestCustomFields: data.customFields, + customFieldsConfiguration, + }; + + validateCustomFields(customFieldsValidationParams); const savedObjectID = SavedObjectsUtils.generateId(); @@ -62,21 +77,27 @@ export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs) } /** - * Trim title, category, description and tags before saving to ES + * Trim title, category, description and tags + * and fill out missing custom fields + * before saving to ES */ - const trimmedQuery = { + const normalizedQuery = { ...query, title: query.title.trim(), description: query.description.trim(), category: query.category?.trim() ?? null, tags: query.tags?.map((tag) => tag.trim()) ?? [], + customFields: fillMissingCustomFields({ + customFields: query.customFields, + customFieldsConfiguration, + }), }; const newCase = await caseService.postNewCase({ attributes: transformNewCase({ user, - newCase: trimmedQuery, + newCase: normalizedQuery, }), id: savedObjectID, refresh: false, @@ -91,6 +112,7 @@ export const create = async (data: CasePostRequest, clientArgs: CasesClientArgs) severity: query.severity ?? CaseSeverity.LOW, assignees: query.assignees ?? [], category: query.category ?? null, + customFields: query.customFields ?? [], }, owner: newCase.attributes.owner, }); diff --git a/x-pack/plugins/cases/server/client/cases/update.test.ts b/x-pack/plugins/cases/server/client/cases/update.test.ts index f78e198ddeb52..a1427284c3c9a 100644 --- a/x-pack/plugins/cases/server/client/cases/update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/update.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CustomFieldTypes } from '../../../common/types/domain'; import { MAX_CATEGORY_LENGTH, MAX_DESCRIPTION_LENGTH, @@ -14,9 +15,10 @@ import { MAX_CASES_TO_UPDATE, MAX_USER_ACTIONS_PER_CASE, MAX_ASSIGNEES_PER_CASE, + MAX_CUSTOM_FIELDS_PER_CASE, } from '../../../common/constants'; import { mockCases } from '../../mocks'; -import { createCasesClientMockArgs } from '../mocks'; +import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; import { update } from './update'; describe('update', () => { @@ -29,6 +31,8 @@ describe('update', () => { }, ], }; + const casesClientMock = createCasesClientMock(); + casesClientMock.configure.get = jest.fn().mockResolvedValue([]); describe('Assignees', () => { const clientArgs = createCasesClientMockArgs(); @@ -49,7 +53,7 @@ describe('update', () => { }); it('notifies an assignee', async () => { - await update(cases, clientArgs); + await update(cases, clientArgs, casesClientMock); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ { @@ -76,7 +80,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"not-exists","version":"123"}]: Error: These cases not-exists do not exist. Please check you have the correct ids.' @@ -97,7 +102,7 @@ describe('update', () => { ], }); - await expect(update(cases, clientArgs)).rejects.toThrow( + await expect(update(cases, clientArgs, casesClientMock)).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: All update fields are identical to current version.' ); @@ -133,7 +138,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ @@ -174,7 +180,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([]); @@ -212,7 +219,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.notificationService.bulkNotifyAssignees).toHaveBeenCalledWith([ @@ -249,7 +257,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); /** @@ -274,7 +283,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: invalid keys \\"foo\\""` @@ -295,7 +305,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the field assignees is too long. Array must be of length <= 10.' @@ -333,7 +344,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -350,7 +362,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the category is too long. The maximum length is ${MAX_CATEGORY_LENGTH}.,Invalid value \"A very long category with more than fifty characters!\" supplied to \"cases,category\"` @@ -369,7 +382,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The category field cannot be an empty string.,Invalid value "" supplied to "cases,category"' @@ -388,7 +402,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The category field cannot be an empty string.,Invalid value " " supplied to "cases,category"' @@ -406,7 +421,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( @@ -461,7 +477,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -479,7 +496,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the title is too long. The maximum length is ${MAX_TITLE_LENGTH}.` @@ -498,7 +516,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The title field cannot be an empty string.' @@ -517,7 +536,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The title field cannot be an empty string.' @@ -535,7 +555,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( @@ -590,7 +611,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -611,7 +633,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the description is too long. The maximum length is ${MAX_DESCRIPTION_LENGTH}.` @@ -630,7 +653,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The description field cannot be an empty string.' @@ -649,7 +673,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The description field cannot be an empty string.' @@ -667,7 +692,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( @@ -722,7 +748,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -743,7 +770,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).resolves.not.toThrow(); }); @@ -762,7 +790,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the field tags is too long. Array must be of length <= ${MAX_TAGS_PER_CASE}.` @@ -785,7 +814,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( `Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The length of the tag is too long. The maximum length is ${MAX_LENGTH_PER_TAG}.` @@ -804,7 +834,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The tag field cannot be an empty string.' @@ -823,7 +854,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ) ).rejects.toThrow( 'Failed to update case, ids: [{"id":"mock-id-1","version":"WzAsMV0="}]: Error: The tag field cannot be an empty string.' @@ -841,7 +873,8 @@ describe('update', () => { }, ], }, - clientArgs + clientArgs, + casesClientMock ); expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( @@ -866,7 +899,302 @@ describe('update', () => { }); }); + describe('Custom Fields', () => { + const clientArgs = createCasesClientMockArgs(); + const casesClient = createCasesClientMock(); + + beforeEach(() => { + jest.clearAllMocks(); + clientArgs.services.caseService.getCases.mockResolvedValue({ saved_objects: mockCases }); + clientArgs.services.caseService.getAllCaseComments.mockResolvedValue({ + saved_objects: [], + total: 0, + per_page: 10, + page: 1, + }); + + casesClient.configure.get = jest.fn().mockResolvedValue([ + { + owner: mockCases[0].attributes.owner, + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ], + }, + ]); + }); + + it('can update customFields', async () => { + const customFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: null, + }, + ]; + + clientArgs.services.caseService.patchCases.mockResolvedValue({ + saved_objects: [{ ...mockCases[0] }], + }); + + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields, + }, + ], + }, + clientArgs, + casesClient + ) + ).resolves.not.toThrow(); + + expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( + expect.objectContaining({ + cases: [ + { + caseId: mockCases[0].id, + version: mockCases[0].version, + originalCase: { + ...mockCases[0], + }, + updatedAttributes: { + customFields, + updated_at: expect.any(String), + updated_by: expect.any(Object), + }, + }, + ], + refresh: false, + }) + ); + }); + + it('fills out missing custom fields', async () => { + const customFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + ]; + + clientArgs.services.caseService.patchCases.mockResolvedValue({ + saved_objects: [{ ...mockCases[0] }], + }); + + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields, + }, + ], + }, + clientArgs, + casesClient + ) + ).resolves.not.toThrow(); + + expect(clientArgs.services.caseService.patchCases).toHaveBeenCalledWith( + expect.objectContaining({ + cases: [ + { + caseId: mockCases[0].id, + version: mockCases[0].version, + originalCase: { + ...mockCases[0], + }, + updatedAttributes: { + customFields: [ + ...customFields, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: null, + }, + ], + updated_at: expect.any(String), + updated_by: expect.any(Object), + }, + }, + ], + refresh: false, + }) + ); + }); + + it('throws error when the customFields array is too long', async () => { + const customFields = Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'first_custom_field_key', + type: 'text', + value: ['this is a text field value', 'this is second'], + }); + + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields, + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: The length of the field customFields is too long. Array must be of length <= 10."` + ); + }); + + it('throws with duplicated customFields keys', async () => { + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + ], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid duplicated custom field keys in request: duplicated_key"` + ); + }); + + it('throws when customFields keys are not present in configuration', async () => { + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'missing_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Invalid custom field keys: missing_key"` + ); + }); + + it('throws error when custom fields are missing', async () => { + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields: [ + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: Missing required custom fields: first_key"` + ); + }); + + it('throws when the customField types dont match the configuration', async () => { + await expect( + update( + { + cases: [ + { + id: mockCases[0].id, + version: mockCases[0].version ?? '', + customFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + value: ['foobar'], + }, + ], + }, + ], + }, + clientArgs, + casesClient + ) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to update case, ids: [{\\"id\\":\\"mock-id-1\\",\\"version\\":\\"WzAsMV0=\\"}]: Error: The following custom fields have the wrong type in the request: first_key,second_key"` + ); + }); + }); + describe('Validation', () => { + const clientArgsMock = createCasesClientMockArgs(); + beforeEach(() => { jest.clearAllMocks(); }); @@ -881,7 +1209,8 @@ describe('update', () => { title: 'This is a test case!!', }), }, - createCasesClientMockArgs() + clientArgsMock, + casesClientMock ) ).rejects.toThrow( 'Error: The length of the field cases is too long. Array must be of length <= 100.' @@ -894,7 +1223,8 @@ describe('update', () => { { cases: [], }, - createCasesClientMockArgs() + clientArgsMock, + casesClientMock ) ).rejects.toThrow( 'Error: The length of the field cases is too short. Array must be of length >= 1.' @@ -902,14 +1232,12 @@ describe('update', () => { }); describe('Validate max user actions per page', () => { - const casesClient = createCasesClientMockArgs(); - beforeEach(() => { jest.clearAllMocks(); - casesClient.services.caseService.getCases.mockResolvedValue({ + clientArgsMock.services.caseService.getCases.mockResolvedValue({ saved_objects: [{ ...mockCases[0] }, { ...mockCases[1] }], }); - casesClient.services.caseService.getAllCaseComments.mockResolvedValue({ + clientArgsMock.services.caseService.getAllCaseComments.mockResolvedValue({ saved_objects: [], total: 0, per_page: 10, @@ -918,16 +1246,18 @@ describe('update', () => { }); it('passes validation if max user actions per case is not reached', async () => { - casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ - [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE - 1, - }); + clientArgsMock.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue( + { + [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE - 1, + } + ); // @ts-ignore: only the array length matters here - casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({ + clientArgsMock.services.userActionService.creator.buildUserActions.mockReturnValue({ [mockCases[0].id]: [1], }); - casesClient.services.caseService.patchCases.mockResolvedValue({ + clientArgsMock.services.caseService.patchCases.mockResolvedValue({ saved_objects: [{ ...mockCases[0] }], }); @@ -942,18 +1272,21 @@ describe('update', () => { }, ], }, - casesClient + clientArgsMock, + casesClientMock ) ).resolves.not.toThrow(); }); it(`throws an error when the user actions to be created will reach ${MAX_USER_ACTIONS_PER_CASE}`, async () => { - casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ - [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, - }); + clientArgsMock.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue( + { + [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, + } + ); // @ts-ignore: only the array length matters here - casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({ + clientArgsMock.services.userActionService.creator.buildUserActions.mockReturnValue({ [mockCases[0].id]: [1, 2, 3], }); @@ -968,7 +1301,8 @@ describe('update', () => { }, ], }, - casesClient + clientArgsMock, + casesClientMock ) ).rejects.toThrow( `Error: The case with case id ${mockCases[0].id} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` @@ -976,13 +1310,15 @@ describe('update', () => { }); it('throws an error when trying to update multiple cases and one of them is expected to fail', async () => { - casesClient.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue({ - [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, - [mockCases[1].id]: 0, - }); + clientArgsMock.services.userActionService.getMultipleCasesUserActionsTotal.mockResolvedValue( + { + [mockCases[0].id]: MAX_USER_ACTIONS_PER_CASE, + [mockCases[1].id]: 0, + } + ); // @ts-ignore: only the array length matters here - casesClient.services.userActionService.creator.buildUserActions.mockReturnValue({ + clientArgsMock.services.userActionService.creator.buildUserActions.mockReturnValue({ [mockCases[0].id]: [1, 2, 3], [mockCases[1].id]: [1], }); @@ -1004,7 +1340,8 @@ describe('update', () => { }, ], }, - casesClient + clientArgsMock, + casesClientMock ) ).rejects.toThrow( `Error: The case with case id ${mockCases[0].id} has reached the limit of ${MAX_USER_ACTIONS_PER_CASE} user actions.` diff --git a/x-pack/plugins/cases/server/client/cases/update.ts b/x-pack/plugins/cases/server/client/cases/update.ts index 64c0f170517fa..b2baff721302f 100644 --- a/x-pack/plugins/cases/server/client/cases/update.ts +++ b/x-pack/plugins/cases/server/client/cases/update.ts @@ -19,7 +19,7 @@ import { nodeBuilder } from '@kbn/es-query'; import type { AlertService, CasesService, CaseUserActionService } from '../../services'; import type { UpdateAlertStatusRequest } from '../alerts/types'; -import type { CasesClientArgs } from '..'; +import type { CasesClient, CasesClientArgs } from '..'; import type { OwnerEntity } from '../../authorization'; import type { PatchCasesArgs } from '../../services/cases/types'; import type { UserActionEvent, UserActionsDict } from '../../services/user_actions/types'; @@ -38,7 +38,12 @@ import { isCommentRequestTypeAlert, } from '../../common/utils'; import { arraysDifference, getCaseToUpdate } from '../utils'; -import { dedupAssignees, getClosedInfoForUpdate, getDurationForUpdate } from './utils'; +import { + dedupAssignees, + fillMissingCustomFields, + getClosedInfoForUpdate, + getDurationForUpdate, +} from './utils'; import { LICENSING_CASE_ASSIGNMENT_FEATURE } from '../../common/constants'; import type { LicensingService } from '../../services/licensing'; import type { CaseSavedObjectTransformed } from '../../common/types/case'; @@ -50,10 +55,12 @@ import type { User, CaseAssignees, AttachmentAttributes, + CustomFieldsConfiguration, } from '../../../common/types/domain'; import { CasesPatchRequestRt } from '../../../common/types/api'; import { decodeWithExcessOrThrow } from '../../../common/api'; import { CasesRt, CaseStatuses, AttachmentType } from '../../../common/types/domain'; +import { validateCustomFields } from './validators'; /** * Throws an error if any of the requests attempt to update the owner of a case. @@ -63,7 +70,7 @@ function throwIfUpdateOwner(requests: UpdateRequestWithOriginalCase[]) { if (requestsUpdatingOwner.length > 0) { const ids = requestsUpdatingOwner.map(({ updateReq }) => updateReq.id); - throw Boom.badRequest(`Updating the owner of a case is not allowed ids: [${ids.join(', ')}]`); + throw Boom.badRequest(`Updating the owner of a case is not allowed ids: [${ids.join(', ')}]`); } } @@ -97,6 +104,26 @@ async function throwIfMaxUserActionsReached({ }); } +async function validateCustomFieldsInRequest({ + casesToUpdate, + customFieldsConfigurationMap, +}: { + casesToUpdate: UpdateRequestWithOriginalCase[]; + customFieldsConfigurationMap: Map; +}) { + casesToUpdate.forEach(({ updateReq, originalCase }) => { + if (updateReq.customFields) { + const owner = originalCase.attributes.owner; + const customFieldsConfiguration = customFieldsConfigurationMap.get(owner); + + validateCustomFields({ + requestCustomFields: updateReq.customFields, + customFieldsConfiguration, + }); + } + }); +} + /** * Throws an error if any of the requests attempt to update the assignees of the case * without the appropriate license @@ -272,7 +299,7 @@ function partitionPatchRequest( }; } -interface UpdateRequestWithOriginalCase { +export interface UpdateRequestWithOriginalCase { updateReq: CasePatchRequest; originalCase: CaseSavedObjectTransformed; } @@ -284,7 +311,8 @@ interface UpdateRequestWithOriginalCase { */ export const update = async ( cases: CasesPatchRequest, - clientArgs: CasesClientArgs + clientArgs: CasesClientArgs, + casesClient: CasesClient ): Promise => { const { services: { @@ -301,7 +329,6 @@ export const update = async ( try { const query = decodeWithExcessOrThrow(CasesPatchRequestRt)(cases); - const myCases = await caseService.getCases({ caseIds: query.cases.map((q) => q.id), }); @@ -342,6 +369,11 @@ export const update = async ( ); } + const configurations = await casesClient.configure.get({}); + const customFieldsConfigurationMap: Map = new Map( + configurations.map((conf) => [conf.owner, conf.customFields]) + ); + const casesToUpdate: UpdateRequestWithOriginalCase[] = query.cases.reduce( (acc: UpdateRequestWithOriginalCase[], updateCase) => { const originalCase = casesMap.get(updateCase.id); @@ -372,7 +404,13 @@ export const update = async ( throwIfUpdateOwner(casesToUpdate); throwIfUpdateAssigneesWithoutValidLicense(casesToUpdate, hasPlatinumLicense); - const patchCasesPayload = createPatchCasesPayload({ user, casesToUpdate }); + await validateCustomFieldsInRequest({ casesToUpdate, customFieldsConfigurationMap }); + + const patchCasesPayload = createPatchCasesPayload({ + user, + casesToUpdate, + customFieldsConfigurationMap, + }); const userActionsDict = userActionService.creator.buildUserActions({ updatedCases: patchCasesPayload, user, @@ -462,8 +500,9 @@ export const update = async ( } }; -const trimCaseAttributes = ( - updateCaseAttributes: Omit +const normalizeCaseAttributes = ( + updateCaseAttributes: Omit, + customFieldsConfiguration?: CustomFieldsConfiguration ) => { let trimmedAttributes = { ...updateCaseAttributes }; @@ -489,15 +528,27 @@ const trimCaseAttributes = ( }; } + if (updateCaseAttributes.customFields) { + trimmedAttributes = { + ...trimmedAttributes, + customFields: fillMissingCustomFields({ + customFields: updateCaseAttributes.customFields, + customFieldsConfiguration, + }), + }; + } + return trimmedAttributes; }; const createPatchCasesPayload = ({ casesToUpdate, user, + customFieldsConfigurationMap, }: { casesToUpdate: UpdateRequestWithOriginalCase[]; user: User; + customFieldsConfigurationMap: Map; }): PatchCasesArgs => { const updatedDt = new Date().toISOString(); @@ -508,7 +559,10 @@ const createPatchCasesPayload = ({ const dedupedAssignees = dedupAssignees(assignees); - const trimmedCaseAttributes = trimCaseAttributes(updateCaseAttributes); + const trimmedCaseAttributes = normalizeCaseAttributes( + updateCaseAttributes, + customFieldsConfigurationMap.get(originalCase.attributes.owner) + ); return { caseId, diff --git a/x-pack/plugins/cases/server/client/cases/utils.test.ts b/x-pack/plugins/cases/server/client/cases/utils.test.ts index b6f2bafe24f07..b7460aa4a4237 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.test.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.test.ts @@ -27,8 +27,10 @@ import { mapCaseFieldsToExternalSystemFields, formatComments, addKibanaInformationToDescription, + fillMissingCustomFields, } from './utils'; -import { CaseStatuses, UserActionActions } from '../../../common/types/domain'; +import type { CaseCustomFields } from '../../../common/types/domain'; +import { CaseStatuses, CustomFieldTypes, UserActionActions } from '../../../common/types/domain'; import { flattenCaseSavedObject } from '../../common/utils'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { casesConnectors } from '../../connectors'; @@ -253,8 +255,11 @@ describe('utils', () => { expect(res).toEqual({ incident: { - externalId: null, description: 'This is a brand new case of a bad meanie defacing data', + externalId: null, + id: 'mock-id-1', + severity: 'low', + status: 'open', tags: ['defacement'], title: 'Super Bad Security Issue', }, @@ -1341,4 +1346,128 @@ describe('utils', () => { ).toEqual(userProfiles[0].user.username); }); }); + + describe('fillMissingCustomFields', () => { + const customFields: CaseCustomFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value', 'this is second'], + }, + ]; + + const customFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ]; + + it('adds missing custom fields correctly', () => { + expect( + fillMissingCustomFields({ + customFields, + customFieldsConfiguration, + }) + ).toEqual([ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ]); + }); + + it('does not set to null custom fields that exists', () => { + expect( + fillMissingCustomFields({ + customFields: [ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + customFieldsConfiguration, + }) + ).toEqual([ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]); + }); + + it('returns all custom fields if they are more than the configuration', () => { + expect( + fillMissingCustomFields({ + customFields: [ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + customFieldsConfiguration, + }) + ).toEqual([ + customFields[0], + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]); + }); + + it('adds missing custom fields if the customFields is undefined', () => { + expect( + fillMissingCustomFields({ + customFieldsConfiguration, + }) + ).toEqual([ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ]); + }); + + it('does not add missing fields if the customFieldsConfiguration is undefined', () => { + expect( + fillMissingCustomFields({ + customFields, + }) + ).toEqual(customFields); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/cases/utils.ts b/x-pack/plugins/cases/server/client/cases/utils.ts index 5c77986742d54..9e7f1ab73af20 100644 --- a/x-pack/plugins/cases/server/client/cases/utils.ts +++ b/x-pack/plugins/cases/server/client/cases/utils.ts @@ -19,11 +19,15 @@ import type { ConnectorMappings, ConnectorMappingSource, ConnectorMappingTarget, + CustomFieldsConfiguration, ExternalService, User, } from '../../../common/types/domain'; import { CaseStatuses, UserActionTypes, AttachmentType } from '../../../common/types/domain'; -import type { CaseUserActionsDeprecatedResponse } from '../../../common/types/api'; +import type { + CaseRequestCustomFields, + CaseUserActionsDeprecatedResponse, +} from '../../../common/types/api'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { isPushedUserAction } from '../../../common/utils/user_actions'; import type { CasesClientGetAlertsResponse } from '../alerts/types'; @@ -453,3 +457,26 @@ export const getUserProfiles = async ( return acc; }, new Map()); }; + +export const fillMissingCustomFields = ({ + customFields = [], + customFieldsConfiguration = [], +}: { + customFields?: CaseRequestCustomFields; + customFieldsConfiguration?: CustomFieldsConfiguration; +}): CaseRequestCustomFields => { + const customFieldsKeys = new Set(customFields.map((customField) => customField.key)); + const missingCustomFields: CaseRequestCustomFields = []; + + for (const confCustomField of customFieldsConfiguration) { + if (!customFieldsKeys.has(confCustomField.key)) { + missingCustomFields.push({ + key: confCustomField.key, + type: confCustomField.type, + value: null, + }); + } + } + + return [...customFields, ...missingCustomFields]; +}; diff --git a/x-pack/plugins/cases/server/client/cases/validators.test.ts b/x-pack/plugins/cases/server/client/cases/validators.test.ts new file mode 100644 index 0000000000000..6956440a25685 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/validators.test.ts @@ -0,0 +1,407 @@ +/* + * 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 { CustomFieldsConfiguration, CaseCustomFields } from '../../../common/types/domain'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import { + validateCustomFieldKeysAgainstConfiguration, + validateCustomFieldTypesInRequest, + validateRequiredCustomFields, +} from './validators'; + +describe('validators', () => { + describe('validateCustomFieldTypesInRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not throw if all custom fields types in request match the configuration', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: null, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if no custom fields are in request', () => { + expect(() => + validateCustomFieldTypesInRequest({ + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if the configuration is undefined but no custom fields are in request', () => { + expect(() => validateCustomFieldTypesInRequest({})).not.toThrow(); + }); + + it('throws for a single invalid type', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: first_key"` + ); + }); + + it('throws for multiple custom fields with invalid types', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'third_key', + type: CustomFieldTypes.TEXT, + value: ['abc'], + }, + ], + + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'third_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"The following custom fields have the wrong type in the request: first_key,second_key,third_key"` + ); + }); + + it('throws if configuration is missing and request has custom fields', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"No custom fields configured."`); + }); + }); + + describe('validateCustomFieldKeysAgainstConfiguration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not throw if all custom fields are in configuration', () => { + expect(() => + validateCustomFieldKeysAgainstConfiguration({ + requestCustomFields: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TEXT as const, + value: null, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if no custom fields are in request', () => { + expect(() => + validateCustomFieldKeysAgainstConfiguration({ + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if no configuration found but no custom fields are in request', () => { + expect(() => validateCustomFieldKeysAgainstConfiguration({})).not.toThrow(); + }); + + it('throws if there are invalid custom field keys', () => { + expect(() => + validateCustomFieldKeysAgainstConfiguration({ + requestCustomFields: [ + { + key: 'invalid_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + customFieldsConfiguration: [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + ] as CustomFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid custom field keys: invalid_key"`); + }); + + it('throws if configuration is missing and request has custom fields', () => { + expect(() => + validateCustomFieldKeysAgainstConfiguration({ + requestCustomFields: [ + { + key: 'invalid_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"No custom fields configured."`); + }); + }); + + describe('validateRequiredCustomFields', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not throw if all required custom fields are in the request', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: true, + }, + ]; + + const requestCustomFields: CaseCustomFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE as const, + value: null, + }, + ]; + expect(() => + validateRequiredCustomFields({ + requestCustomFields, + customFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if there are only optional custom fields in configuration', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: false, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: false, + }, + ]; + + expect(() => + validateRequiredCustomFields({ + customFieldsConfiguration, + }) + ).not.toThrow(); + }); + + it('does not throw if the configuration is undefined but no custom fields are in request', () => { + expect(() => validateRequiredCustomFields({})).not.toThrow(); + }); + + it('throws if there are missing required custom fields', () => { + const requestCustomFields: CaseCustomFields = [ + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]; + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + { + key: 'second_key', + type: CustomFieldTypes.TOGGLE, + label: 'foo', + required: true, + }, + ]; + expect(() => + validateRequiredCustomFields({ + requestCustomFields, + customFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Missing required custom fields: first_key"`); + }); + + it('throws if configuration is missing and request has custom fields', () => { + const requestCustomFields: CaseCustomFields = [ + { + key: 'first_key', + type: CustomFieldTypes.TOGGLE, + value: null, + }, + ]; + expect(() => + validateRequiredCustomFields({ + requestCustomFields, + }) + ).toThrowErrorMatchingInlineSnapshot(`"No custom fields configured."`); + }); + + it('throws if configuration has required fields but request has no custom fields', () => { + const customFieldsConfiguration: CustomFieldsConfiguration = [ + { + key: 'first_key', + type: CustomFieldTypes.TEXT, + label: 'foo', + required: true, + }, + ]; + expect(() => + validateRequiredCustomFields({ + customFieldsConfiguration, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Missing required custom fields: first_key"`); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/cases/validators.ts b/x-pack/plugins/cases/server/client/cases/validators.ts new file mode 100644 index 0000000000000..f33ac2ff4d087 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/validators.ts @@ -0,0 +1,119 @@ +/* + * 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 { differenceWith, intersectionWith } from 'lodash'; +import Boom from '@hapi/boom'; +import type { CustomFieldsConfiguration } from '../../../common/types/domain'; +import type { CaseRequestCustomFields } from '../../../common/types/api'; +import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; + +interface CustomFieldValidationParams { + requestCustomFields?: CaseRequestCustomFields; + customFieldsConfiguration?: CustomFieldsConfiguration; +} + +export const validateCustomFields = (params: CustomFieldValidationParams) => { + validateDuplicatedCustomFieldKeysInRequest(params); + validateCustomFieldKeysAgainstConfiguration(params); + validateRequiredCustomFields(params); + validateCustomFieldTypesInRequest(params); +}; + +/** + * Throws if the type doesn't match the configuration. + */ +export function validateCustomFieldTypesInRequest({ + requestCustomFields, + customFieldsConfiguration, +}: CustomFieldValidationParams) { + if (!Array.isArray(requestCustomFields) || !requestCustomFields.length) { + return; + } + + if (customFieldsConfiguration === undefined) { + throw Boom.badRequest('No custom fields configured.'); + } + + let invalidCustomFieldKeys: string[] = []; + + const validCustomFields = intersectionWith( + customFieldsConfiguration, + requestCustomFields, + (requiredVal, requestedVal) => + requiredVal.key === requestedVal.key && requiredVal.type === requestedVal.type + ); + + if (requestCustomFields.length !== validCustomFields.length) { + invalidCustomFieldKeys = differenceWith( + requestCustomFields, + validCustomFields, + (requiredVal, requestedVal) => requiredVal.key === requestedVal.key + ).map((e) => e.key); + } + + if (invalidCustomFieldKeys.length) { + throw Boom.badRequest( + `The following custom fields have the wrong type in the request: ${invalidCustomFieldKeys}` + ); + } +} + +/** + * Throws if the key doesn't match the configuration or is missing + */ +export const validateCustomFieldKeysAgainstConfiguration = ({ + requestCustomFields, + customFieldsConfiguration, +}: CustomFieldValidationParams) => { + if (!Array.isArray(requestCustomFields) || !requestCustomFields.length) { + return []; + } + + if (customFieldsConfiguration === undefined) { + throw Boom.badRequest('No custom fields configured.'); + } + + const invalidCustomFieldKeys = differenceWith( + requestCustomFields, + customFieldsConfiguration, + (requestVal, configurationVal) => requestVal.key === configurationVal.key + ).map((e) => e.key); + + if (invalidCustomFieldKeys.length) { + throw Boom.badRequest(`Invalid custom field keys: ${invalidCustomFieldKeys}`); + } +}; + +/** + * Returns a list of required custom fields missing from the request + */ +export const validateRequiredCustomFields = ({ + requestCustomFields, + customFieldsConfiguration, +}: CustomFieldValidationParams) => { + if (customFieldsConfiguration === undefined) { + if (!Array.isArray(requestCustomFields) || !requestCustomFields.length) { + return; + } else { + throw Boom.badRequest('No custom fields configured.'); + } + } + + const requiredCustomFields = customFieldsConfiguration.filter( + (customField) => customField.required + ); + + const missingRequiredCustomFields = differenceWith( + requiredCustomFields, + requestCustomFields ?? [], + (requiredVal, requestedVal) => requiredVal.key === requestedVal.key + ).map((e) => e.key); + + if (missingRequiredCustomFields.length) { + throw Boom.badRequest(`Missing required custom fields: ${missingRequiredCustomFields}`); + } +}; diff --git a/x-pack/plugins/cases/server/client/configure/client.test.ts b/x-pack/plugins/cases/server/client/configure/client.test.ts index 60c95a20f9e09..c92b1f96fbc3a 100644 --- a/x-pack/plugins/cases/server/client/configure/client.test.ts +++ b/x-pack/plugins/cases/server/client/configure/client.test.ts @@ -7,10 +7,18 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsClientMock } from '@kbn/actions-plugin/server/mocks'; + import type { CasesClientArgs } from '../types'; -import { getConnectors, get, update } from './client'; + +import { getConnectors, get, update, create } from './client'; import { createCasesClientInternalMock, createCasesClientMockArgs } from '../mocks'; -import { MAX_SUPPORTED_CONNECTORS_RETURNED } from '../../../common/constants'; +import { + MAX_CUSTOM_FIELDS_PER_CASE, + MAX_SUPPORTED_CONNECTORS_RETURNED, +} from '../../../common/constants'; +import { ConnectorTypes } from '../../../common'; +import { CustomFieldTypes } from '../../../common/types/domain'; +import type { ConfigurationRequest } from '../../../common/types/api'; describe('client', () => { const clientArgs = createCasesClientMockArgs(); @@ -251,5 +259,153 @@ describe('client', () => { update('test-id', { version: 'test-version', foo: 'bar' }, clientArgs, casesClientInternal) ).rejects.toThrow('invalid keys "foo"'); }); + + it(`throws when trying to update more than ${MAX_CUSTOM_FIELDS_PER_CASE} custom fields`, async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + customFields: new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'foobar', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to get patch configure in route: Error: The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it('throws when there are duplicated custom field keys in the request', async () => { + await expect( + update( + 'test-id', + { + version: 'test-version', + customFields: [ + { + key: 'duplicated_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'duplicated_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid duplicated custom field keys in request: duplicated_key' + ); + }); + + it('throws when trying to updated the type of a custom field', async () => { + clientArgs.services.caseConfigureService.get.mockResolvedValue({ + // @ts-ignore: these are all the attributes needed for the test + attributes: { + customFields: [ + { + key: 'wrong_type_key', + label: 'text', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }, + }); + + await expect( + update( + 'test-id', + { + version: 'test-version', + customFields: [ + { + key: 'wrong_type_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid custom field types in request for the following keys: wrong_type_key' + ); + }); + }); + + describe('create', () => { + const baseRequest = { + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', + owner: 'securitySolutionFixture', + } as ConfigurationRequest; + + it(`throws when trying to create more than ${MAX_CUSTOM_FIELDS_PER_CASE} custom fields`, async () => { + await expect( + create( + { + ...baseRequest, + customFields: new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ + key: 'foobar', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }), + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + `Failed to create case configuration: Error: The length of the field customFields is too long. Array must be of length <= ${MAX_CUSTOM_FIELDS_PER_CASE}.` + ); + }); + + it('throws when there are duplicated keys in the request', async () => { + await expect( + create( + { + ...baseRequest, + customFields: [ + { + key: 'duplicated_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'duplicated_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: Invalid duplicated custom field keys in request: duplicated_key' + ); + }); }); }); diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index 94a51256467e6..1482ea501ca87 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -48,6 +48,8 @@ import { createMappings } from './create_mappings'; import { updateMappings } from './update_mappings'; import { decodeOrThrow } from '../../../common/api/runtime_types'; import { ConfigurationRt, ConfigurationsRt } from '../../../common/types/domain'; +import { validateDuplicatedCustomFieldKeysInRequest } from '../validators'; +import { validateCustomFieldTypesInRequest } from './validators'; /** * Defines the internal helper functions. @@ -250,6 +252,8 @@ export async function update( try { const request = decodeWithExcessOrThrow(ConfigurationPatchRequestRt)(req); + validateDuplicatedCustomFieldKeysInRequest({ requestCustomFields: request.customFields }); + const { version, ...queryWithoutVersion } = request; const configuration = await caseConfigureService.get({ @@ -257,6 +261,11 @@ export async function update( configurationId, }); + validateCustomFieldTypesInRequest({ + requestCustomFields: request.customFields, + originalCustomFields: configuration.attributes.customFields, + }); + await authorization.ensureAuthorized({ operation: Operations.updateConfiguration, entities: [{ owner: configuration.attributes.owner, id: configuration.id }], @@ -339,7 +348,7 @@ export async function update( } } -async function create( +export async function create( configRequest: ConfigurationRequest, clientArgs: CasesClientArgs, casesClientInternal: CasesClientInternal @@ -356,6 +365,10 @@ async function create( const validatedConfigurationRequest = decodeWithExcessOrThrow(ConfigurationRequestRt)(configRequest); + validateDuplicatedCustomFieldKeysInRequest({ + requestCustomFields: validatedConfigurationRequest.customFields, + }); + let error = null; const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = @@ -428,6 +441,7 @@ async function create( unsecuredSavedObjectsClient, attributes: { ...validatedConfigurationRequest, + customFields: validatedConfigurationRequest.customFields ?? [], connector: validatedConfigurationRequest.connector, created_at: creationDate, created_by: user, diff --git a/x-pack/plugins/cases/server/client/configure/validators.test.ts b/x-pack/plugins/cases/server/client/configure/validators.test.ts new file mode 100644 index 0000000000000..3ef853f0d671d --- /dev/null +++ b/x-pack/plugins/cases/server/client/configure/validators.test.ts @@ -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 { CustomFieldTypes } from '../../../common/types/domain'; +import { validateCustomFieldTypesInRequest } from './validators'; + +describe('validators', () => { + describe('validateCustomFieldTypesInRequest', () => { + it('throws an error with the keys of customFields in request that have invalid types', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { key: '1', type: CustomFieldTypes.TOGGLE }, + { key: '2', type: CustomFieldTypes.TEXT }, + ], + originalCustomFields: [ + { key: '1', type: CustomFieldTypes.TEXT }, + { key: '2', type: CustomFieldTypes.TOGGLE }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid custom field types in request for the following keys: 1,2"` + ); + }); + + it('throws an error when not all custom field types are invalid', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { key: '1', type: CustomFieldTypes.TOGGLE }, + { key: '2', type: CustomFieldTypes.TOGGLE }, + ], + originalCustomFields: [ + { key: '1', type: CustomFieldTypes.TEXT }, + { key: '2', type: CustomFieldTypes.TOGGLE }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid custom field types in request for the following keys: 1"` + ); + }); + + it('does not throw if the request has no customFields', () => { + expect(() => + validateCustomFieldTypesInRequest({ + originalCustomFields: [ + { key: '1', type: CustomFieldTypes.TEXT }, + { key: '2', type: CustomFieldTypes.TOGGLE }, + ], + }) + ).not.toThrow(); + }); + + it('does not throw if the current configuration has no customFields', () => { + expect(() => + validateCustomFieldTypesInRequest({ + requestCustomFields: [ + { key: '1', type: CustomFieldTypes.TOGGLE }, + { key: '2', type: CustomFieldTypes.TEXT }, + ], + originalCustomFields: [], + }) + ).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/configure/validators.ts b/x-pack/plugins/cases/server/client/configure/validators.ts new file mode 100644 index 0000000000000..36743d1720376 --- /dev/null +++ b/x-pack/plugins/cases/server/client/configure/validators.ts @@ -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 Boom from '@hapi/boom'; +import type { CustomFieldTypes } from '../../../common/types/domain'; + +/** + * Throws an error if the request tries to change the type of existing custom fields. + */ +export const validateCustomFieldTypesInRequest = ({ + requestCustomFields, + originalCustomFields, +}: { + requestCustomFields?: Array<{ key: string; type: CustomFieldTypes }>; + originalCustomFields: Array<{ key: string; type: CustomFieldTypes }>; +}) => { + if (!Array.isArray(requestCustomFields) || !originalCustomFields.length) { + return; + } + + const invalidFields: string[] = []; + + requestCustomFields.forEach((requestField) => { + const originalField = originalCustomFields.find((item) => item.key === requestField.key); + + if (originalField && originalField.type !== requestField.type) { + invalidFields.push(requestField.key); + } + }); + + if (invalidFields.length > 0) { + throw Boom.badRequest( + `Invalid custom field types in request for the following keys: ${invalidFields}` + ); + } +}; diff --git a/x-pack/plugins/cases/server/client/user_actions/connectors.ts b/x-pack/plugins/cases/server/client/user_actions/connectors.ts index 7b835edfa2e77..2532b8c81bb0a 100644 --- a/x-pack/plugins/cases/server/client/user_actions/connectors.ts +++ b/x-pack/plugins/cases/server/client/user_actions/connectors.ts @@ -33,6 +33,7 @@ import type { ExternalService, UserActionAttributes, } from '../../../common/types/domain'; +import { ConnectorTypes } from '../../../common/types/domain'; export const getConnectors = async ( { caseId }: GetConnectorsRequest, @@ -142,7 +143,28 @@ const getConnectorsInfo = async ({ await getActionConnectors(actionsClient, logger, connectorIds), ]); - return createConnectorInfoResult({ actionConnectors, connectors, pushInfo, latestUserAction }); + /** + * TODO: Remove when all connectors support the status and + * the severity user actions or if there is a mechanism to + * define supported user actions per connector type + */ + const hasCasesWebhookConnector = actionConnectors.some( + (actionConnector) => actionConnector.actionTypeId === ConnectorTypes.casesWebhook + ); + let latestUserActionCasesWebhook: SavedObject | undefined; + if (hasCasesWebhookConnector) { + // if cases webhook connector, we need to fetch latestUserAction again because + // the cases webhook connector includes extra fields other case connectors do not track + latestUserActionCasesWebhook = await userActionService.getMostRecentUserAction(caseId, true); + } + + return createConnectorInfoResult({ + actionConnectors, + connectors, + pushInfo, + latestUserAction, + latestUserActionCasesWebhook, + }); }; const getActionConnectors = async ( @@ -267,11 +289,13 @@ const createConnectorInfoResult = ({ connectors, pushInfo, latestUserAction, + latestUserActionCasesWebhook, }: { actionConnectors: ActionResult[]; connectors: CaseConnectorActivity[]; pushInfo: Map; latestUserAction?: SavedObject; + latestUserActionCasesWebhook?: SavedObject; }) => { const results: GetCaseConnectorsResponse = {}; const actionConnectorsMap = new Map( @@ -282,7 +306,16 @@ const createConnectorInfoResult = ({ const connectorDetails = actionConnectorsMap.get(aggregationConnector.connectorId); const connector = getConnectorInfoFromSavedObject(aggregationConnector.fields); - const latestUserActionCreatedAt = getDate(latestUserAction?.attributes.created_at); + const latestUserActionCreatedAt = getDate( + /** + * TODO: Remove when all connectors support the status and + * the severity user actions or if there is a mechanism to + * define supported user actions per connector type + */ + connectorDetails?.actionTypeId === ConnectorTypes.casesWebhook + ? latestUserActionCasesWebhook?.attributes.created_at + : latestUserAction?.attributes.created_at + ); if (connector != null) { const enrichedPushInfo = pushInfo.get(aggregationConnector.connectorId); diff --git a/x-pack/plugins/cases/server/client/validators.test.ts b/x-pack/plugins/cases/server/client/validators.test.ts new file mode 100644 index 0000000000000..8d6caa218f932 --- /dev/null +++ b/x-pack/plugins/cases/server/client/validators.test.ts @@ -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 { validateDuplicatedCustomFieldKeysInRequest } from './validators'; + +describe('validators', () => { + describe('validateDuplicatedCustomFieldKeysInRequest', () => { + it('returns customFields in request that have duplicated keys', () => { + expect(() => + validateDuplicatedCustomFieldKeysInRequest({ + requestCustomFields: [ + { + key: 'triplicated_key', + }, + { + key: 'triplicated_key', + }, + { + key: 'triplicated_key', + }, + { + key: 'duplicated_key', + }, + { + key: 'duplicated_key', + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duplicated custom field keys in request: triplicated_key,duplicated_key"` + ); + }); + + it('does not throw if no customFields in request have duplicated keys', () => { + expect(() => + validateDuplicatedCustomFieldKeysInRequest({ + requestCustomFields: [ + { + key: '1', + }, + { + key: '2', + }, + ], + }) + ).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/validators.ts b/x-pack/plugins/cases/server/client/validators.ts new file mode 100644 index 0000000000000..88b62640cee88 --- /dev/null +++ b/x-pack/plugins/cases/server/client/validators.ts @@ -0,0 +1,34 @@ +/* + * 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 Boom from '@hapi/boom'; + +/** + * Throws an error if the request has custom fields with duplicated keys. + */ +export const validateDuplicatedCustomFieldKeysInRequest = ({ + requestCustomFields = [], +}: { + requestCustomFields?: Array<{ key: string }>; +}) => { + const uniqueKeys = new Set(); + const duplicatedKeys = new Set(); + + requestCustomFields.forEach((item) => { + if (uniqueKeys.has(item.key)) { + duplicatedKeys.add(item.key); + } else { + uniqueKeys.add(item.key); + } + }); + + if (duplicatedKeys.size > 0) { + throw Boom.badRequest( + `Invalid duplicated custom field keys in request: ${Array.from(duplicatedKeys.values())}` + ); + } +}; diff --git a/x-pack/plugins/cases/server/common/types/case.ts b/x-pack/plugins/cases/server/common/types/case.ts index e22acd381fbe6..00a86f1a71d56 100644 --- a/x-pack/plugins/cases/server/common/types/case.ts +++ b/x-pack/plugins/cases/server/common/types/case.ts @@ -48,8 +48,15 @@ export interface CasePersistedAttributes { updated_at: string | null; updated_by: User | null; category?: string | null; + customFields?: CasePersistedCustomFields; } +type CasePersistedCustomFields = Array<{ + key: string; + type: string; + value: null | unknown | unknown[]; +}>; + export type CaseTransformedAttributes = CaseAttributes; export const CaseTransformedAttributesRt = CaseAttributesRt; diff --git a/x-pack/plugins/cases/server/common/types/configure.ts b/x-pack/plugins/cases/server/common/types/configure.ts index 7f615a9fd5e09..a591375a4d439 100644 --- a/x-pack/plugins/cases/server/common/types/configure.ts +++ b/x-pack/plugins/cases/server/common/types/configure.ts @@ -25,8 +25,16 @@ export interface ConfigurationPersistedAttributes { created_by: User; updated_at: string | null; updated_by: User | null; + customFields?: PersistedCustomFieldsConfiguration; } +type PersistedCustomFieldsConfiguration = Array<{ + key: string; + type: string; + label: string; + required: boolean; +}>; + export type ConfigurationTransformedAttributes = ConfigurationAttributes; export type ConfigurationSavedObjectTransformed = SavedObject; diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index 71848e170af03..a632200a7b0bc 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -38,7 +38,12 @@ import type { CaseConnector, UserCommentAttachmentPayload, } from '../../common/types/domain'; -import { ConnectorTypes, CaseSeverity, AttachmentType } from '../../common/types/domain'; +import { + ConnectorTypes, + CaseSeverity, + AttachmentType, + CustomFieldTypes, +} from '../../common/types/domain'; import type { AttachmentRequest } from '../../common/types/api'; import { createAlertRequests, @@ -83,6 +88,21 @@ function createCommentFindResponse( } describe('common utils', () => { + const connector: CaseConnector = { + id: '123', + name: 'My connector', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'High', parent: null }, + }; + + const customFields = [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT as const, + value: ['this is a text field value', 'this is second'], + }, + ]; + describe('transformNewCase', () => { beforeAll(() => { jest.useFakeTimers(); @@ -93,13 +113,6 @@ describe('common utils', () => { jest.useRealTimers(); }); - const connector: CaseConnector = { - id: '123', - name: 'My connector', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }; - it('transform correctly', () => { const myCase = { newCase: { ...newCase, connector }, @@ -134,6 +147,7 @@ describe('common utils', () => { "full_name": "Elastic", "username": "elastic", }, + "customFields": Array [], "description": "A description", "duration": null, "external_service": null, @@ -188,6 +202,7 @@ describe('common utils', () => { "full_name": "Elastic", "username": "elastic", }, + "customFields": Array [], "description": "A description", "duration": null, "external_service": null, @@ -246,6 +261,75 @@ describe('common utils', () => { "full_name": "Elastic", "username": "elastic", }, + "customFields": Array [], + "description": "A description", + "duration": null, + "external_service": null, + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [ + "new", + "case", + ], + "title": "My new case", + "updated_at": null, + "updated_by": null, + } + `); + }); + + it('transform correctly with customFields provided', () => { + const myCase = { + newCase: { + ...newCase, + connector, + customFields, + }, + user: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + }; + + const res = transformNewCase(myCase); + + expect(res).toMatchInlineSnapshot(` + Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "connector": Object { + "fields": Object { + "issueType": "Task", + "parent": null, + "priority": "High", + }, + "id": "123", + "name": "My connector", + "type": ".jira", + }, + "created_at": "2020-04-09T09:43:51.778Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic", + "username": "elastic", + }, + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + "this is second", + ], + }, + ], "description": "A description", "duration": null, "external_service": null, @@ -304,6 +388,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": null, @@ -346,6 +431,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie destroying data!", "duration": null, "external_service": null, @@ -392,6 +478,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -442,6 +529,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -476,6 +564,89 @@ describe('common utils', () => { } `); }); + + it('transforms correctly case with customFields', () => { + const casesMap = new Map(); + const theCase = { ...mockCases[0] }; + + theCase.attributes = { ...theCase.attributes, customFields }; + casesMap.set(theCase.id, flattenCaseSavedObject({ savedObject: theCase })); + + const res = transformCases({ + casesMap, + countOpenCases: 2, + countInProgressCases: 2, + countClosedCases: 2, + page: 1, + perPage: 10, + total: casesMap.size, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "cases": Array [ + Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + "this is second", + ], + }, + ], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": null, + "id": "mock-id-1", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 0, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + }, + ], + "count_closed_cases": 2, + "count_in_progress_cases": 2, + "count_open_cases": 2, + "page": 1, + "per_page": 10, + "total": 1, + } + `); + }); }); describe('flattenCaseSavedObject', () => { @@ -509,6 +680,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -567,6 +739,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -648,6 +821,7 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "Oh no, a bad meanie going LOLBins all over the place!", "duration": null, "external_service": null, @@ -704,6 +878,72 @@ describe('common utils', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], + "description": "This is a brand new case of a bad meanie defacing data", + "duration": null, + "external_service": null, + "id": "mock-id-1", + "owner": "securitySolution", + "settings": Object { + "syncAlerts": true, + }, + "severity": "low", + "status": "open", + "tags": Array [ + "defacement", + ], + "title": "Super Bad Security Issue", + "totalAlerts": 0, + "totalComment": 2, + "updated_at": "2019-11-25T21:54:48.952Z", + "updated_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "version": "WzAsMV0=", + } + `); + }); + + it('flattens correctly with customFields', () => { + const theCase = { ...mockCases[0] }; + theCase.attributes = { ...theCase.attributes, customFields }; + + const res = flattenCaseSavedObject({ + savedObject: theCase, + totalComment: 2, + }); + + expect(res).toMatchInlineSnapshot(` + Object { + "assignees": Array [], + "category": null, + "closed_at": null, + "closed_by": null, + "comments": Array [], + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + "created_at": "2019-11-25T21:54:48.952Z", + "created_by": Object { + "email": "testemail@elastic.co", + "full_name": "elastic", + "username": "elastic", + }, + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + "this is second", + ], + }, + ], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": null, diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index 94ad077e21bf2..6843e855f3ba8 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -87,6 +87,7 @@ export const transformNewCase = ({ updated_by: null, assignees: dedupAssignees(newCase.assignees) ?? [], category: newCase.category ?? null, + customFields: newCase.customFields ?? [], }); export const transformCases = ({ diff --git a/x-pack/plugins/cases/server/connectors/cases_webook/format.ts b/x-pack/plugins/cases/server/connectors/cases_webook/format.ts index 320b190dc24e8..e839d074aedb2 100644 --- a/x-pack/plugins/cases/server/connectors/cases_webook/format.ts +++ b/x-pack/plugins/cases/server/connectors/cases_webook/format.ts @@ -7,10 +7,11 @@ import type { Format } from './types'; -export const format: Format = (theCase) => { - return { - title: theCase.title, - description: theCase.description, - tags: theCase.tags, - }; -}; +export const format: Format = (theCase) => ({ + title: theCase.title, + description: theCase.description, + tags: theCase.tags, + id: theCase.id, + severity: theCase.severity, + status: theCase.status, +}); diff --git a/x-pack/plugins/cases/server/mocks.ts b/x-pack/plugins/cases/server/mocks.ts index 4a3e3db88c6f5..f05b9e9e1a292 100644 --- a/x-pack/plugins/cases/server/mocks.ts +++ b/x-pack/plugins/cases/server/mocks.ts @@ -162,6 +162,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }, references: [], updated_at: '2019-11-25T21:54:48.952Z', @@ -204,6 +205,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }, references: [], updated_at: '2019-11-25T22:32:00.900Z', @@ -246,6 +248,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -292,6 +295,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }, references: [], updated_at: '2019-11-25T22:32:17.947Z', diff --git a/x-pack/plugins/cases/server/saved_object_types/cases.ts b/x-pack/plugins/cases/server/saved_object_types/cases/cases.ts similarity index 77% rename from x-pack/plugins/cases/server/saved_object_types/cases.ts rename to x-pack/plugins/cases/server/saved_object_types/cases/cases.ts index 164984793dd2a..8e9160604a69d 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases/cases.ts @@ -13,16 +13,18 @@ import type { SavedObjectsExportTransformContext, SavedObjectsType, } from '@kbn/core/server'; -import { CASE_SAVED_OBJECT } from '../../common/constants'; -import type { CasePersistedAttributes } from '../common/types/case'; -import { handleExport } from './import_export/export'; -import { caseMigrations } from './migrations'; +import { CASE_SAVED_OBJECT } from '../../../common/constants'; +import type { CasePersistedAttributes } from '../../common/types/case'; +import { handleExport } from '../import_export/export'; +import { caseMigrations } from '../migrations'; +import { modelVersion1 } from './model_versions'; export const createCaseSavedObjectType = ( coreSetup: CoreSetup, logger: Logger ): SavedObjectsType => ({ name: CASE_SAVED_OBJECT, + switchToModelVersionAt: '8.10.0', indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, hidden: true, namespaceType: 'multiple-isolated', @@ -191,9 +193,48 @@ export const createCaseSavedObjectType = ( category: { type: 'keyword', }, + customFields: { + type: 'nested', + properties: { + key: { + type: 'keyword', + }, + type: { + type: 'keyword', + }, + value: { + type: 'keyword', + fields: { + number: { + type: 'long', + ignore_malformed: true, + }, + boolean: { + type: 'boolean', + // @ts-expect-error: es types are not correct. ignore_malformed is supported. + ignore_malformed: true, + }, + string: { + type: 'text', + }, + date: { + type: 'date', + ignore_malformed: true, + }, + ip: { + type: 'ip', + ignore_malformed: true, + }, + }, + }, + }, + }, }, }, migrations: caseMigrations, + modelVersions: { + 1: modelVersion1, + }, management: { importableAndExportable: true, defaultSearchField: 'title', diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts new file mode 100644 index 0000000000000..8520fd9673d31 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts @@ -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 { modelVersion1 } from './model_versions'; + +describe('Model versions', () => { + describe('1', () => { + it('returns the model version correctly', () => { + expect(modelVersion1).toMatchInlineSnapshot(` + Object { + "changes": Array [ + Object { + "addedMappings": Object { + "customFields": Object { + "properties": Object { + "key": Object { + "type": "keyword", + }, + "type": Object { + "type": "keyword", + }, + "value": Object { + "fields": Object { + "boolean": Object { + "ignore_malformed": true, + "type": "boolean", + }, + "date": Object { + "ignore_malformed": true, + "type": "date", + }, + "ip": Object { + "ignore_malformed": true, + "type": "ip", + }, + "number": Object { + "ignore_malformed": true, + "type": "long", + }, + "string": Object { + "type": "text", + }, + }, + "type": "keyword", + }, + }, + "type": "nested", + }, + }, + "type": "mappings_addition", + }, + ], + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts new file mode 100644 index 0000000000000..56806e7dec607 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts @@ -0,0 +1,57 @@ +/* + * 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 { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server'; + +/** + * Adds custom fields to the cases SO. + */ +export const modelVersion1: SavedObjectsModelVersion = { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + customFields: { + type: 'nested', + properties: { + key: { + type: 'keyword', + }, + type: { + type: 'keyword', + }, + value: { + type: 'keyword', + fields: { + number: { + type: 'long', + ignore_malformed: true, + }, + boolean: { + // @ts-expect-error: es types are not correct. ignore_malformed is supported. + ignore_malformed: true, + type: 'boolean', + }, + string: { + type: 'text', + }, + date: { + type: 'date', + ignore_malformed: true, + }, + ip: { + type: 'ip', + ignore_malformed: true, + }, + }, + }, + }, + }, + }, + }, + ], +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/index.ts b/x-pack/plugins/cases/server/saved_object_types/index.ts index 8acbc1fbbb5fc..a43e60c0a240b 100644 --- a/x-pack/plugins/cases/server/saved_object_types/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -export { createCaseSavedObjectType } from './cases'; +export { createCaseSavedObjectType } from './cases/cases'; export { caseConfigureSavedObjectType } from './configure'; export { createCaseCommentSavedObjectType } from './comments'; export { createCaseUserActionSavedObjectType } from './user_actions'; diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts index 3f7ab21017b78..712e22732b9ff 100644 --- a/x-pack/plugins/cases/server/services/cases/index.test.ts +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -210,6 +210,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "owner": "securitySolution", @@ -721,6 +722,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -1086,15 +1088,15 @@ describe('CasesService', () => { }); expect(res.attributes).toMatchInlineSnapshot(` - Object { - "connector": Object { - "fields": null, - "id": "none", - "name": "none", - "type": ".none", - }, - } - `); + Object { + "connector": Object { + "fields": null, + "id": "none", + "name": "none", + "type": ".none", + }, + } + `); expect(res.references).toMatchInlineSnapshot(`Array []`); }); @@ -1110,10 +1112,10 @@ describe('CasesService', () => { }); expect(res.attributes).toMatchInlineSnapshot(` - Object { - "external_service": null, - } - `); + Object { + "external_service": null, + } + `); expect(res.references).toMatchInlineSnapshot(`Array []`); }); @@ -1142,10 +1144,10 @@ describe('CasesService', () => { }); expect(res).toMatchInlineSnapshot(` - Object { - "attributes": Object {}, - } - `); + Object { + "attributes": Object {}, + } + `); }); it('returns the default none connector when it cannot find the reference', async () => { @@ -1887,7 +1889,8 @@ describe('CasesService', () => { 'severity', 'connector', 'external_service', - 'category' + 'category', + 'customFields' ); describe('getCaseIdsByAlertId', () => { @@ -1983,6 +1986,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": null, @@ -2072,6 +2076,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2164,6 +2169,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2256,6 +2262,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2361,6 +2368,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2416,6 +2424,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { @@ -2517,6 +2526,7 @@ describe('CasesService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": Object { diff --git a/x-pack/plugins/cases/server/services/cases/transform.ts b/x-pack/plugins/cases/server/services/cases/transform.ts index 99fafaf80cdb3..10eb6fc292323 100644 --- a/x-pack/plugins/cases/server/services/cases/transform.ts +++ b/x-pack/plugins/cases/server/services/cases/transform.ts @@ -15,7 +15,7 @@ import type { } from '@kbn/core/server'; import { ACTION_SAVED_OBJECT_TYPE } from '@kbn/actions-plugin/server'; import { NONE_CONNECTOR_ID } from '../../../common/constants'; -import type { ExternalService } from '../../../common/types/domain'; +import type { CaseCustomFields, ExternalService } from '../../../common/types/domain'; import { CaseSeverity, CaseStatuses } from '../../../common/types/domain'; import { CONNECTOR_ID_REFERENCE_NAME, @@ -45,6 +45,7 @@ export function transformUpdateResponseToExternalModel( status, total_alerts, total_comments, + customFields, ...restUpdateAttributes } = updatedCase.attributes ?? @@ -77,6 +78,9 @@ export function transformUpdateResponseToExternalModel( ...(transformedConnector && { connector: transformedConnector }), // if externalService is null that means we intentionally updated it to null within ES so return that as a valid value ...(externalService !== undefined && { external_service: externalService }), + ...(customFields !== undefined && { + customFields: customFields as CaseTransformedAttributes['customFields'], + }), }, }; } @@ -174,6 +178,9 @@ export function transformSavedObjectToExternalModel( SEVERITY_ESMODEL_TO_EXTERNAL[caseSavedObjectAttributes.severity] ?? CaseSeverity.LOW; const status = STATUS_ESMODEL_TO_EXTERNAL[caseSavedObjectAttributes.status] ?? CaseStatuses.open; const category = !caseSavedObjectAttributes.category ? null : caseSavedObjectAttributes.category; + const customFields = !caseSavedObjectAttributes.customFields + ? [] + : (caseSavedObjectAttributes.customFields as CaseCustomFields); return { ...caseSavedObject, @@ -184,6 +191,7 @@ export function transformSavedObjectToExternalModel( connector, external_service: externalService, category, + customFields, }, }; } diff --git a/x-pack/plugins/cases/server/services/configure/index.test.ts b/x-pack/plugins/cases/server/services/configure/index.test.ts index 8de0815613b2e..3be7e771c5e64 100644 --- a/x-pack/plugins/cases/server/services/configure/index.test.ts +++ b/x-pack/plugins/cases/server/services/configure/index.test.ts @@ -43,6 +43,7 @@ const basicConfigFields = { email: 'testemail@elastic.co', username: 'elastic', }, + customFields: [], }; const createConfigUpdateParams = (connector?: CaseConnector): Partial => ({ @@ -171,6 +172,7 @@ describe('CaseConfigureService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "owner": "securitySolution", "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { @@ -441,6 +443,7 @@ describe('CaseConfigureService', () => { "full_name": "elastic", "username": "elastic", }, + "customFields": Array [], "owner": "securitySolution", "updated_at": "2020-04-09T09:43:51.778Z", "updated_by": Object { diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index 6860f9edc940d..5f31403be8d3e 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -83,7 +83,6 @@ export class CaseConfigureService { }: FindCaseConfigureArgs): Promise> { try { this.log.debug(`Attempting to find all case configuration`); - const findResp = await unsecuredSavedObjectsClient.find({ ...options, // Get the latest configuration @@ -120,6 +119,7 @@ export class CaseConfigureService { this.log.debug(`Attempting to POST a new case configuration`); const decodedAttributes = decodeOrThrow(ConfigurationTransformedAttributesRt)(attributes); + const esConfigInfo = transformAttributesToESModel(decodedAttributes); const createdConfig = @@ -149,6 +149,7 @@ export class CaseConfigureService { this.log.debug(`Attempting to UPDATE case configuration ${configurationId}`); const decodedAttributes = decodeOrThrow(ConfigurationPartialAttributesRt)(updatedAttributes); + const esUpdateInfo = transformAttributesToESModel(decodedAttributes); const updatedConfiguration = @@ -223,12 +224,16 @@ function transformToExternalModel( }); const castedAttributes = configuration.attributes as ConfigurationTransformedAttributes; + const customFields = !configuration.attributes.customFields + ? [] + : (configuration.attributes.customFields as ConfigurationTransformedAttributes['customFields']); return { ...configuration, attributes: { ...castedAttributes, connector, + customFields, }, }; } diff --git a/x-pack/plugins/cases/server/services/test_utils.ts b/x-pack/plugins/cases/server/services/test_utils.ts index 22158ef4d656e..022a868ee0d9b 100644 --- a/x-pack/plugins/cases/server/services/test_utils.ts +++ b/x-pack/plugins/cases/server/services/test_utils.ts @@ -163,6 +163,7 @@ export const basicCaseFields: CaseAttributes = { owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, + customFields: [], }; export const createCaseSavedObjectResponse = ({ diff --git a/x-pack/plugins/cases/server/services/user_actions/builder_factory.ts b/x-pack/plugins/cases/server/services/user_actions/builder_factory.ts index ace74c86c9444..53a19dccd11bd 100644 --- a/x-pack/plugins/cases/server/services/user_actions/builder_factory.ts +++ b/x-pack/plugins/cases/server/services/user_actions/builder_factory.ts @@ -22,6 +22,7 @@ import type { BuilderDeps } from './types'; import { AssigneesUserActionBuilder } from './builders/assignees'; import { NoopUserActionBuilder } from './builders/noop'; import { CategoryUserActionBuilder } from './builders/category'; +import { CustomFieldsUserActionBuilder } from './builders/custom_fields'; const builderMap = { assignees: AssigneesUserActionBuilder, @@ -37,6 +38,7 @@ const builderMap = { severity: SeverityUserActionBuilder, settings: SettingsUserActionBuilder, delete_case: NoopUserActionBuilder, + customFields: CustomFieldsUserActionBuilder, }; export class BuilderFactory { diff --git a/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.test.ts b/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.test.ts new file mode 100644 index 0000000000000..9269d2507ef97 --- /dev/null +++ b/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { CustomFieldTypes, UserActionActions } from '../../../../common/types/domain'; +import { PersistableStateAttachmentTypeRegistry } from '../../../attachment_framework/persistable_state_registry'; +import type { UserActionParameters } from '../types'; +import { CustomFieldsUserActionBuilder } from './custom_fields'; + +describe('CustomFieldsUserActionBuilder', () => { + const persistableStateAttachmentTypeRegistry = new PersistableStateAttachmentTypeRegistry(); + + const builderArgs: UserActionParameters<'customFields'> = { + action: 'update' as const, + caseId: 'test-id', + user: { + email: 'elastic@elastic.co', + full_name: 'Elastic User', + username: 'elastic', + }, + owner: 'cases', + payload: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }, + }; + + let builder: CustomFieldsUserActionBuilder; + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2022-01-09T22:00:00.000Z')); + }); + + beforeEach(() => { + jest.resetAllMocks(); + + builder = new CustomFieldsUserActionBuilder({ persistableStateAttachmentTypeRegistry }); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('builds the action correctly', async () => { + const res = builder.build(builderArgs); + + expect(res).toMatchInlineSnapshot(` + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "test-id", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "cases", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "test-id", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + } + `); + }); + + it.each([ + [UserActionActions.add, 'added', 'to'], + [UserActionActions.update, 'changed', 'for'], + [UserActionActions.delete, 'deleted', 'from'], + ])('show the message correctly for action: %s', async (action, verb, preposition) => { + const res = builder.build({ ...builderArgs, action }); + + expect(res.eventDetails.getMessage('ua-id')).toBe( + `User ${verb} keys: [string_custom_field_1] ${preposition} case id: test-id - user action id: ua-id` + ); + }); +}); diff --git a/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.ts b/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.ts new file mode 100644 index 0000000000000..67873ac3f4e1a --- /dev/null +++ b/x-pack/plugins/cases/server/services/user_actions/builders/custom_fields.ts @@ -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 { CASE_SAVED_OBJECT } from '../../../../common/constants'; +import type { UserActionAction } from '../../../../common/types/domain'; +import { UserActionActions, UserActionTypes } from '../../../../common/types/domain'; +import { UserActionBuilder } from '../abstract_builder'; +import type { EventDetails, UserActionParameters, UserActionEvent } from '../types'; + +export class CustomFieldsUserActionBuilder extends UserActionBuilder { + build(args: UserActionParameters<'customFields'>): UserActionEvent { + const action = args.action ?? UserActionActions.add; + + const soParams = this.buildCommonUserAction({ + ...args, + action, + valueKey: 'customFields', + value: args.payload.customFields, + type: UserActionTypes.customFields, + }); + + const keys = args.payload.customFields.map((customField) => customField.key); + const verbMessage = getVerbMessage(action, keys); + + const getMessage = (id?: string) => + `User ${verbMessage} case id: ${args.caseId} - user action id: ${id}`; + + const event: EventDetails = { + getMessage, + action, + descriptiveAction: `case_user_action_${action}_case_custom_fields`, + savedObjectId: args.caseId, + savedObjectType: CASE_SAVED_OBJECT, + }; + + return { + parameters: soParams, + eventDetails: event, + }; + } +} + +const getVerbMessage = (action: UserActionAction, keys: string[]) => { + const keysText = `keys: [${keys}]`; + + switch (action) { + case 'add': + return `added ${keysText} to`; + case 'delete': + return `deleted ${keysText} from`; + default: + return `changed ${keysText} for`; + } +}; diff --git a/x-pack/plugins/cases/server/services/user_actions/index.test.ts b/x-pack/plugins/cases/server/services/user_actions/index.test.ts index b09b8aba8d114..905e94bc11d8a 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.test.ts @@ -1926,6 +1926,222 @@ describe('CaseUserActionService', () => { } `); }); + + it('constructs the user actions filter correctly', async () => { + const userAction = createUserActionSO(); + const soFindRes = createSOFindResponse([createUserActionFindSO(userAction)]); + unsecuredSavedObjectsClient.find.mockResolvedValue(soFindRes); + + await service.getMostRecentUserAction('123'); + + expect(unsecuredSavedObjectsClient.find.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "comment", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "description", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "tags", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "title", + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + "hasReference": Object { + "id": "123", + "type": "cases", + }, + "page": 1, + "perPage": 1, + "sortField": "created_at", + "sortOrder": "desc", + "type": "cases-user-actions", + } + `); + }); + + it('constructs the user actions filter correctly for the webhook connector', async () => { + const userAction = createUserActionSO(); + const soFindRes = createSOFindResponse([createUserActionFindSO(userAction)]); + unsecuredSavedObjectsClient.find.mockResolvedValue(soFindRes); + + await service.getMostRecentUserAction('123', true); + + expect(unsecuredSavedObjectsClient.find.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "comment", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "description", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "tags", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "title", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "severity", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases-user-actions.attributes.type", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "status", + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "or", + "type": "function", + }, + "hasReference": Object { + "id": "123", + "type": "cases", + }, + "page": 1, + "perPage": 1, + "sortField": "created_at", + "sortOrder": "desc", + "type": "cases-user-actions", + } + `); + }); }); describe('getCaseConnectorInformation', () => { diff --git a/x-pack/plugins/cases/server/services/user_actions/index.ts b/x-pack/plugins/cases/server/services/user_actions/index.ts index 939fb843758d1..7385c4cc958f6 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.ts @@ -235,7 +235,8 @@ export class CaseUserActionService { } public async getMostRecentUserAction( - caseId: string + caseId: string, + isCasesWebhook = false ): Promise { try { this.context.log.debug( @@ -251,6 +252,12 @@ export class CaseUserActionService { UserActionTypes.description, UserActionTypes.tags, UserActionTypes.title, + /** + * TODO: Remove when all connectors support the status and + * the severity user actions or if there is a mechanism to + * define supported user actions per connector type + */ + ...(isCasesWebhook ? [UserActionTypes.severity, UserActionTypes.status] : []), ], field: 'type', operator: 'or', diff --git a/x-pack/plugins/cases/server/services/user_actions/mocks.ts b/x-pack/plugins/cases/server/services/user_actions/mocks.ts index 41689cc785324..00de014c9b1c1 100644 --- a/x-pack/plugins/cases/server/services/user_actions/mocks.ts +++ b/x-pack/plugins/cases/server/services/user_actions/mocks.ts @@ -12,7 +12,13 @@ import { createCaseSavedObjectResponse } from '../test_utils'; import { transformSavedObjectToExternalModel } from '../cases/transform'; import { alertComment, comment } from '../../mocks'; import type { UserActionsDict } from './types'; -import { CaseSeverity, CaseStatuses, ConnectorTypes } from '../../../common/types/domain'; +import { + CaseSeverity, + CaseStatuses, + ConnectorTypes, + CustomFieldTypes, +} from '../../../common/types/domain'; +import type { PatchCasesArgs } from '../cases/types'; export const casePayload: CasePostRequest = { title: 'Case SIR', @@ -141,6 +147,143 @@ export const patchTagsCasesRequest = { ], }; +const originalCasesWithCustomFields = [ + { + ...createCaseSavedObjectResponse({ + overrides: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['old value'], + }, + { + key: 'string_custom_field_2', + type: CustomFieldTypes.TEXT, + value: ['old value 2'], + }, + ], + }, + }), + id: '1', + }, +].map((so) => transformSavedObjectToExternalModel(so)); + +export const patchAddCustomFieldsToOriginalCasesRequest: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }, + originalCase: originalCases[0], + }, + ], +}; + +export const patchUpdateCustomFieldsCasesRequest: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['updated value'], + }, + { + key: 'string_custom_field_2', + type: CustomFieldTypes.TEXT, + value: ['old value 2'], + }, + ], + }, + originalCase: originalCasesWithCustomFields[0], + }, + ], +}; + +export const patchUpdateResetCustomFieldsCasesRequest: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: null, + }, + { + key: 'string_custom_field_2', + type: CustomFieldTypes.TEXT, + value: ['new custom field 2'], + }, + ], + }, + originalCase: originalCasesWithCustomFields[0], + }, + ], +}; + +export const patchNewCustomFieldConfAdded: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['new value'], + }, + { + key: 'string_custom_field_2', + type: CustomFieldTypes.TEXT, + value: ['old value 2'], + }, + { + key: 'string_custom_field_3', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }, + originalCase: originalCasesWithCustomFields[0], + }, + ], +}; + +export const patchCustomFieldConfRemoved: PatchCasesArgs = { + cases: [ + { + ...createCaseSavedObjectResponse(), + caseId: '1', + updatedAttributes: { + customFields: [ + { + key: 'string_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['new value'], + }, + ], + }, + originalCase: originalCasesWithCustomFields[0], + }, + ], +}; + export const attachments = [ { id: '1', attachment: { ...comment }, owner: SECURITY_SOLUTION_OWNER }, { id: '2', attachment: { ...alertComment }, owner: SECURITY_SOLUTION_OWNER }, diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts index e29bb63168ece..432295989fca0 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts @@ -26,8 +26,13 @@ import { patchAddRemoveAssigneesCasesRequest, patchAssigneesCasesRequest, patchCasesRequest, + patchAddCustomFieldsToOriginalCasesRequest, + patchUpdateCustomFieldsCasesRequest, patchRemoveAssigneesCasesRequest, patchTagsCasesRequest, + patchUpdateResetCustomFieldsCasesRequest, + patchNewCustomFieldConfAdded, + patchCustomFieldConfRemoved, } from '../mocks'; import { AttachmentType } from '../../../../common/types/domain'; @@ -218,5 +223,315 @@ describe('UserActionPersister', () => { }) ); }); + + describe('customFields', () => { + it('creates the correct user actions when adding a new custom field to a case without custom fields', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchAddCustomFieldsToOriginalCasesRequest, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "this is a text field value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + + it('creates the correct user actions when updating an existing custom field', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchUpdateCustomFieldsCasesRequest, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "updated value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + + it('creates the correct user actions when updating and resetting custom fields', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchUpdateResetCustomFieldsCasesRequest, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": null, + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_2", + "type": "text", + "value": Array [ + "new custom field 2", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + + it('should create a user action only for the updated field and not for the added configuration', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchNewCustomFieldConfAdded, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "new value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + + it('should create a user action only for the field that got updated and not for the removed configuration', async () => { + expect( + persister.buildUserActions({ + updatedCases: patchCustomFieldConfRemoved, + user: testUser, + }) + ).toMatchInlineSnapshot(` + Object { + "1": Array [ + Object { + "eventDetails": Object { + "action": "update", + "descriptiveAction": "case_user_action_update_case_custom_fields", + "getMessage": [Function], + "savedObjectId": "1", + "savedObjectType": "cases", + }, + "parameters": Object { + "attributes": Object { + "action": "update", + "created_at": "2022-01-09T22:00:00.000Z", + "created_by": Object { + "email": "elastic@elastic.co", + "full_name": "Elastic User", + "username": "elastic", + }, + "owner": "securitySolution", + "payload": Object { + "customFields": Array [ + Object { + "key": "string_custom_field_1", + "type": "text", + "value": Array [ + "new value", + ], + }, + ], + }, + "type": "customFields", + }, + "references": Array [ + Object { + "id": "1", + "name": "associated-cases", + "type": "cases", + }, + ], + }, + }, + ], + } + `); + }); + }); }); }); diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts index 3b9c51e2df24b..1f2ba3dc8f046 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.ts @@ -9,6 +9,8 @@ import type { SavedObject, SavedObjectsBulkResponse } from '@kbn/core/server'; import { get, isEmpty } from 'lodash'; import type { CaseAssignees, + CaseCustomField, + CaseCustomFields, CaseUserProfile, UserActionAction, UserActionType, @@ -37,7 +39,7 @@ import type { UserActionEvent, UserActionsDict, } from '../types'; -import { isAssigneesArray, isStringArray } from '../type_guards'; +import { isAssigneesArray, isCustomFieldsArray, isStringArray } from '../type_guards'; import type { IndexRefresh } from '../../types'; import { UserActionAuditLogger } from '../audit_logger'; @@ -120,6 +122,12 @@ export class UserActionPersister { isStringArray(newValue) ) { return this.buildTagsUserActions({ ...params, originalValue, newValue }); + } else if ( + field === UserActionTypes.customFields && + isCustomFieldsArray(originalValue) && + isCustomFieldsArray(newValue) + ) { + return this.buildCustomFieldsUserActions({ ...params, originalValue, newValue }); } else if (isUserActionType(field) && newValue !== undefined) { const userActionBuilder = this.builderFactory.getBuilder(UserActionTypes[field]); const fieldUserAction = userActionBuilder?.build({ @@ -154,6 +162,42 @@ export class UserActionPersister { return this.buildAddDeleteUserActions(params, createPayload, UserActionTypes.tags); } + private buildCustomFieldsUserActions(params: TypedUserActionDiffedItems) { + const createPayload: CreatePayloadFunction< + CaseCustomField, + typeof UserActionTypes.customFields + > = (items: CaseCustomFields) => ({ customFields: items }); + + const { originalValue: originalCustomFields, newValue: newCustomFields } = params; + + const originalCustomFieldsKeys = new Set( + originalCustomFields.map((customField) => customField.key) + ); + + const compareValues = arraysDifference(originalCustomFields, newCustomFields); + + const updatedCustomFieldsUsersActions = compareValues?.addedItems + .filter((customField) => { + if (customField.value != null) { + return true; + } + + return originalCustomFieldsKeys.has(customField.key); + }) + .map((customField) => + this.buildUserAction({ + commonArgs: params, + actionType: UserActionTypes.customFields, + action: UserActionActions.update, + createPayload, + modifiedItems: [customField], + }) + ) + .filter((userAction): userAction is UserActionEvent => userAction != null); + + return [...(updatedCustomFieldsUsersActions ? updatedCustomFieldsUsersActions : [])]; + } + private buildAddDeleteUserActions( params: TypedUserActionDiffedItems, createPayload: CreatePayloadFunction, diff --git a/x-pack/plugins/cases/server/services/user_actions/type_guards.ts b/x-pack/plugins/cases/server/services/user_actions/type_guards.ts index 494550923ce8e..8cd11926b66a5 100644 --- a/x-pack/plugins/cases/server/services/user_actions/type_guards.ts +++ b/x-pack/plugins/cases/server/services/user_actions/type_guards.ts @@ -6,8 +6,8 @@ */ import { isString } from 'lodash'; -import type { CaseAssignees } from '../../../common/types/domain'; -import { CaseAssigneesRt } from '../../../common/types/domain'; +import type { CaseAssignees, CaseCustomFields } from '../../../common/types/domain'; +import { CaseAssigneesRt, CaseCustomFieldsRt } from '../../../common/types/domain'; export const isStringArray = (value: unknown): value is string[] => { return Array.isArray(value) && value.every((val) => isString(val)); @@ -16,3 +16,7 @@ export const isStringArray = (value: unknown): value is string[] => { export const isAssigneesArray = (value: unknown): value is CaseAssignees => { return CaseAssigneesRt.is(value); }; + +export const isCustomFieldsArray = (value: unknown): value is CaseCustomFields => { + return CaseCustomFieldsRt.is(value); +}; diff --git a/x-pack/plugins/cases/server/services/user_actions/types.ts b/x-pack/plugins/cases/server/services/user_actions/types.ts index 657fc31d7275b..42ebb7e413582 100644 --- a/x-pack/plugins/cases/server/services/user_actions/types.ts +++ b/x-pack/plugins/cases/server/services/user_actions/types.ts @@ -26,6 +26,7 @@ import type { CaseStatuses, User, CaseAssignees, + CaseCustomFields, } from '../../../common/types/domain'; import type { PersistableStateAttachmentTypeRegistry } from '../../attachment_framework/persistable_state_registry'; import type { @@ -92,6 +93,9 @@ export interface BuilderParameters { category: { parameters: { payload: { category: string | null } }; }; + customFields: { + parameters: { payload: { customFields: CaseCustomFields } }; + }; } export interface CreateUserAction { diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.ts index 8f4b0d63c9947..c7f6655141b2d 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.test.ts @@ -40,6 +40,9 @@ describe('LaunchDarklyClient - browser', () => { avatar: 'fake-blue-avatar', ip: 'my-weird-ip', country: 'distributed', + // intentionally adding this to make sure the code is overriding appropriately + kind: 'other kind', + key: 'other user', }; const extraFields = { @@ -52,9 +55,10 @@ describe('LaunchDarklyClient - browser', () => { expect(launchDarklyLibraryMock.initialize).toHaveBeenCalledWith( 'fake-client-id', { - key: 'fake-user-id', ...topFields, - custom: extraFields, + ...extraFields, + kind: 'user', + key: 'fake-user-id', }, { application: { id: 'kibana-browser', version: 'version' }, @@ -73,8 +77,9 @@ describe('LaunchDarklyClient - browser', () => { expect(launchDarklyLibraryMock.initialize).toHaveBeenCalledWith( 'fake-client-id', { + kind: 'user', key: 'fake-user-id', - custom: { kibanaVersion: 'version' }, + kibanaVersion: 'version', }, { application: { id: 'kibana-browser', version: 'version' }, @@ -92,8 +97,9 @@ describe('LaunchDarklyClient - browser', () => { expect(launchDarklyLibraryMock.initialize).toHaveBeenCalledWith( 'fake-client-id', { + kind: 'user', key: 'fake-user-id', - custom: { kibanaVersion: 'version' }, + kibanaVersion: 'version', }, { application: { id: 'kibana-browser', version: 'version' }, @@ -107,8 +113,9 @@ describe('LaunchDarklyClient - browser', () => { // Update user metadata a 2nd time await client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' }); expect(ldClientMock.identify).toHaveBeenCalledWith({ + kind: 'user', key: 'fake-user-id', - custom: { kibanaVersion: 'version' }, + kibanaVersion: 'version', }); }); }); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts index f78286f0fa8ca..34d639bd1dc33 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/public/launch_darkly_client/launch_darkly_client.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { type LDClient, type LDUser, type LDLogLevel } from 'launchdarkly-js-client-sdk'; +import { + type LDClient, + type LDSingleKindContext, + type LDLogLevel, +} from 'launchdarkly-js-client-sdk'; export interface LaunchDarklyClientConfig { client_id: string; @@ -15,14 +19,6 @@ export interface LaunchDarklyClientConfig { export interface LaunchDarklyUserMetadata extends Record { userId: string; - // We are not collecting any of the above, but this is to match the LDUser first-level definition - name?: string; - firstName?: string; - lastName?: string; - email?: string; - avatar?: string; - ip?: string; - country?: string; } export class LaunchDarklyClient { @@ -34,19 +30,11 @@ export class LaunchDarklyClient { ) {} public async updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) { - const { userId, name, firstName, lastName, email, avatar, ip, country, ...custom } = - userMetadata; - const launchDarklyUser: LDUser = { + const { userId, ...userMetadataWithoutUserId } = userMetadata; + const launchDarklyUser: LDSingleKindContext = { + ...userMetadataWithoutUserId, + kind: 'user', key: userId, - name, - firstName, - lastName, - email, - avatar, - ip, - country, - // This casting is needed because LDUser does not allow `Record` - custom: custom as Record, }; if (this.launchDarklyClient) { await this.launchDarklyClient.identify(launchDarklyUser); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.ts index cc140ea44ffdb..7d40fe6d10ccf 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.test.ts @@ -80,7 +80,7 @@ describe('LaunchDarklyClient - server', () => { }); describe('updateUserMetadata', () => { - test('sets the top-level properties at the root (renaming userId to key) and the rest under `custom`', () => { + test('sets all properties at the root level, renaming userId to key (no nesting into custom)', () => { expect(client).toHaveProperty('launchDarklyUser', undefined); const topFields = { @@ -91,6 +91,9 @@ describe('LaunchDarklyClient - server', () => { avatar: 'fake-blue-avatar', ip: 'my-weird-ip', country: 'distributed', + // intentionally adding this to make sure the code is overriding appropriately + kind: 'other kind', + key: 'other user', }; const extraFields = { @@ -101,9 +104,10 @@ describe('LaunchDarklyClient - server', () => { client.updateUserMetadata({ userId: 'fake-user-id', ...topFields, ...extraFields }); expect(client).toHaveProperty('launchDarklyUser', { - key: 'fake-user-id', ...topFields, - custom: extraFields, + ...extraFields, + kind: 'user', + key: 'fake-user-id', }); }); @@ -113,8 +117,9 @@ describe('LaunchDarklyClient - server', () => { client.updateUserMetadata({ userId: 'fake-user-id', kibanaVersion: 'version' }); expect(client).toHaveProperty('launchDarklyUser', { + kind: 'user', key: 'fake-user-id', - custom: { kibanaVersion: 'version' }, + kibanaVersion: 'version', }); }); }); @@ -132,7 +137,7 @@ describe('LaunchDarklyClient - server', () => { expect(ldClientMock.variation).toHaveBeenCalledTimes(1); expect(ldClientMock.variation).toHaveBeenCalledWith( 'my-feature-flag', - { key: 'fake-user-id', custom: { kibanaVersion: 'version' } }, + { kind: 'user', key: 'fake-user-id', kibanaVersion: 'version' }, 123 ); }); @@ -150,7 +155,7 @@ describe('LaunchDarklyClient - server', () => { expect(ldClientMock.track).toHaveBeenCalledTimes(1); expect(ldClientMock.track).toHaveBeenCalledWith( 'my-feature-flag', - { key: 'fake-user-id', custom: { kibanaVersion: 'version' } }, + { kind: 'user', key: 'fake-user-id', kibanaVersion: 'version' }, {}, 123 ); @@ -183,8 +188,9 @@ describe('LaunchDarklyClient - server', () => { }); expect(ldClientMock.allFlagsState).toHaveBeenCalledTimes(1); expect(ldClientMock.allFlagsState).toHaveBeenCalledWith({ + kind: 'user', key: 'fake-user-id', - custom: { kibanaVersion: 'version' }, + kibanaVersion: 'version', }); }); }); diff --git a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts index 10126e6d48a46..53554ff242e07 100644 --- a/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts +++ b/x-pack/plugins/cloud_integrations/cloud_experiments/server/launch_darkly_client/launch_darkly_client.ts @@ -9,7 +9,7 @@ import LaunchDarkly, { type LDClient, type LDFlagSet, type LDLogLevel, - type LDUser, + type LDSingleKindContext, } from 'launchdarkly-node-server-sdk'; import type { Logger } from '@kbn/core/server'; @@ -41,7 +41,7 @@ export interface LaunchDarklyGetAllFlags { export class LaunchDarklyClient { private readonly launchDarklyClient: LDClient; - private launchDarklyUser?: LDUser; + private launchDarklyUser?: LDSingleKindContext; constructor(ldConfig: LaunchDarklyClientConfig, private readonly logger: Logger) { this.launchDarklyClient = LaunchDarkly.init(ldConfig.sdk_key, { @@ -59,19 +59,11 @@ export class LaunchDarklyClient { } public updateUserMetadata(userMetadata: LaunchDarklyUserMetadata) { - const { userId, name, firstName, lastName, email, avatar, ip, country, ...custom } = - userMetadata; + const { userId, ...userMetadataWithoutUserId } = userMetadata; this.launchDarklyUser = { + ...userMetadataWithoutUserId, + kind: 'user', key: userId, - name, - firstName, - lastName, - email, - avatar, - ip, - country, - // This casting is needed because LDUser does not allow `Record` - custom: custom as Record, }; } diff --git a/x-pack/plugins/fleet/common/authz.test.ts b/x-pack/plugins/fleet/common/authz.test.ts index 22bfeb6c04cef..c7686083a6bed 100644 --- a/x-pack/plugins/fleet/common/authz.test.ts +++ b/x-pack/plugins/fleet/common/authz.test.ts @@ -10,8 +10,11 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; import { TRANSFORM_PLUGIN_ID } from './constants/plugin'; import { + calculateEndpointExceptionsPrivilegesFromCapabilities, + calculateEndpointExceptionsPrivilegesFromKibanaPrivileges, calculatePackagePrivilegesFromCapabilities, calculatePackagePrivilegesFromKibanaPrivileges, + getAuthorizationFromPrivileges, } from './authz'; import { ENDPOINT_PRIVILEGES } from './constants'; @@ -74,6 +77,56 @@ describe('fleet authz', () => { }); }); + describe('#calculateEndpointExceptionsPrivilegesFromCapabilities', () => { + it('calculates endpoint exceptions privileges correctly', () => { + const endpointExceptionsCapabilities = { + showEndpointExceptions: false, + crudEndpointExceptions: true, + }; + + const expected = { + actions: { + showEndpointExceptions: false, + crudEndpointExceptions: true, + }, + }; + + const actual = calculateEndpointExceptionsPrivilegesFromCapabilities({ + navLinks: {}, + management: {}, + catalogue: {}, + siem: endpointExceptionsCapabilities, + }); + + expect(actual).toEqual(expected); + }); + + it('calculates endpoint exceptions privileges correctly when no matching capabilities', () => { + const endpointCapabilities = { + writeEndpointList: true, + writeTrustedApplications: true, + writePolicyManagement: false, + readPolicyManagement: true, + writeHostIsolationExceptions: true, + writeHostIsolation: false, + }; + const expected = { + actions: { + showEndpointExceptions: false, + crudEndpointExceptions: false, + }, + }; + const actual = calculateEndpointExceptionsPrivilegesFromCapabilities({ + navLinks: {}, + management: {}, + catalogue: {}, + siem: endpointCapabilities, + }); + + expect(actual).toEqual(expected); + }); + }); + describe('calculatePackagePrivilegesFromKibanaPrivileges', () => { it('calculates privileges correctly', () => { const endpointPrivileges = [ @@ -111,4 +164,86 @@ describe('fleet authz', () => { expect(actual).toEqual(expected); }); }); + + describe('#calculateEndpointExceptionsPrivilegesFromKibanaPrivileges', () => { + it('calculates endpoint exceptions privileges correctly', () => { + const endpointExceptionsPrivileges = [ + { privilege: `${SECURITY_SOLUTION_ID}-showEndpointExceptions`, authorized: true }, + { privilege: `${SECURITY_SOLUTION_ID}-crudEndpointExceptions`, authorized: false }, + { privilege: `${SECURITY_SOLUTION_ID}-ignoreMe`, authorized: true }, + ]; + const expected = { + actions: { + showEndpointExceptions: true, + crudEndpointExceptions: false, + }, + }; + const actual = calculateEndpointExceptionsPrivilegesFromKibanaPrivileges( + endpointExceptionsPrivileges + ); + expect(actual).toEqual(expected); + }); + }); + + describe('#getAuthorizationFromPrivileges', () => { + it('returns `false` when no `prefix` nor `searchPrivilege`', () => { + expect( + getAuthorizationFromPrivileges({ + kibanaPrivileges: [ + { + privilege: `${SECURITY_SOLUTION_ID}-ignoreMe`, + authorized: true, + }, + ], + }) + ).toEqual(false); + }); + + it('returns correct Boolean when `prefix` and `searchPrivilege` are given', () => { + const kibanaPrivileges = [ + { privilege: `${SECURITY_SOLUTION_ID}-writeHostIsolationExceptions`, authorized: false }, + { privilege: `${SECURITY_SOLUTION_ID}-writeHostIsolation`, authorized: true }, + { privilege: `${SECURITY_SOLUTION_ID}-ignoreMe`, authorized: false }, + ]; + + expect( + getAuthorizationFromPrivileges({ + kibanaPrivileges, + prefix: `${SECURITY_SOLUTION_ID}-`, + searchPrivilege: `writeHostIsolation`, + }) + ).toEqual(true); + }); + + it('returns correct Boolean when only `prefix` is given', () => { + const kibanaPrivileges = [ + { privilege: `ignore-me-writeHostIsolationExceptions`, authorized: false }, + { privilege: `${SECURITY_SOLUTION_ID}-writeHostIsolation`, authorized: true }, + { privilege: `${SECURITY_SOLUTION_ID}-ignoreMe`, authorized: false }, + ]; + + expect( + getAuthorizationFromPrivileges({ + kibanaPrivileges, + prefix: `${SECURITY_SOLUTION_ID}-`, + searchPrivilege: `writeHostIsolation`, + }) + ).toEqual(true); + }); + + it('returns correct Boolean when only `searchPrivilege` is given', () => { + const kibanaPrivileges = [ + { privilege: `${SECURITY_SOLUTION_ID}-writeHostIsolationExceptions`, authorized: false }, + { privilege: `${SECURITY_SOLUTION_ID}-writeHostIsolation`, authorized: true }, + { privilege: `${SECURITY_SOLUTION_ID}-ignoreMe`, authorized: false }, + ]; + + expect( + getAuthorizationFromPrivileges({ + kibanaPrivileges, + searchPrivilege: `writeHostIsolation`, + }) + ).toEqual(true); + }); + }); }); diff --git a/x-pack/plugins/fleet/common/authz.ts b/x-pack/plugins/fleet/common/authz.ts index 27e72857392a8..97918648db25b 100644 --- a/x-pack/plugins/fleet/common/authz.ts +++ b/x-pack/plugins/fleet/common/authz.ts @@ -9,7 +9,7 @@ import type { Capabilities } from '@kbn/core-capabilities-common'; import { TRANSFORM_PLUGIN_ID } from './constants/plugin'; -import { ENDPOINT_PRIVILEGES } from './constants'; +import { ENDPOINT_EXCEPTIONS_PRIVILEGES, ENDPOINT_PRIVILEGES } from './constants'; export type TransformPrivilege = | 'canGetTransform' @@ -49,6 +49,13 @@ export interface FleetAuthz { }; }; }; + + endpointExceptionsPrivileges?: { + actions: { + crudEndpointExceptions: boolean; + showEndpointExceptions: boolean; + }; + }; } interface CalculateParams { @@ -135,19 +142,50 @@ export function calculatePackagePrivilegesFromCapabilities( }; } -function getAuthorizationFromPrivileges( +export function calculateEndpointExceptionsPrivilegesFromCapabilities( + capabilities: Capabilities | undefined +): FleetAuthz['endpointExceptionsPrivileges'] { + if (!capabilities || !capabilities.siem) { + return; + } + + const endpointExceptionsActions = Object.keys(ENDPOINT_EXCEPTIONS_PRIVILEGES).reduce< + Record + >((acc, privilegeName) => { + acc[privilegeName] = (capabilities.siem[privilegeName] as boolean) || false; + return acc; + }, {}); + + return { + actions: endpointExceptionsActions, + } as FleetAuthz['endpointExceptionsPrivileges']; +} + +export function getAuthorizationFromPrivileges({ + kibanaPrivileges, + searchPrivilege = '', + prefix = '', +}: { kibanaPrivileges: Array<{ resource?: string; privilege: string; authorized: boolean; - }>, - prefix: string, - searchPrivilege: string -): boolean { - const privilege = kibanaPrivileges.find((p) => - p.privilege.endsWith(`${prefix}${searchPrivilege}`) - ); - return privilege?.authorized || false; + }>; + prefix?: string; + searchPrivilege?: string; +}): boolean { + const privilege = kibanaPrivileges.find((p) => { + if (prefix.length && searchPrivilege.length) { + return p.privilege.endsWith(`${prefix}${searchPrivilege}`); + } else if (prefix.length) { + return p.privilege.endsWith(`${prefix}`); + } else if (searchPrivilege.length) { + return p.privilege.endsWith(`${searchPrivilege}`); + } + return false; + }); + + return !!privilege?.authorized; } export function calculatePackagePrivilegesFromKibanaPrivileges( @@ -165,11 +203,11 @@ export function calculatePackagePrivilegesFromKibanaPrivileges( const endpointActions = Object.entries(ENDPOINT_PRIVILEGES).reduce( (acc, [privilege, { appId, privilegeSplit, privilegeName }]) => { - const kibanaPrivilege = getAuthorizationFromPrivileges( + const kibanaPrivilege = getAuthorizationFromPrivileges({ kibanaPrivileges, - `${appId}${privilegeSplit}`, - privilegeName - ); + prefix: `${appId}${privilegeSplit}`, + searchPrivilege: privilegeName, + }); acc[privilege] = { executePackageAction: kibanaPrivilege, }; @@ -178,11 +216,11 @@ export function calculatePackagePrivilegesFromKibanaPrivileges( {} ); - const hasTransformAdmin = getAuthorizationFromPrivileges( + const hasTransformAdmin = getAuthorizationFromPrivileges({ kibanaPrivileges, - `${TRANSFORM_PLUGIN_ID}-`, - `admin` - ); + prefix: `${TRANSFORM_PLUGIN_ID}-`, + searchPrivilege: `admin`, + }); const transformActions: { [key in TransformPrivilege]: { executePackageAction: boolean; @@ -198,11 +236,11 @@ export function calculatePackagePrivilegesFromKibanaPrivileges( executePackageAction: hasTransformAdmin, }, canGetTransform: { - executePackageAction: getAuthorizationFromPrivileges( + executePackageAction: getAuthorizationFromPrivileges({ kibanaPrivileges, - `${TRANSFORM_PLUGIN_ID}-`, - `read` - ), + prefix: `${TRANSFORM_PLUGIN_ID}-`, + searchPrivilege: `read`, + }), }, }; @@ -215,3 +253,28 @@ export function calculatePackagePrivilegesFromKibanaPrivileges( }, }; } + +export function calculateEndpointExceptionsPrivilegesFromKibanaPrivileges( + kibanaPrivileges: + | Array<{ + resource?: string; + privilege: string; + authorized: boolean; + }> + | undefined +): FleetAuthz['endpointExceptionsPrivileges'] { + if (!kibanaPrivileges || !kibanaPrivileges.length) { + return; + } + const endpointExceptionsActions = Object.entries(ENDPOINT_EXCEPTIONS_PRIVILEGES).reduce< + Record + >((acc, [privilege, { appId, privilegeSplit, privilegeName }]) => { + acc[privilege] = getAuthorizationFromPrivileges({ + kibanaPrivileges, + searchPrivilege: privilegeName, + }); + return acc; + }, {}); + + return { actions: endpointExceptionsActions } as FleetAuthz['endpointExceptionsPrivileges']; +} diff --git a/x-pack/plugins/fleet/common/constants/authz.ts b/x-pack/plugins/fleet/common/constants/authz.ts index 77f5c0b798b2f..72c975af1a3d1 100644 --- a/x-pack/plugins/fleet/common/constants/authz.ts +++ b/x-pack/plugins/fleet/common/constants/authz.ts @@ -10,7 +10,7 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; const SECURITY_SOLUTION_APP_ID = 'siem'; -interface PrivilegeMapObject { +export interface PrivilegeMapObject { appId: string; privilegeSplit: string; privilegeType: 'ui' | 'api'; @@ -163,3 +163,18 @@ export const ENDPOINT_PRIVILEGES: Record = deepFreez privilegeName: 'writeExecuteOperations', }, }); + +export const ENDPOINT_EXCEPTIONS_PRIVILEGES: Record = deepFreeze({ + showEndpointExceptions: { + appId: DEFAULT_APP_CATEGORIES.security.id, + privilegeSplit: '-', + privilegeType: 'api', + privilegeName: 'showEndpointExceptions', + }, + crudEndpointExceptions: { + appId: DEFAULT_APP_CATEGORIES.security.id, + privilegeSplit: '-', + privilegeType: 'api', + privilegeName: 'crudEndpointExceptions', + }, +}); diff --git a/x-pack/plugins/fleet/common/mocks.ts b/x-pack/plugins/fleet/common/mocks.ts index 13d0edcf07e58..8e2545adf5ed5 100644 --- a/x-pack/plugins/fleet/common/mocks.ts +++ b/x-pack/plugins/fleet/common/mocks.ts @@ -6,10 +6,10 @@ */ import type { - PostDeletePackagePoliciesResponse, + AgentPolicy, NewPackagePolicy, PackagePolicy, - AgentPolicy, + PostDeletePackagePoliciesResponse, } from './types'; import type { FleetAuthz } from './authz'; import { dataTypes, ENDPOINT_PRIVILEGES } from './constants'; @@ -108,6 +108,12 @@ export const createFleetAuthzMock = (): FleetAuthz => { }, }, }, + endpointExceptionsPrivileges: { + actions: { + showEndpointExceptions: true, + crudEndpointExceptions: true, + }, + }, }; }; diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index 95452d52c4b12..7c1aadeb530eb 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -9,18 +9,19 @@ import React from 'react'; import type { AppMountParameters, CoreSetup, + CoreStart, Plugin, PluginInitializerContext, - CoreStart, } from '@kbn/core/public'; +import { AppNavLinkStatus, DEFAULT_APP_CATEGORIES } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; import type { - CustomIntegrationsStart, CustomIntegrationsSetup, + CustomIntegrationsStart, } from '@kbn/custom-integrations-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; @@ -29,20 +30,17 @@ import { once } from 'lodash'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { DiscoverStart } from '@kbn/discover-plugin/public'; -import type { CloudStart } from '@kbn/cloud-plugin/public'; +import type { CloudSetup, CloudStart } from '@kbn/cloud-plugin/public'; import type { UsageCollectionSetup, UsageCollectionStart, } from '@kbn/usage-collection-plugin/public'; -import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '@kbn/core/public'; - import type { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; -import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { GlobalSearchPluginSetup } from '@kbn/global-search-plugin/public'; import type { SendRequestResponse } from '@kbn/es-ui-shared-plugin/public'; @@ -52,40 +50,43 @@ import type { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/ import type { DashboardStart } from '@kbn/dashboard-plugin/public'; -import { PLUGIN_ID, INTEGRATIONS_PLUGIN_ID, setupRouteService, appRoutesService } from '../common'; -import { calculateAuthz, calculatePackagePrivilegesFromCapabilities } from '../common/authz'; -import { parseExperimentalConfigValue } from '../common/experimental_features'; -import type { CheckPermissionsResponse, PostFleetSetupResponse } from '../common/types'; import type { FleetAuthz } from '../common'; +import { appRoutesService, INTEGRATIONS_PLUGIN_ID, PLUGIN_ID, setupRouteService } from '../common'; +import { + calculateAuthz, + calculateEndpointExceptionsPrivilegesFromCapabilities, + calculatePackagePrivilegesFromCapabilities, +} from '../common/authz'; import type { ExperimentalFeatures } from '../common/experimental_features'; - -import type { FleetConfigType } from '../common/types'; +import { parseExperimentalConfigValue } from '../common/experimental_features'; +import type { + CheckPermissionsResponse, + FleetConfigType, + PostFleetSetupResponse, +} from '../common/types'; import { API_VERSIONS } from '../common/constants'; import { CUSTOM_LOGS_INTEGRATION_NAME, INTEGRATIONS_BASE_PATH } from './constants'; -import { licenseService } from './hooks'; +import type { RequestError } from './hooks'; +import { licenseService, sendGetBulkAssets } from './hooks'; import { setHttpClient } from './hooks/use_request'; import { createPackageSearchProvider } from './search_provider'; import { TutorialDirectoryHeaderLink, TutorialModuleNotice } from './components/home_integration'; import { createExtensionRegistrationCallback } from './services/ui_extensions'; import { ExperimentalFeaturesService } from './services/experimental_features'; import type { - UIExtensionRegistrationCallback, - UIExtensionsStorage, GetBulkAssetsRequest, GetBulkAssetsResponse, + UIExtensionRegistrationCallback, + UIExtensionsStorage, } from './types'; import { LazyCustomLogsAssetsExtension } from './lazy_custom_logs_assets_extension'; - -export type { FleetConfigType } from '../common/types'; - import { setCustomIntegrations, setCustomIntegrationsStart } from './services/custom_integrations'; - -import type { RequestError } from './hooks'; -import { sendGetBulkAssets } from './hooks'; import { getFleetDeepLinks } from './deep_links'; +export type { FleetConfigType } from '../common/types'; + // We need to provide an object instead of void so that dependent plugins know when Fleet // is disabled. // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -326,6 +327,8 @@ export class FleetPlugin implements Plugin { diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 74af2fe533a9b..37e570648e392 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -75,11 +75,13 @@ export { FLEET_PROXY_SAVED_OBJECT_TYPE, // Authz ENDPOINT_PRIVILEGES, + ENDPOINT_EXCEPTIONS_PRIVILEGES, // Message signing service MESSAGE_SIGNING_SERVICE_API_ROUTES, // secrets SECRETS_ENDPOINT_PATH, SECRETS_MINIMUM_FLEET_SERVER_VERSION, + type PrivilegeMapObject, } from '../../common/constants'; export { diff --git a/x-pack/plugins/fleet/server/services/security/security.ts b/x-pack/plugins/fleet/server/services/security/security.ts index 715d8d966484f..76986768416ff 100644 --- a/x-pack/plugins/fleet/server/services/security/security.ts +++ b/x-pack/plugins/fleet/server/services/security/security.ts @@ -9,22 +9,31 @@ import { pick } from 'lodash'; import type { KibanaRequest } from '@kbn/core/server'; +import type { SecurityPluginStart } from '@kbn/security-plugin/server'; + import { TRANSFORM_PLUGIN_ID } from '../../../common/constants/plugin'; import type { FleetAuthz } from '../../../common'; import { INTEGRATIONS_PLUGIN_ID } from '../../../common'; import { calculateAuthz, + calculateEndpointExceptionsPrivilegesFromKibanaPrivileges, calculatePackagePrivilegesFromKibanaPrivileges, + getAuthorizationFromPrivileges, } from '../../../common/authz'; import { appContextService } from '..'; -import { ENDPOINT_PRIVILEGES, PLUGIN_ID } from '../../constants'; +import { + ENDPOINT_EXCEPTIONS_PRIVILEGES, + ENDPOINT_PRIVILEGES, + PLUGIN_ID, + type PrivilegeMapObject, +} from '../../constants'; import type { FleetAuthzRequirements, - FleetRouteRequiredAuthz, FleetAuthzRouteConfig, + FleetRouteRequiredAuthz, } from './types'; export function checkSecurityEnabled() { @@ -51,31 +60,31 @@ export function checkSuperuser(req: KibanaRequest) { return true; } -function getAuthorizationFromPrivileges( - kibanaPrivileges: Array<{ - resource?: string; - privilege: string; - authorized: boolean; - }>, - searchPrivilege: string -) { - const privilege = kibanaPrivileges.find((p) => p.privilege.includes(searchPrivilege)); - return privilege ? privilege.authorized : false; -} +const computeUiApiPrivileges = ( + security: SecurityPluginStart, + privileges: Record +): string[] => { + return Object.entries(privileges).map( + ([_, { appId, privilegeType, privilegeSplit, privilegeName }]) => { + if (privilegeType === 'ui') { + return security.authz.actions[privilegeType].get(`${appId}`, `${privilegeName}`); + } + return security.authz.actions[privilegeType].get(`${appId}${privilegeSplit}${privilegeName}`); + } + ); +}; export async function getAuthzFromRequest(req: KibanaRequest): Promise { const security = appContextService.getSecurity(); if (security.authz.mode.useRbacForRequest(req)) { const checkPrivileges = security.authz.checkPrivilegesDynamicallyWithRequest(req); - const endpointPrivileges = Object.entries(ENDPOINT_PRIVILEGES).map( - ([_, { appId, privilegeType, privilegeName }]) => { - if (privilegeType === 'ui') { - return security.authz.actions[privilegeType].get(`${appId}`, `${privilegeName}`); - } - return security.authz.actions[privilegeType].get(`${appId}-${privilegeName}`); - } + const endpointPrivileges = computeUiApiPrivileges(security, ENDPOINT_PRIVILEGES); + const endpointExceptionsPrivileges = computeUiApiPrivileges( + security, + ENDPOINT_EXCEPTIONS_PRIVILEGES ); + const { privileges } = await checkPrivileges({ kibana: [ security.authz.actions.api.get(`${PLUGIN_ID}-all`), @@ -87,20 +96,27 @@ export async function getAuthzFromRequest(req: KibanaRequest): Promise = ({ coreStart, deps, appMountParams, isServerless, mlFe kibanaVersion: deps.kibanaVersion, share: deps.share, data: deps.data, + dataViewEditor: deps.dataViewEditor, security: deps.security, licenseManagement: deps.licenseManagement, storage: localStorage, embeddable: deps.embeddable, maps: deps.maps, triggersActionsUi: deps.triggersActionsUi, - dataViewEditor: deps.dataViewEditor, dataVisualizer: deps.dataVisualizer, usageCollection: deps.usageCollection, fieldFormats: deps.fieldFormats, diff --git a/x-pack/plugins/ml/public/application/components/create_data_view_button/create_data_view_button.tsx b/x-pack/plugins/ml/public/application/components/create_data_view_button/create_data_view_button.tsx new file mode 100644 index 0000000000000..069b5589eb058 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/create_data_view_button/create_data_view_button.tsx @@ -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 { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { useMlKibana } from '../../contexts/kibana'; + +export const CreateDataViewButton = ({ + onDataViewCreated, + allowAdHocDataView = false, +}: { + onDataViewCreated: (id: string, type: string, name?: string) => void; + allowAdHocDataView?: boolean; +}) => { + const { dataViewEditor } = useMlKibana().services; + const canEditDataView = Boolean(dataViewEditor?.userPermissions.editDataView()); + const closeDataViewEditorRef = useRef<() => void | undefined>(); + + const createNewDataView = useCallback(() => { + closeDataViewEditorRef.current = dataViewEditor?.openEditor({ + onSave: async (dataView) => { + if (dataView.id && onDataViewCreated) { + onDataViewCreated(dataView.id, 'index-pattern', dataView.name); + } + }, + + allowAdHocDataView, + }); + }, [onDataViewCreated, dataViewEditor, allowAdHocDataView]); + + useEffect(function cleanUpFlyout() { + return () => { + // Close the editor when unmounting + if (closeDataViewEditorRef.current) { + closeDataViewEditorRef.current(); + } + }; + }, []); + + return canEditDataView ? ( + + + + ) : null; +}; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.ts b/x-pack/plugins/ml/public/application/components/create_data_view_button/index.ts similarity index 64% rename from x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.ts rename to x-pack/plugins/ml/public/application/components/create_data_view_button/index.ts index 4b651600e9c9e..ef0244c8a1c17 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.ts +++ b/x-pack/plugins/ml/public/application/components/create_data_view_button/index.ts @@ -5,7 +5,4 @@ * 2.0. */ -import * as t from 'io-ts'; - -export const ReadTagsResponse = t.array(t.string); -export type ReadTagsResponse = t.TypeOf; +export { CreateDataViewButton } from './create_data_view_button'; diff --git a/x-pack/plugins/ml/public/application/components/ml_inference/add_inference_pipeline_flyout.tsx b/x-pack/plugins/ml/public/application/components/ml_inference/add_inference_pipeline_flyout.tsx index f9dc782e1e23a..592df6079603d 100644 --- a/x-pack/plugins/ml/public/application/components/ml_inference/add_inference_pipeline_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_inference/add_inference_pipeline_flyout.tsx @@ -159,7 +159,7 @@ export const AddInferencePipelineFlyout: FC = ( {step === ADD_INFERENCE_PIPELINE_STEPS.TEST && ( )} - {step === ADD_INFERENCE_PIPELINE_STEPS.CREATE && sourceIndex && ( + {step === ADD_INFERENCE_PIPELINE_STEPS.CREATE && ( = ({ @@ -202,7 +202,7 @@ export const ReviewAndCreatePipeline: FC = ({ - {pipelineCreated ? ( + {pipelineCreated && sourceIndex ? ( <> { const onSearchSelected = async ( id: string, type: string, - fullName: string, - savedObject: SavedObjectCommon + fullName?: string, + savedObject?: SavedObjectCommon ) => { // Kibana data views including `:` are cross-cluster search indices // and are not supported by Data Frame Analytics yet. For saved searches @@ -47,7 +48,7 @@ export const SourceSelection: FC = () => { // the selection before redirecting and show an error callout instead. let dataViewName = ''; - if (type === 'index-pattern') { + if (type === 'index-pattern' && savedObject) { dataViewName = getNestedProperty(savedObject, 'attributes.title'); } else if (type === 'search') { try { @@ -71,7 +72,7 @@ export const SourceSelection: FC = () => { } } - if (isCcsIndexPattern(dataViewName)) { + if (isCcsIndexPattern(dataViewName) && savedObject) { setIsCcsCallOut(true); if (type === 'search') { setCcsCallOutBodyText( @@ -157,7 +158,9 @@ export const SourceSelection: FC = () => { contentClient: contentManagement.client, uiSettings, }} - /> + > + + diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/page.tsx index c4b81907e52b9..0bfa0ad5acdb7 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/index_or_search/page.tsx @@ -5,11 +5,12 @@ * 2.0. */ -import React, { FC } from 'react'; +import React, { FC, useCallback } from 'react'; import { EuiPageBody, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { SavedObjectFinder } from '@kbn/saved-objects-finder-plugin/public'; +import { CreateDataViewButton } from '../../../../components/create_data_view_button'; import { useMlKibana, useNavigateToPath } from '../../../../contexts/kibana'; import { MlPageHeader } from '../../../../components/page_header'; @@ -23,13 +24,16 @@ export const Page: FC = ({ nextStepPath }) => { const { contentManagement, uiSettings } = useMlKibana().services; const navigateToPath = useNavigateToPath(); - const onObjectSelection = (id: string, type: string) => { - navigateToPath( - `${nextStepPath}?${type === 'index-pattern' ? 'index' : 'savedSearchId'}=${encodeURIComponent( - id - )}` - ); - }; + const onObjectSelection = useCallback( + (id: string, type: string, name?: string) => { + navigateToPath( + `${nextStepPath}?${ + type === 'index-pattern' ? 'index' : 'savedSearchId' + }=${encodeURIComponent(id)}` + ); + }, + [navigateToPath, nextStepPath] + ); return (
@@ -75,7 +79,9 @@ export const Page: FC = ({ nextStepPath }) => { contentClient: contentManagement.client, uiSettings, }} - /> + > + +
diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 1627fbc13e497..8a0139f0d7782 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -151,7 +151,7 @@ export const JobsListPage: FC = ({ description={ } rightSideItems={[]} diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts index d05af5d609b8c..2803fcefcb6ee 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.test.ts @@ -389,8 +389,12 @@ describe('The metric threshold alert type', () => { }, ]); await execute(Comparator.GT, [0.75]); - expect(mostRecentAction(instanceIdA).action.group).toEqual({ groupByField: 'a' }); - expect(mostRecentAction(instanceIdB).action.group).toEqual({ groupByField: 'b' }); + expect(mostRecentAction(instanceIdA).action.group).toEqual([ + { field: 'groupByField', value: 'a' }, + ]); + expect(mostRecentAction(instanceIdB).action.group).toEqual([ + { field: 'groupByField', value: 'b' }, + ]); }); test('persists previous groups that go missing, until the groupBy param changes', async () => { setEvaluationResults([ diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts index 6f0f146f2a267..1f57c215e51a7 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/custom_threshold_executor.ts @@ -8,7 +8,12 @@ import { isEqual } from 'lodash'; import { TypeOf } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; -import { ALERT_ACTION_GROUP, ALERT_EVALUATION_VALUES, ALERT_REASON } from '@kbn/rule-data-utils'; +import { + ALERT_ACTION_GROUP, + ALERT_EVALUATION_VALUES, + ALERT_REASON, + ALERT_GROUP, +} from '@kbn/rule-data-utils'; import { LocatorPublic } from '@kbn/share-plugin/common'; import { ActionGroupIdsOf, @@ -37,7 +42,7 @@ import { hasAdditionalContext, validGroupByForContext, flattenAdditionalContext, - getGroupByObject, + getFormattedGroupBy, } from './utils'; import { EvaluatedRuleParams, evaluateRule } from './lib/evaluate_rule'; @@ -80,12 +85,18 @@ type MetricThresholdAlert = Alert< MetricThresholdAllowedActionGroups >; +export type Group = Array<{ + field: string; + value: string; +}>; + type MetricThresholdAlertFactory = ( id: string, reason: string, actionGroup: MetricThresholdActionGroup, additionalContext?: AdditionalContext | null, - evaluationValues?: Array + evaluationValues?: Array, + group?: Group ) => MetricThresholdAlert; export const createMetricThresholdExecutor = ({ @@ -139,7 +150,8 @@ export const createMetricThresholdExecutor = ({ reason, actionGroup, additionalContext, - evaluationValues + evaluationValues, + group ) => alertWithLifecycle({ id, @@ -147,6 +159,7 @@ export const createMetricThresholdExecutor = ({ [ALERT_REASON]: reason, [ALERT_ACTION_GROUP]: actionGroup, [ALERT_EVALUATION_VALUES]: evaluationValues, + [ALERT_GROUP]: group, ...flattenAdditionalContext(additionalContext), }, }); @@ -198,7 +211,7 @@ export const createMetricThresholdExecutor = ({ } } - const groupByKeysObjectMapping = getGroupByObject(params.groupBy, resultGroupSet); + const groupByKeysObjectMapping = getFormattedGroupBy(params.groupBy, resultGroupSet); const groups = [...resultGroupSet]; const nextMissingGroups = new Set(); const hasGroups = !isEqual(groups, [UNGROUPED_FACTORY_KEY]); @@ -292,7 +305,8 @@ export const createMetricThresholdExecutor = ({ reason, actionGroupId, additionalContext, - evaluationValues + evaluationValues, + groupByKeysObjectMapping[group] ); const alertUuid = getAlertUuid(group); const indexedStartedAt = getAlertStartedDate(group) ?? startedAt.toISOString(); @@ -325,7 +339,7 @@ export const createMetricThresholdExecutor = ({ const { getRecoveredAlerts } = services.alertFactory.done(); const recoveredAlerts = getRecoveredAlerts(); - const groupByKeysObjectForRecovered = getGroupByObject( + const groupByKeysObjectForRecovered = getFormattedGroupBy( params.groupBy, new Set(recoveredAlerts.map((recoveredAlert) => recoveredAlert.getId())) ); diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts index 5ebe33ca88ef9..dcfe754e6243d 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/register_custom_threshold_rule_type.ts @@ -147,7 +147,7 @@ export function thresholdRuleType( doesSetRecoveryContext: true, actionVariables: { context: [ - { name: 'groupings', description: groupByKeysActionVariableDescription }, + { name: 'group', description: groupByKeysActionVariableDescription }, { name: 'alertDetailsUrl', description: alertDetailUrlActionVariableDescription, diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/utils.test.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/utils.test.ts index a6e131d5493af..96eaebdd2b92f 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/utils.test.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { flattenObject, validateKQLStringFilter } from './utils'; +import { flattenObject, getFormattedGroupBy, validateKQLStringFilter } from './utils'; describe('FlattenObject', () => { it('flattens multi level item', () => { @@ -69,3 +69,48 @@ describe('validateKQLStringFilter', () => { expect(validateKQLStringFilter(input)).toEqual(output); }); }); + +describe('getFormattedGroupBy', () => { + it('should format groupBy correctly for empty input', () => { + expect(getFormattedGroupBy(undefined, new Set())).toEqual({}); + }); + + it('should format groupBy correctly for multiple groups', () => { + expect( + getFormattedGroupBy( + ['host.name', 'host.mac', 'tags', 'container.name'], + new Set([ + 'host-0,00-00-5E-00-53-23,event-0,container-name', + 'host-0,00-00-5E-00-53-23,group-0,container-name', + 'host-0,00-00-5E-00-53-24,event-0,container-name', + 'host-0,00-00-5E-00-53-24,group-0,container-name', + ]) + ) + ).toEqual({ + 'host-0,00-00-5E-00-53-23,event-0,container-name': [ + { field: 'host.name', value: 'host-0' }, + { field: 'host.mac', value: '00-00-5E-00-53-23' }, + { field: 'tags', value: 'event-0' }, + { field: 'container.name', value: 'container-name' }, + ], + 'host-0,00-00-5E-00-53-23,group-0,container-name': [ + { field: 'host.name', value: 'host-0' }, + { field: 'host.mac', value: '00-00-5E-00-53-23' }, + { field: 'tags', value: 'group-0' }, + { field: 'container.name', value: 'container-name' }, + ], + 'host-0,00-00-5E-00-53-24,event-0,container-name': [ + { field: 'host.name', value: 'host-0' }, + { field: 'host.mac', value: '00-00-5E-00-53-24' }, + { field: 'tags', value: 'event-0' }, + { field: 'container.name', value: 'container-name' }, + ], + 'host-0,00-00-5E-00-53-24,group-0,container-name': [ + { field: 'host.name', value: 'host-0' }, + { field: 'host.mac', value: '00-00-5E-00-53-24' }, + { field: 'tags', value: 'group-0' }, + { field: 'container.name', value: 'container-name' }, + ], + }); + }); +}); diff --git a/x-pack/plugins/observability/server/lib/rules/custom_threshold/utils.ts b/x-pack/plugins/observability/server/lib/rules/custom_threshold/utils.ts index 854b7cc497502..db666f17a00f1 100644 --- a/x-pack/plugins/observability/server/lib/rules/custom_threshold/utils.ts +++ b/x-pack/plugins/observability/server/lib/rules/custom_threshold/utils.ts @@ -16,6 +16,7 @@ import { ES_FIELD_TYPES } from '@kbn/field-types'; import { set } from '@kbn/safer-lodash-set'; import { ParsedExperimentalFields } from '@kbn/rule-registry-plugin/common/parse_experimental_fields'; import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common'; +import { Group } from './custom_threshold_executor'; import { ObservabilityConfig } from '../../..'; import { AlertExecutionDetails } from './types'; @@ -227,21 +228,20 @@ export const flattenObject = (obj: AdditionalContext, prefix: string = ''): Addi return acc; }, {}); -export const getGroupByObject = ( +export const getFormattedGroupBy = ( groupBy: string | string[] | undefined, - resultGroupSet: Set -): Record => { - const groupByKeysObjectMapping: Record = {}; + groupSet: Set +): Record => { + const groupByKeysObjectMapping: Record = {}; if (groupBy) { - resultGroupSet.forEach((groupSet) => { - const groupSetKeys = groupSet.split(','); - groupByKeysObjectMapping[groupSet] = unflattenObject( - Array.isArray(groupBy) - ? groupBy.reduce((result, group, index) => { - return { ...result, [group]: groupSetKeys[index]?.trim() }; - }, {}) - : { [groupBy]: groupSet } - ); + groupSet.forEach((group) => { + const groupSetKeys = group.split(','); + groupByKeysObjectMapping[group] = Array.isArray(groupBy) + ? groupBy.reduce((result: Group, groupByItem, index) => { + result.push({ field: groupByItem, value: groupSetKeys[index]?.trim() }); + return result; + }, []) + : [{ field: groupBy, value: group }]; }); } return groupByKeysObjectMapping; diff --git a/x-pack/plugins/observability_onboarding/public/components/app/home/index.tsx b/x-pack/plugins/observability_onboarding/public/components/app/home/index.tsx index d1b05433b9d82..612a0c5710c13 100644 --- a/x-pack/plugins/observability_onboarding/public/components/app/home/index.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/app/home/index.tsx @@ -18,6 +18,7 @@ import { EuiText, EuiTitle, useEuiTheme, + EuiPanel, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public'; @@ -41,12 +42,7 @@ const StyledItem = styled(EuiFlexItem)` flex-direction: row; &:before { content: '•'; - width: 5px; - height: 5px; - margin: 0 20px 0 16px; - } - > a { - min-width: 100%; + margin-right: 20px; } `; @@ -79,288 +75,313 @@ export function Home() { }; return ( - - - - -

- {i18n.translate('xpack.observability_onboarding.home.title', { - defaultMessage: 'Collect and analyze logs', - })} -

-
-
- - -

- {i18n.translate('xpack.observability_onboarding.home.description', { - defaultMessage: - 'Select your method for collecting data into Observability.', - })} -

-
- -
- - - - } - betaBadgeProps={{ - 'data-test-subj': 'obltOnboardingHomeQuickstartBadge', - color: 'accent', - label: i18n.translate( - 'xpack.observability_onboarding.card.systemLogs.quickstartBadge', - { defaultMessage: 'Quickstart' } - ), - }} - title={i18n.translate( - 'xpack.observability_onboarding.card.systemLogs.title', - { defaultMessage: 'Stream host system logs' } - )} - footer={ - - {getStartedLabel} - - } - style={{ - borderColor: euiTheme.colors.accent, - borderWidth: 2, - }} - paddingSize="l" - display="plain" - hasBorder - onClick={handleClickSystemLogs} - > - - {elasticAgentLabel} - - -

- {i18n.translate( - 'xpack.observability_onboarding.card.systemLogs.description1', - { - defaultMessage: - 'The quickest path to onboard log data from your own machine or server.', - } - )} -

-
- -
-
- - } - title={i18n.translate( - 'xpack.observability_onboarding.card.customLogs.title', - { defaultMessage: 'Stream log files' } - )} - footer={ - - {getStartedLabel} - - } - paddingSize="l" - display="plain" - hasBorder - onClick={handleClickCustomLogs} - > - - {elasticAgentLabel} - - -

- {i18n.translate( - 'xpack.observability_onboarding.card.customLogs.description.text', - { - defaultMessage: - 'Stream any logs into Elastic in a simple way and explore their data.', - } - )} -

-
- -
-
-
-
- - - - - - - - } - title={i18n.translate( - 'xpack.observability_onboarding.card.apm.title', - { - defaultMessage: 'Collect application performance data', - } - )} - description={i18n.translate( - 'xpack.observability_onboarding.card.apm.description', + + + + + +

+ {i18n.translate('xpack.observability_onboarding.home.title', { + defaultMessage: 'Collect and analyze logs', + })} +

+
+
+ + +

+ {i18n.translate( + 'xpack.observability_onboarding.home.description', { defaultMessage: - 'Collect traces, logs, and metrics from OpenTelemetry or APM custom agent.', + 'Select your method for collecting data into Observability.', } )} - footer={ - - {getStartedLabel} - - } - paddingSize="m" - titleSize="xs" - display="plain" - hasBorder - onClick={handleClickApmSetupGuide} - /> - - - } - title={i18n.translate( - 'xpack.observability_onboarding.card.k8s.title', - { defaultMessage: 'Collect Kubernetes clusters data' } - )} - description={i18n.translate( - 'xpack.observability_onboarding.card.k8s.description', - { - defaultMessage: - 'Collect logs and metrics from Kubernetes clusters with Elastic agent.', +

+
+ +
+ + + + } + betaBadgeProps={{ + 'data-test-subj': 'obltOnboardingHomeQuickstartBadge', + color: 'accent', + label: i18n.translate( + 'xpack.observability_onboarding.card.systemLogs.quickstartBadge', + { defaultMessage: 'Quick start' } + ), + }} + title={i18n.translate( + 'xpack.observability_onboarding.card.systemLogs.title', + { defaultMessage: 'Stream host system logs' } + )} + footer={ + + {getStartedLabel} + } - )} - footer={ + style={{ + borderColor: euiTheme.colors.accent, + borderWidth: 2, + }} + paddingSize="m" + display="plain" + hasBorder + onClick={handleClickSystemLogs} + > + + {elasticAgentLabel} + + +

+ {i18n.translate( + 'xpack.observability_onboarding.card.systemLogs.description1', + { + defaultMessage: + 'The quickest path to onboard log data from your own machine or server.', + } + )} +

+
+ +
+
+ + } + title={i18n.translate( + 'xpack.observability_onboarding.card.customLogs.title', + { defaultMessage: 'Stream log files' } + )} + footer={ + + {getStartedLabel} + + } + paddingSize="m" + display="plain" + hasBorder + onClick={handleClickCustomLogs} + > + + {elasticAgentLabel} + + +

+ {i18n.translate( + 'xpack.observability_onboarding.card.customLogs.description.text', + { + defaultMessage: + 'Stream any logs into Elastic in a simple way and explore their data.', + } + )} +

+
+ +
+
+
+
+ + + + + + + + } + title={i18n.translate( + 'xpack.observability_onboarding.card.apm.title', + { + defaultMessage: 'Collect application performance data', + } + )} + description={ + +

+ {i18n.translate( + 'xpack.observability_onboarding.card.apm.description', + { + defaultMessage: + 'Collect traces, logs, and metrics from OpenTelemetry or APM custom agent.', + } + )} +

+
+ } + footer={ + + {getStartedLabel} + + } + paddingSize="m" + titleSize="xs" + display="plain" + hasBorder + onClick={handleClickApmSetupGuide} + /> +
+ + } + title={i18n.translate( + 'xpack.observability_onboarding.card.k8s.title', + { defaultMessage: 'Collect Kubernetes clusters data' } + )} + description={ + +

+ {i18n.translate( + 'xpack.observability_onboarding.card.k8s.description', + { + defaultMessage: + 'Collect logs and metrics from Kubernetes clusters with Elastic agent.', + } + )} +

+
+ } + footer={ + + {getStartedLabel} + + } + titleSize="xs" + paddingSize="m" + display="plain" + hasBorder + onClick={handleClickKubernetesSetupGuide} + /> +
+
+
+ + + + + + + + +
+ } + title={i18n.translate( + 'xpack.observability_onboarding.card.integrations.title', + { + defaultMessage: + 'Explore 300+ ways of ingesting data with our integrations', + } + )} + footer={ + <> - {getStartedLabel} - - } - titleSize="xs" - paddingSize="m" - display="plain" - hasBorder - onClick={handleClickKubernetesSetupGuide} - /> -
- - - - - - - - - - - - } - title={i18n.translate( - 'xpack.observability_onboarding.card.integrations.title', - { - defaultMessage: - 'Explore 300+ ways of ingesting data with our integrations', - } - )} - footer={ - <> - - {i18n.translate( - 'xpack.observability_onboarding.card.integrations.start', - { defaultMessage: 'Start exploring' } - )} - - - - {i18n.translate( - 'xpack.observability_onboarding.card.integrations.quickLinks', - { defaultMessage: 'Quick links:' } + 'xpack.observability_onboarding.card.integrations.start', + { defaultMessage: 'Start exploring' } )} - - - - + + + + {i18n.translate( + 'xpack.observability_onboarding.card.integrations.quickLinks', + { defaultMessage: 'Quick links:' } + )} + + + - {i18n.translate( - 'xpack.observability_onboarding.card.integrations.sampleData', - { defaultMessage: 'Use sample data' } - )} - - - - {i18n.translate( - 'xpack.observability_onboarding.card.integrations.uploadFile', - { defaultMessage: 'Upload a file' } - )} - - - - - {i18n.translate( - 'xpack.observability_onboarding.card.integrations.awsFirehose', - { defaultMessage: 'AWS Firehose' } - )} - - - - - - - - } - titleSize="xs" - paddingSize="none" - display="plain" - hasBorder - /> - - + + + {i18n.translate( + 'xpack.observability_onboarding.card.integrations.sampleData', + { defaultMessage: 'Use sample data' } + )} + + + + + + {i18n.translate( + 'xpack.observability_onboarding.card.integrations.uploadFile', + { defaultMessage: 'Upload a file' } + )} + + + + + + + {i18n.translate( + 'xpack.observability_onboarding.card.integrations.awsFirehose', + { defaultMessage: 'AWS Firehose' } + )} + + + + + + + + + } + titleSize="xs" + paddingSize="m" + display="plain" + hasBorder + /> + + + ); } diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.gen.ts new file mode 100644 index 0000000000000..4ca5d3a41b6fa --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.gen.ts @@ -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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +import { RuleSignatureId } from './rule_schema/common_attributes.gen'; + +export type ErrorSchema = z.infer; +export const ErrorSchema = z + .object({ + id: z.string().optional(), + rule_id: RuleSignatureId.optional(), + list_id: z.string().min(1).optional(), + item_id: z.string().min(1).optional(), + error: z.object({ + status_code: z.number().int().min(400), + message: z.string(), + }), + }) + .strict(); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.schema.yaml index 7e9a11ccf56dc..1bcffdd010bd9 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.schema.yaml @@ -4,12 +4,13 @@ info: version: 'not applicable' paths: {} components: - x-codegen-enabled: false + x-codegen-enabled: true schemas: ErrorSchema: type: object required: - error + additionalProperties: false properties: id: type: string diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.test.ts index 4d5bf2c3f2947..8326479db9c14 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.test.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.test.ts @@ -8,14 +8,13 @@ import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; import { left } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; -import type { ErrorSchema } from './error_schema'; -import { errorSchema } from './error_schema'; +import { ErrorSchema } from './error_schema'; import { getErrorSchemaMock } from './error_schema.mock'; describe('error_schema', () => { test('it should validate an error with a UUID given for id', () => { const error = getErrorSchemaMock(); - const decoded = errorSchema.decode(getErrorSchemaMock()); + const decoded = ErrorSchema.decode(getErrorSchemaMock()); const checked = exactCheck(error, decoded); const message = pipe(checked, foldLeftRight); @@ -25,7 +24,7 @@ describe('error_schema', () => { test('it should validate an error with a plain string given for id since sometimes we echo the user id which might not be a UUID back out to them', () => { const error = getErrorSchemaMock('fake id'); - const decoded = errorSchema.decode(error); + const decoded = ErrorSchema.decode(error); const checked = exactCheck(error, decoded); const message = pipe(checked, foldLeftRight); @@ -37,7 +36,7 @@ describe('error_schema', () => { type InvalidError = ErrorSchema & { invalid_extra_data?: string }; const error: InvalidError = getErrorSchemaMock(); error.invalid_extra_data = 'invalid_extra_data'; - const decoded = errorSchema.decode(error); + const decoded = ErrorSchema.decode(error); const checked = exactCheck(error, decoded); const message = pipe(checked, foldLeftRight); @@ -49,7 +48,7 @@ describe('error_schema', () => { const error = getErrorSchemaMock(); // @ts-expect-error delete error.error; - const decoded = errorSchema.decode(error); + const decoded = ErrorSchema.decode(error); const checked = exactCheck(error, decoded); const message = pipe(checked, foldLeftRight); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.ts index e6f2fecbf7e16..53114d500bc21 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/error_schema.ts @@ -31,5 +31,5 @@ const required = t.exact( }) ); -export const errorSchema = t.intersection([partial, required]); -export type ErrorSchema = t.TypeOf; +export const ErrorSchema = t.intersection([partial, required]); +export type ErrorSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts new file mode 100644 index 0000000000000..5ce6fe1bc4727 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.gen.ts @@ -0,0 +1,207 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +/** + * A universally unique identifier + */ +export type UUID = z.infer; +export const UUID = z.string(); + +export type RuleObjectId = z.infer; +export const RuleObjectId = z.string(); + +/** + * Could be any string, not necessarily a UUID + */ +export type RuleSignatureId = z.infer; +export const RuleSignatureId = z.string(); + +export type RuleName = z.infer; +export const RuleName = z.string().min(1); + +export type RuleDescription = z.infer; +export const RuleDescription = z.string().min(1); + +export type RuleVersion = z.infer; +export const RuleVersion = z.string(); + +export type IsRuleImmutable = z.infer; +export const IsRuleImmutable = z.boolean(); + +export type IsRuleEnabled = z.infer; +export const IsRuleEnabled = z.boolean(); + +export type RuleTagArray = z.infer; +export const RuleTagArray = z.array(z.string()); + +export type RuleMetadata = z.infer; +export const RuleMetadata = z.object({}); + +export type RuleLicense = z.infer; +export const RuleLicense = z.string(); + +export type RuleAuthorArray = z.infer; +export const RuleAuthorArray = z.array(z.string()); + +export type RuleFalsePositiveArray = z.infer; +export const RuleFalsePositiveArray = z.array(z.string()); + +export type RuleReferenceArray = z.infer; +export const RuleReferenceArray = z.array(z.string()); + +export type InvestigationGuide = z.infer; +export const InvestigationGuide = z.string(); + +export type SetupGuide = z.infer; +export const SetupGuide = z.string(); + +export type BuildingBlockType = z.infer; +export const BuildingBlockType = z.string(); + +export type AlertsIndex = z.infer; +export const AlertsIndex = z.string(); + +export type AlertsIndexNamespace = z.infer; +export const AlertsIndexNamespace = z.string(); + +export type MaxSignals = z.infer; +export const MaxSignals = z.number().int().min(1); + +export type Subtechnique = z.infer; +export const Subtechnique = z.object({ + /** + * Subtechnique ID + */ + id: z.string(), + /** + * Subtechnique name + */ + name: z.string(), + /** + * Subtechnique reference + */ + reference: z.string(), +}); + +export type Technique = z.infer; +export const Technique = z.object({ + /** + * Technique ID + */ + id: z.string(), + /** + * Technique name + */ + name: z.string(), + /** + * Technique reference + */ + reference: z.string(), + /** + * Array containing more specific information on the attack technique + */ + subtechnique: z.array(Subtechnique).optional(), +}); + +export type Threat = z.infer; +export const Threat = z.object({ + /** + * Relevant attack framework + */ + framework: z.string(), + tactic: z.object({ + /** + * Tactic ID + */ + id: z.string(), + /** + * Tactic name + */ + name: z.string(), + /** + * Tactic reference + */ + reference: z.string(), + }), + /** + * Array containing information on the attack techniques (optional) + */ + technique: z.array(Technique).optional(), +}); + +export type ThreatArray = z.infer; +export const ThreatArray = z.array(Threat); + +export type IndexPatternArray = z.infer; +export const IndexPatternArray = z.array(z.string()); + +export type DataViewId = z.infer; +export const DataViewId = z.string(); + +export type RuleQuery = z.infer; +export const RuleQuery = z.string(); + +export type RuleFilterArray = z.infer; +export const RuleFilterArray = z.array(z.object({})); + +export type RuleNameOverride = z.infer; +export const RuleNameOverride = z.string(); + +export type TimestampOverride = z.infer; +export const TimestampOverride = z.string(); + +export type TimestampOverrideFallbackDisabled = z.infer; +export const TimestampOverrideFallbackDisabled = z.boolean(); + +export type RequiredField = z.infer; +export const RequiredField = z.object({ + name: z.string().min(1).optional(), + type: z.string().min(1).optional(), + ecs: z.boolean().optional(), +}); + +export type RequiredFieldArray = z.infer; +export const RequiredFieldArray = z.array(RequiredField); + +export type TimelineTemplateId = z.infer; +export const TimelineTemplateId = z.string(); + +export type TimelineTemplateTitle = z.infer; +export const TimelineTemplateTitle = z.string(); + +export type SavedObjectResolveOutcome = z.infer; +export const SavedObjectResolveOutcome = z.enum(['exactMatch', 'aliasMatch', 'conflict']); +export const SavedObjectResolveOutcomeEnum = SavedObjectResolveOutcome.enum; +export type SavedObjectResolveOutcomeEnum = typeof SavedObjectResolveOutcome.enum; + +export type SavedObjectResolveAliasTargetId = z.infer; +export const SavedObjectResolveAliasTargetId = z.string(); + +export type SavedObjectResolveAliasPurpose = z.infer; +export const SavedObjectResolveAliasPurpose = z.enum([ + 'savedObjectConversion', + 'savedObjectImport', +]); +export const SavedObjectResolveAliasPurposeEnum = SavedObjectResolveAliasPurpose.enum; +export type SavedObjectResolveAliasPurposeEnum = typeof SavedObjectResolveAliasPurpose.enum; + +export type RelatedIntegration = z.infer; +export const RelatedIntegration = z.object({ + package: z.string().min(1), + version: z.string().min(1), + integration: z.string().min(1).optional(), +}); + +export type RelatedIntegrationArray = z.infer; +export const RelatedIntegrationArray = z.array(RelatedIntegration); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml index 0e5a602e71018..52d59c3a656d6 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/common_attributes.schema.yaml @@ -4,7 +4,7 @@ info: version: 'not applicable' paths: {} components: - x-codegen-enabled: false + x-codegen-enabled: true schemas: UUID: type: string diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts new file mode 100644 index 0000000000000..4615a7b0b466f --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.gen.ts @@ -0,0 +1,547 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +export type Action = z.infer; +export const Action = z.object({ + /** + * The action type used for sending notifications. + */ + action_type_id: z.string(), + /** + * Optionally groups actions by use cases. Use `default` for alert notifications. + */ + group: z.string(), + /** + * The connector ID. + */ + id: z.string(), + /** + * Object containing the allowed connector fields, which varies according to the connector type. + */ + params: z.object({}), + uuid: z.string().optional(), + /** + * TODO implement the schema type + */ + alerts_filter: z.object({}).optional(), + /** + * TODO implement the schema type + */ + frequency: z.object({}).optional(), +}); + +export type AlertSuppression = z.infer; +export const AlertSuppression = z.object({ + group_by: z.array(z.string()).min(1).max(3), + duration: z + .object({ + value: z.number().int().min(1), + unit: z.enum(['s', 'm', 'h']), + }) + .optional(), + missing_fields_strategy: z.enum(['doNotSuppress', 'suppress']).optional(), +}); + +export type BaseRule = z.infer; +export const BaseRule = z.object({ + /** + * Rule name + */ + name: z.string(), + /** + * Rule description + */ + description: z.string(), + /** + * Risk score (0 to 100) + */ + risk_score: z.number().int().min(0).max(100), + /** + * Severity of the rule + */ + severity: z.enum(['low', 'medium', 'high', 'critical']), + /** + * Sets the source field for the alert's signal.rule.name value + */ + rule_name_override: z.string().optional(), + /** + * Sets the time field used to query indices (optional) + */ + timestamp_override: z.string().optional(), + /** + * Timeline template ID + */ + timeline_id: z.string().optional(), + /** + * Timeline template title + */ + timeline_title: z.string().optional(), + outcome: z.enum(['exactMatch', 'aliasMatch', 'conflict']).optional(), + /** + * TODO + */ + alias_target_id: z.string().optional(), + /** + * TODO + */ + alias_purpose: z.enum(['savedObjectConversion', 'savedObjectImport']).optional(), + /** + * The rule’s license. + */ + license: z.string().optional(), + /** + * Notes to help investigate alerts produced by the rule. + */ + note: z.string().optional(), + /** + * Determines if the rule acts as a building block. By default, building-block alerts are not displayed in the UI. These rules are used as a foundation for other rules that do generate alerts. Its value must be default. + */ + building_block_type: z.string().optional(), + /** + * (deprecated) Has no effect. + */ + output_index: z.string().optional(), + /** + * Has no effect. + */ + namespace: z.string().optional(), + /** + * Stores rule metadata. + */ + meta: z.object({}).optional(), + /** + * Defines the interval on which a rule's actions are executed. + */ + throttle: z.string().optional(), + /** + * The rule’s version number. Defaults to 1. + */ + version: z.number().int().min(1).optional().default(1), + /** + * String array containing words and phrases to help categorize, filter, and search rules. Defaults to an empty array. + */ + tags: z.array(z.string()).optional().default([]), + /** + * Determines whether the rule is enabled. Defaults to true. + */ + enabled: z.boolean().optional().default(true), + /** + * Overrides generated alerts' risk_score with a value from the source event + */ + risk_score_mapping: z + .array( + z.object({ + field: z.string(), + operator: z.enum(['equals']), + value: z.string(), + risk_score: z.number().int().min(0).max(100).optional(), + }) + ) + .optional() + .default([]), + /** + * Overrides generated alerts' severity with values from the source event + */ + severity_mapping: z + .array( + z.object({ + field: z.string(), + operator: z.enum(['equals']), + severity: z.enum(['low', 'medium', 'high', 'critical']), + value: z.string(), + }) + ) + .optional() + .default([]), + /** + * Frequency of rule execution, using a date math range. For example, "1h" means the rule runs every hour. Defaults to 5m (5 minutes). + */ + interval: z.string().optional().default('5m'), + /** + * Time from which data is analyzed each time the rule executes, using a date math range. For example, now-4200s means the rule analyzes data from 70 minutes before its start time. Defaults to now-6m (analyzes data from 6 minutes before the start time). + */ + from: z.string().optional().default('now-6m'), + /** + * TODO + */ + to: z.string().optional().default('now'), + actions: z.array(Action).optional().default([]), + exceptions_list: z + .array( + z.object({ + /** + * ID of the exception container + */ + id: z.string().min(1), + /** + * List ID of the exception container + */ + list_id: z.string().min(1), + /** + * The exception type + */ + type: z.enum([ + 'detection', + 'rule_default', + 'endpoint', + 'endpoint_trusted_apps', + 'endpoint_events', + 'endpoint_host_isolation_exceptions', + 'endpoint_blocklists', + ]), + /** + * Determines the exceptions validity in rule's Kibana space + */ + namespace_type: z.enum(['agnostic', 'single']), + }) + ) + .optional() + .default([]), + author: z.array(z.string()).optional().default([]), + false_positives: z.array(z.string()).optional().default([]), + references: z.array(z.string()).optional().default([]), + max_signals: z.number().int().min(1).optional().default(100), + threat: z + .array( + z.object({ + /** + * Relevant attack framework + */ + framework: z.string(), + tactic: z.object({ + id: z.string(), + name: z.string(), + reference: z.string(), + }), + technique: z + .array( + z.object({ + id: z.string(), + name: z.string(), + reference: z.string(), + subtechnique: z + .array( + z.object({ + id: z.string(), + name: z.string(), + reference: z.string(), + }) + ) + .optional(), + }) + ) + .optional(), + }) + ) + .optional(), +}); + +export type QueryRule = z.infer; +export const QueryRule = BaseRule.and( + z.object({ + /** + * Rule type + */ + type: z.enum(['query']), + index: z.array(z.string()).optional(), + data_view_id: z.string().optional(), + filters: z.array(z.unknown()).optional(), + saved_id: z.string().optional(), + /** + * TODO + */ + response_actions: z.array(z.object({})).optional(), + alert_suppression: AlertSuppression.optional(), + /** + * Query to execute + */ + query: z.string().optional().default(''), + /** + * Query language to use. + */ + language: z.enum(['kuery', 'lucene']).optional().default('kuery'), + }) +); + +export type SavedQueryRule = z.infer; +export const SavedQueryRule = BaseRule.and( + z.object({ + /** + * Rule type + */ + type: z.enum(['saved_query']), + index: z.array(z.string()).optional(), + data_view_id: z.string().optional(), + filters: z.array(z.unknown()).optional(), + saved_id: z.string(), + /** + * TODO + */ + response_actions: z.array(z.object({})).optional(), + alert_suppression: AlertSuppression.optional(), + /** + * Query to execute + */ + query: z.string().optional(), + /** + * Query language to use. + */ + language: z.enum(['kuery', 'lucene']).optional().default('kuery'), + }) +); + +export type ThresholdRule = z.infer; +export const ThresholdRule = BaseRule.and( + z.object({ + /** + * Rule type + */ + type: z.enum(['threshold']), + query: z.string(), + threshold: z.object({ + /** + * Field to aggregate on + */ + field: z.union([z.string(), z.array(z.string())]), + /** + * Threshold value + */ + value: z.number().int().min(1).optional(), + cardinality: z + .array( + z.object({ + field: z.string().optional(), + value: z.number().int().min(0).optional(), + }) + ) + .optional(), + }), + index: z.array(z.string()).optional(), + data_view_id: z.string().optional(), + filters: z.array(z.unknown()).optional(), + saved_id: z.string().optional(), + /** + * Query language to use. + */ + language: z.enum(['kuery', 'lucene']).optional().default('kuery'), + }) +); + +export type ThreatMatchRule = z.infer; +export const ThreatMatchRule = BaseRule.and( + z.object({ + /** + * Rule type + */ + type: z.enum(['threat_match']), + query: z.string(), + /** + * Query to execute + */ + threat_query: z.string(), + threat_mapping: z + .array( + z.object({ + entries: z + .array( + z.object({ + field: z.string().min(1).optional(), + type: z.enum(['mapping']).optional(), + value: z.string().min(1).optional(), + }) + ) + .optional(), + }) + ) + .min(1), + threat_index: z.array(z.string()), + index: z.array(z.string()).optional(), + data_view_id: z.string().optional(), + filters: z.array(z.unknown()).optional(), + saved_id: z.string().optional(), + threat_filters: z.array(z.unknown()).optional(), + /** + * Defines the path to the threat indicator in the indicator documents (optional) + */ + threat_indicator_path: z.string().optional(), + /** + * Query language to use. + */ + threat_language: z.enum(['kuery', 'lucene']).optional(), + concurrent_searches: z.number().int().min(1).optional(), + items_per_search: z.number().int().min(1).optional(), + /** + * Query language to use. + */ + language: z.enum(['kuery', 'lucene']).optional().default('kuery'), + }) +); + +export type MlRule = z.infer; +export const MlRule = BaseRule.and( + z.object({ + /** + * Rule type + */ + type: z.enum(['machine_learning']), + /** + * Anomaly threshold + */ + anomaly_threshold: z.number().int().min(0), + /** + * Machine learning job ID + */ + machine_learning_job_id: z.union([z.string(), z.array(z.string()).min(1)]), + }) +); + +export type EqlRule = z.infer; +export const EqlRule = BaseRule.and( + z.object({ + /** + * Rule type + */ + type: z.enum(['eql']), + language: z.enum(['eql']), + /** + * EQL query to execute + */ + query: z.string(), + index: z.array(z.string()).optional(), + data_view_id: z.string().optional(), + filters: z.array(z.unknown()).optional(), + /** + * Contains the event classification + */ + event_category_field: z.string().optional(), + /** + * Sets a secondary field for sorting events + */ + tiebreaker_field: z.string().optional(), + /** + * Contains the event timestamp used for sorting a sequence of events + */ + timestamp_field: z.string().optional(), + }) +); + +export type NewTermsRule = z.infer; +export const NewTermsRule = BaseRule.and( + z.object({ + /** + * Rule type + */ + type: z.enum(['new_terms']), + query: z.string(), + new_terms_fields: z.array(z.string()).min(1).max(3), + history_window_size: z.string().min(1).optional(), + index: z.array(z.string()).optional(), + data_view_id: z.string().optional(), + filters: z.array(z.unknown()).optional(), + language: z.enum(['kuery', 'lucene']).optional().default('kuery'), + }) +); + +export type Rule = z.infer; +export const Rule = z.union([ + QueryRule, + SavedQueryRule, + ThresholdRule, + ThreatMatchRule, + MlRule, + EqlRule, + NewTermsRule, +]); + +/** + * Defines the maximum interval in which a rule's actions are executed. + */ +export type Throttle = z.infer; +export const Throttle = z.enum(['rule', '1h', '1d', '7d']); +export const ThrottleEnum = Throttle.enum; +export type ThrottleEnum = typeof Throttle.enum; + +export type Subtechnique = z.infer; +export const Subtechnique = z.object({ + /** + * Subtechnique ID + */ + id: z.string(), + /** + * Subtechnique name + */ + name: z.string(), + /** + * Subtechnique reference + */ + reference: z.string(), +}); + +export type Technique = z.infer; +export const Technique = z.object({ + /** + * Technique ID + */ + id: z.string(), + /** + * Technique name + */ + name: z.string(), + /** + * Technique reference + */ + reference: z.string(), + /** + * Array containing more specific information on the attack technique + */ + subtechnique: z.array(Subtechnique).optional(), +}); + +export type Threat = z.infer; +export const Threat = z.object({ + /** + * Relevant attack framework + */ + framework: z.string(), + tactic: z.object({ + /** + * Tactic ID + */ + id: z.string(), + /** + * Tactic name + */ + name: z.string(), + /** + * Tactic reference + */ + reference: z.string(), + }), + /** + * Array containing information on the attack techniques (optional) + */ + technique: z.array(Technique).optional(), +}); + +export type RuleResponse = z.infer; +export const RuleResponse = z.object({}); + +export type RuleCreateProps = z.infer; +export const RuleCreateProps = z.object({}); + +export type RuleUpdateProps = z.infer; +export const RuleUpdateProps = z.object({}); + +export type RulePatchProps = z.infer; +export const RulePatchProps = z.object({}); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml index a4cdcae498e7a..16cc2aec5cf2b 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/rule_schema/rule_schemas.schema.yaml @@ -4,98 +4,8 @@ info: version: 'not applicable' paths: {} components: - x-codegen-enabled: false + x-codegen-enabled: true schemas: - SortOrder: - type: string - enum: - - asc - - desc - RuleExecutionStatus: - type: string - description: |- - Custom execution status of Security rules that is different from the status used in the Alerting Framework. We merge our custom status with the Framework's status to determine the resulting status of a rule. - - going to run - @deprecated Replaced by the 'running' status but left for backwards compatibility with rule execution events already written to Event Log in the prior versions of Kibana. Don't use when writing rule status changes. - - running - Rule execution started but not reached any intermediate or final status. - - partial failure - Rule can partially fail for various reasons either in the middle of an execution (in this case we update its status right away) or in the end of it. So currently this status can be both intermediate and final at the same time. A typical reason for a partial failure: not all the indices that the rule searches over actually exist. - - failed - Rule failed to execute due to unhandled exception or a reason defined in the business logic of its executor function. - - succeeded - Rule executed successfully without any issues. Note: this status is just an indication of a rule's "health". The rule might or might not generate any alerts despite of it. - - enum: - - going to run - - running - - partial failure - - failed - - succeeded - - RuleExecutionResult: - type: object - description: |- - Rule execution result is an aggregate that groups plain rule execution events by execution UUID. - properties: - execution_uuid: - type: string - timestamp: - type: string - format: date-time - duration_ms: - type: integer - status: - type: string - message: - type: string - num_active_alerts: - type: integer - num_new_alerts: - type: integer - num_recovered_alerts: - type: integer - num_triggered_actions: - type: integer - num_succeeded_actions: - type: integer - num_errored_actions: - type: integer - total_search_duration_ms: - type: integer - es_search_duration_ms: - type: integer - schedule_delay_ms: - type: integer - timed_out: - type: boolean - indexing_duration_ms: - type: integer - search_duration_ms: - type: integer - gap_duration_s: - type: integer - security_status: - type: string - security_message: - type: string - required: - - execution_uuid - - timestamp - - duration_ms - - status - - message - - num_active_alerts - - num_new_alerts - - num_recovered_alerts - - num_triggered_actions - - num_succeeded_actions - - num_errored_actions - - total_search_duration_ms - - es_search_duration_ms - - schedule_delay_ms - - timed_out - - indexing_duration_ms - - search_duration_ms - - gap_duration_s - - security_status - - security_message - Action: type: object properties: diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/sorting.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/sorting.gen.ts new file mode 100644 index 0000000000000..b2206c5a381ef --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/sorting.gen.ts @@ -0,0 +1,18 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +export type SortOrder = z.infer; +export const SortOrder = z.enum(['asc', 'desc']); +export const SortOrderEnum = SortOrder.enum; +export type SortOrderEnum = typeof SortOrder.enum; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/sorting.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/model/sorting.schema.yaml new file mode 100644 index 0000000000000..1b1893d19e4df --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/sorting.schema.yaml @@ -0,0 +1,13 @@ +openapi: 3.0.0 +info: + title: Sorting Schema + version: 'not applicable' +paths: {} +components: + x-codegen-enabled: true + schemas: + SortOrder: + type: string + enum: + - asc + - desc diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/model/warning_schema.ts b/x-pack/plugins/security_solution/common/api/detection_engine/model/warning_schema.ts index 1a401d1941cb0..9f82dc5db0605 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/model/warning_schema.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/model/warning_schema.ts @@ -20,5 +20,5 @@ const required = t.exact( }) ); -export const warningSchema = t.intersection([partial, required]); -export type WarningSchema = t.TypeOf; +export const WarningSchema = t.intersection([partial, required]); +export type WarningSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/index.ts index 87b43732bb6d2..a2b514676767b 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/index.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/index.ts @@ -7,7 +7,7 @@ export * from './get_prebuilt_rules_and_timelines_status/get_prebuilt_rules_and_timelines_status_route.gen'; export * from './get_prebuilt_rules_status/get_prebuilt_rules_status_route'; -export * from './install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route'; +export * from './install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.gen'; export * from './perform_rule_installation/perform_rule_installation_route'; export * from './perform_rule_upgrade/perform_rule_upgrade_route'; export * from './review_rule_installation/review_rule_installation_route'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.gen.ts new file mode 100644 index 0000000000000..f6cb7dec85143 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.gen.ts @@ -0,0 +1,37 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +export type InstallPrebuiltRulesAndTimelinesResponse = z.infer< + typeof InstallPrebuiltRulesAndTimelinesResponse +>; +export const InstallPrebuiltRulesAndTimelinesResponse = z + .object({ + /** + * The number of rules installed + */ + rules_installed: z.number().int().min(0), + /** + * The number of rules updated + */ + rules_updated: z.number().int().min(0), + /** + * The number of timelines installed + */ + timelines_installed: z.number().int().min(0), + /** + * The number of timelines updated + */ + timelines_updated: z.number().int().min(0), + }) + .strict(); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.schema.yaml index a7c2309d4a542..158b8667bb615 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.schema.yaml @@ -5,8 +5,8 @@ info: paths: /api/detection_engine/rules/prepackaged: put: - operationId: InstallPrebuiltRules - x-codegen-enabled: false + operationId: InstallPrebuiltRulesAndTimelines + x-codegen-enabled: true summary: Installs all Elastic prebuilt rules and timelines tags: - Prebuilt Rules API @@ -17,6 +17,7 @@ paths: application/json: schema: type: object + additionalProperties: false properties: rules_installed: type: integer diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.test.ts index 7f049af0d78b8..5edbd070b3972 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.test.ts @@ -5,11 +5,9 @@ * 2.0. */ -import { left } from 'fp-ts/lib/Either'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; - -import { InstallPrebuiltRulesAndTimelinesResponse } from './install_prebuilt_rules_and_timelines_route'; +import { stringifyZodError } from '@kbn/securitysolution-es-utils'; +import { expectParseError, expectParseSuccess } from '../../../../test/zod_helpers'; +import { InstallPrebuiltRulesAndTimelinesResponse } from './install_prebuilt_rules_and_timelines_route.gen'; describe('Install prebuilt rules and timelines response schema', () => { test('it should validate an empty prepackaged response with defaults', () => { @@ -19,12 +17,9 @@ describe('Install prebuilt rules and timelines response schema', () => { timelines_installed: 0, timelines_updated: 0, }; - const decoded = InstallPrebuiltRulesAndTimelinesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = InstallPrebuiltRulesAndTimelinesResponse.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); test('it should not validate an extra invalid field added', () => { @@ -35,12 +30,11 @@ describe('Install prebuilt rules and timelines response schema', () => { timelines_installed: 0, timelines_updated: 0, }; - const decoded = InstallPrebuiltRulesAndTimelinesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_field"']); - expect(message.schema).toEqual({}); + const result = InstallPrebuiltRulesAndTimelinesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + "Unrecognized key(s) in object: 'invalid_field'" + ); }); test('it should NOT validate an empty prepackaged response with a negative "rules_installed" number', () => { @@ -50,14 +44,11 @@ describe('Install prebuilt rules and timelines response schema', () => { timelines_installed: 0, timelines_updated: 0, }; - const decoded = InstallPrebuiltRulesAndTimelinesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "-1" supplied to "rules_installed"', - ]); - expect(message.schema).toEqual({}); + const result = InstallPrebuiltRulesAndTimelinesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + 'rules_installed: Number must be greater than or equal to 0' + ); }); test('it should NOT validate an empty prepackaged response with a negative "rules_updated"', () => { @@ -67,14 +58,11 @@ describe('Install prebuilt rules and timelines response schema', () => { timelines_installed: 0, timelines_updated: 0, }; - const decoded = InstallPrebuiltRulesAndTimelinesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "-1" supplied to "rules_updated"', - ]); - expect(message.schema).toEqual({}); + const result = InstallPrebuiltRulesAndTimelinesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + 'rules_updated: Number must be greater than or equal to 0' + ); }); test('it should NOT validate an empty prepackaged response if "rules_installed" is not there', () => { @@ -86,14 +74,9 @@ describe('Install prebuilt rules and timelines response schema', () => { }; // @ts-expect-error delete payload.rules_installed; - const decoded = InstallPrebuiltRulesAndTimelinesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "rules_installed"', - ]); - expect(message.schema).toEqual({}); + const result = InstallPrebuiltRulesAndTimelinesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual('rules_installed: Required'); }); test('it should NOT validate an empty prepackaged response if "rules_updated" is not there', () => { @@ -105,13 +88,8 @@ describe('Install prebuilt rules and timelines response schema', () => { }; // @ts-expect-error delete payload.rules_updated; - const decoded = InstallPrebuiltRulesAndTimelinesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "rules_updated"', - ]); - expect(message.schema).toEqual({}); + const result = InstallPrebuiltRulesAndTimelinesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual('rules_updated: Required'); }); }); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts deleted file mode 100644 index 7da2d6b1fae03..0000000000000 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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 * as t from 'io-ts'; -import { PositiveInteger } from '@kbn/securitysolution-io-ts-types'; - -export type InstallPrebuiltRulesAndTimelinesResponse = t.TypeOf< - typeof InstallPrebuiltRulesAndTimelinesResponse ->; -export const InstallPrebuiltRulesAndTimelinesResponse = t.exact( - t.type({ - rules_installed: PositiveInteger, - rules_updated: PositiveInteger, - - timelines_installed: PositiveInteger, - timelines_updated: PositiveInteger, - }) -); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/bulk_crud/response_schema.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/bulk_crud/response_schema.ts index b6f9bb359344b..07bef679e41ac 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/bulk_crud/response_schema.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/bulk_crud/response_schema.ts @@ -7,7 +7,7 @@ import * as t from 'io-ts'; -import { RuleResponse, errorSchema } from '../../model'; +import { RuleResponse, ErrorSchema } from '../../model'; export type BulkCrudRulesResponse = t.TypeOf; -export const BulkCrudRulesResponse = t.array(t.union([RuleResponse, errorSchema])); +export const BulkCrudRulesResponse = t.array(t.union([RuleResponse, ErrorSchema])); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.gen.ts new file mode 100644 index 0000000000000..01cd91216753f --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.gen.ts @@ -0,0 +1,49 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +import { RuleSignatureId } from '../../model/rule_schema/common_attributes.gen'; + +export type ExportRulesRequestQuery = z.infer; +export const ExportRulesRequestQuery = z.object({ + /** + * Determines whether a summary of the exported rules is returned. + */ + exclude_export_details: z.preprocess( + (value: unknown) => (typeof value === 'boolean' ? String(value) : value), + z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true') + ), + /** + * File name for saving the exported rules. + */ + file_name: z.string().optional().default('export.ndjson'), +}); +export type ExportRulesRequestQueryInput = z.input; + +export type ExportRulesRequestBody = z.infer; +export const ExportRulesRequestBody = z + .object({ + /** + * Array of `rule_id` fields. Exports all rules when unspecified. + */ + objects: z.array( + z.object({ + rule_id: RuleSignatureId, + }) + ), + }) + .nullable(); +export type ExportRulesRequestBodyInput = z.input; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.schema.yaml index cc15d750c9a31..5b36290ddf174 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.schema.yaml @@ -7,7 +7,7 @@ paths: summary: Exports rules to an `.ndjson` file post: operationId: ExportRules - x-codegen-enabled: false + x-codegen-enabled: true summary: Export rules description: Exports rules to an `.ndjson` file. The following configuration items are also included in the `.ndjson` file - Actions, Exception lists. Prebuilt rules cannot be exported. tags: @@ -35,6 +35,7 @@ paths: type: object required: - objects + nullable: true properties: objects: type: array @@ -44,13 +45,13 @@ paths: - rule_id properties: rule_id: - type: string + $ref: '../../model/rule_schema/common_attributes.schema.yaml#/components/schemas/RuleSignatureId' description: Array of `rule_id` fields. Exports all rules when unspecified. responses: 200: description: Indicates a successful call. content: - application/json: + application/ndjson: schema: type: string format: binary diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.test.ts index 86dea6c90fb46..2ec18f7f86c8b 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.test.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.test.ts @@ -5,55 +5,43 @@ * 2.0. */ -import { left } from 'fp-ts/lib/Either'; -import { pipe } from 'fp-ts/lib/pipeable'; -import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; - -import { ExportRulesRequestBody, ExportRulesRequestQuery } from './export_rules_route'; +import { stringifyZodError } from '@kbn/securitysolution-es-utils'; +import { expectParseError, expectParseSuccess } from '../../../../test/zod_helpers'; +import type { ExportRulesRequestQueryInput } from './export_rules_route.gen'; +import { ExportRulesRequestBody, ExportRulesRequestQuery } from './export_rules_route.gen'; describe('Export rules request schema', () => { describe('ExportRulesRequestBody', () => { test('null value or absent values validate', () => { const payload: Partial = null; - const decoded = ExportRulesRequestBody.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ExportRulesRequestBody.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); test('empty object does not validate', () => { const payload = {}; - const decoded = ExportRulesRequestBody.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "objects"', - 'Invalid value "{}" supplied to "({| objects: Array<{| rule_id: string |}> |} | null)"', - ]); - expect(message.schema).toEqual(payload); + const result = ExportRulesRequestBody.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual('objects: Required'); }); test('empty object array does validate', () => { const payload: ExportRulesRequestBody = { objects: [] }; - const decoded = ExportRulesRequestBody.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ExportRulesRequestBody.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); test('array with rule_id validates', () => { const payload: ExportRulesRequestBody = { objects: [{ rule_id: 'test-1' }] }; - const decoded = ExportRulesRequestBody.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ExportRulesRequestBody.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); test('array with id does not validate as we do not allow that on purpose since we export rule_id', () => { @@ -61,94 +49,80 @@ describe('Export rules request schema', () => { objects: [{ id: '4a7ff83d-3055-4bb2-ba68-587b9c6c15a4' }], }; - const decoded = ExportRulesRequestBody.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "objects,rule_id"', - 'Invalid value "{"objects":[{"id":"4a7ff83d-3055-4bb2-ba68-587b9c6c15a4"}]}" supplied to "({| objects: Array<{| rule_id: string |}> |} | null)"', - ]); - expect(message.schema).toEqual({}); + const result = ExportRulesRequestBody.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual('objects.0.rule_id: Required'); }); }); describe('ExportRulesRequestQuery', () => { test('default value for file_name is export.ndjson and default for exclude_export_details is false', () => { - const payload: Partial = {}; + const payload: ExportRulesRequestQueryInput = {}; - const decoded = ExportRulesRequestQuery.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); const expected: ExportRulesRequestQuery = { file_name: 'export.ndjson', exclude_export_details: false, }; - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(expected); + + const result = ExportRulesRequestQuery.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(expected); }); test('file_name validates', () => { - const payload: ExportRulesRequestQuery = { + const payload: ExportRulesRequestQueryInput = { file_name: 'test.ndjson', }; - const decoded = ExportRulesRequestQuery.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); const expected: ExportRulesRequestQuery = { file_name: 'test.ndjson', exclude_export_details: false, }; - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(expected); + + const result = ExportRulesRequestQuery.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(expected); }); test('file_name does not validate with a number', () => { - const payload: Omit & { file_name: number } = { + const payload: Omit & { file_name: number } = { file_name: 10, }; - const decoded = ExportRulesRequestQuery.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "10" supplied to "file_name"', - ]); - expect(message.schema).toEqual({}); + const result = ExportRulesRequestQuery.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + 'file_name: Expected string, received number' + ); }); test('exclude_export_details validates with a boolean true', () => { - const payload: ExportRulesRequestQuery = { + const payload: ExportRulesRequestQueryInput = { exclude_export_details: true, }; - const decoded = ExportRulesRequestQuery.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); const expected: ExportRulesRequestQuery = { exclude_export_details: true, file_name: 'export.ndjson', }; - expect(message.schema).toEqual(expected); + + const result = ExportRulesRequestQuery.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(expected); }); test('exclude_export_details does not validate with a string', () => { - const payload: Omit & { + const payload: Omit & { exclude_export_details: string; } = { exclude_export_details: 'invalid string', }; - const decoded = ExportRulesRequestQuery.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "invalid string" supplied to "exclude_export_details"', - ]); - expect(message.schema).toEqual({}); + const result = ExportRulesRequestQuery.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + `exclude_export_details: Invalid enum value. Expected 'true' | 'false', received 'invalid string'` + ); }); }); }); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.ts deleted file mode 100644 index f4746d3e090da..0000000000000 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/export_rules/export_rules_route.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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 * as t from 'io-ts'; -import { DefaultExportFileName } from '@kbn/securitysolution-io-ts-alerting-types'; -import { DefaultStringBooleanFalse } from '@kbn/securitysolution-io-ts-types'; - -import { RuleSignatureId } from '../../model'; - -const ObjectsWithRuleId = t.array(t.exact(t.type({ rule_id: RuleSignatureId }))); - -/** - * Request body parameters of the API route. - */ -export type ExportRulesRequestBody = t.TypeOf; -export const ExportRulesRequestBody = t.union([ - t.exact(t.type({ objects: ObjectsWithRuleId })), - t.null, -]); - -/** - * Query string parameters of the API route. - */ -export type ExportRulesRequestQuery = t.TypeOf; -export const ExportRulesRequestQuery = t.exact( - t.partial({ file_name: DefaultExportFileName, exclude_export_details: DefaultStringBooleanFalse }) -); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.gen.ts new file mode 100644 index 0000000000000..d0a105e28c2c8 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.gen.ts @@ -0,0 +1,78 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +import { ErrorSchema } from '../../model/error_schema.gen'; +import { WarningSchema } from '../../model/warning_schema.gen'; + +export type ImportRulesRequestQuery = z.infer; +export const ImportRulesRequestQuery = z.object({ + /** + * Determines whether existing rules with the same `rule_id` are overwritten. + */ + overwrite: z.preprocess( + (value: unknown) => (typeof value === 'boolean' ? String(value) : value), + z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true') + ), + /** + * Determines whether existing exception lists with the same `list_id` are overwritten. + */ + overwrite_exceptions: z.preprocess( + (value: unknown) => (typeof value === 'boolean' ? String(value) : value), + z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true') + ), + /** + * Determines whether existing actions with the same `kibana.alert.rule.actions.id` are overwritten. + */ + overwrite_action_connectors: z.preprocess( + (value: unknown) => (typeof value === 'boolean' ? String(value) : value), + z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true') + ), + /** + * Generates a new list ID for each imported exception list. + */ + as_new_list: z.preprocess( + (value: unknown) => (typeof value === 'boolean' ? String(value) : value), + z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true') + ), +}); +export type ImportRulesRequestQueryInput = z.input; + +export type ImportRulesResponse = z.infer; +export const ImportRulesResponse = z + .object({ + exceptions_success: z.boolean(), + exceptions_success_count: z.number().int().min(0), + exceptions_errors: z.array(ErrorSchema), + rules_count: z.number().int().min(0), + success: z.boolean(), + success_count: z.number().int().min(0), + errors: z.array(ErrorSchema), + action_connectors_errors: z.array(ErrorSchema), + action_connectors_warnings: z.array(WarningSchema), + action_connectors_success: z.boolean(), + action_connectors_success_count: z.number().int().min(0), + }) + .strict(); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.schema.yaml index b6879c7ace5e6..e158434354fde 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.schema.yaml @@ -7,7 +7,7 @@ paths: summary: Imports rules from an `.ndjson` file post: operationId: ImportRules - x-codegen-enabled: false + x-codegen-enabled: true summary: Import rules description: Imports rules from an `.ndjson` file, including actions and exception lists. tags: @@ -45,6 +45,13 @@ paths: schema: type: boolean default: false + - name: as_new_list + in: query + required: false + description: Generates a new list ID for each imported exception list. + schema: + type: boolean + default: false responses: 200: description: Indicates a successful call. @@ -52,3 +59,51 @@ paths: application/json: schema: type: object + additionalProperties: false + required: + - exceptions_success + - exceptions_success_count + - exceptions_errors + - rules_count + - success + - success_count + - errors + - action_connectors_errors + - action_connectors_warnings + - action_connectors_success + - action_connectors_success_count + properties: + exceptions_success: + type: boolean + exceptions_success_count: + type: integer + minimum: 0 + exceptions_errors: + type: array + items: + $ref: '../../model/error_schema.schema.yaml#/components/schemas/ErrorSchema' + rules_count: + type: integer + minimum: 0 + success: + type: boolean + success_count: + type: integer + minimum: 0 + errors: + type: array + items: + $ref: '../../model/error_schema.schema.yaml#/components/schemas/ErrorSchema' + action_connectors_errors: + type: array + items: + $ref: '../../model/error_schema.schema.yaml#/components/schemas/ErrorSchema' + action_connectors_warnings: + type: array + items: + $ref: '../../model/warning_schema.schema.yaml#/components/schemas/WarningSchema' + action_connectors_success: + type: boolean + action_connectors_success_count: + type: integer + minimum: 0 diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.test.ts index 64630dca05610..56fcece0f122a 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.test.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.test.ts @@ -5,15 +5,10 @@ * 2.0. */ -import { pipe } from 'fp-ts/lib/pipeable'; -import type { Either } from 'fp-ts/lib/Either'; -import { left } from 'fp-ts/lib/Either'; -import type { Errors } from 'io-ts'; - -import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; -import type { ErrorSchema } from '../../model/error_schema'; - -import { ImportRulesResponse } from './import_rules_route'; +import { stringifyZodError } from '@kbn/securitysolution-es-utils'; +import { expectParseError, expectParseSuccess } from '../../../../test/zod_helpers'; +import type { ErrorSchema } from '../../model/error_schema.gen'; +import { ImportRulesResponse } from './import_rules_route.gen'; describe('Import rules schema', () => { describe('response schema', () => { @@ -31,12 +26,9 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ImportRulesResponse.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); test('it should validate an empty import response with a single error', () => { @@ -53,12 +45,9 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ImportRulesResponse.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); test('it should validate an empty import response with a single exceptions error', () => { @@ -75,12 +64,9 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ImportRulesResponse.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); test('it should validate an empty import response with two errors', () => { @@ -100,12 +86,9 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ImportRulesResponse.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); test('it should validate an empty import response with two exception errors', () => { @@ -125,12 +108,9 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ImportRulesResponse.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); test('it should NOT validate a success_count that is a negative number', () => { @@ -147,14 +127,11 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "-1" supplied to "success_count"', - ]); - expect(message.schema).toEqual({}); + const result = ImportRulesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + 'success_count: Number must be greater than or equal to 0' + ); }); test('it should NOT validate a exceptions_success_count that is a negative number', () => { @@ -171,35 +148,14 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "-1" supplied to "exceptions_success_count"', - ]); - expect(message.schema).toEqual({}); + const result = ImportRulesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + 'exceptions_success_count: Number must be greater than or equal to 0' + ); }); test('it should NOT validate a success that is not a boolean', () => { - type UnsafeCastForTest = Either< - Errors, - { - success: string; - success_count: number; - errors: Array< - { - id?: string | undefined; - rule_id?: string | undefined; - } & { - error: { - status_code: number; - message: string; - }; - } - >; - } - >; const payload: Omit & { success: string } = { success: 'hello', success_count: 0, @@ -213,36 +169,12 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded as UnsafeCastForTest); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "hello" supplied to "success"', - ]); - expect(message.schema).toEqual({}); + const result = ImportRulesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual('success: Expected boolean, received string'); }); test('it should NOT validate a exceptions_success that is not a boolean', () => { - type UnsafeCastForTest = Either< - Errors, - { - success: boolean; - exceptions_success: string; - success_count: number; - errors: Array< - { - id?: string | undefined; - rule_id?: string | undefined; - } & { - error: { - status_code: number; - message: string; - }; - } - >; - } - >; const payload: Omit & { exceptions_success: string; } = { @@ -258,14 +190,11 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded as UnsafeCastForTest); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "hello" supplied to "exceptions_success"', - ]); - expect(message.schema).toEqual({}); + const result = ImportRulesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + 'exceptions_success: Expected boolean, received string' + ); }); test('it should NOT validate a success an extra invalid field', () => { @@ -283,12 +212,11 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_field"']); - expect(message.schema).toEqual({}); + const result = ImportRulesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + "Unrecognized key(s) in object: 'invalid_field'" + ); }); test('it should NOT validate an extra field in the second position of the errors array', () => { @@ -311,12 +239,11 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_data"']); - expect(message.schema).toEqual({}); + const result = ImportRulesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + "errors.1: Unrecognized key(s) in object: 'invalid_data'" + ); }); test('it should validate an empty import response with a single connectors error', () => { @@ -333,13 +260,11 @@ describe('Import rules schema', () => { action_connectors_errors: [{ error: { status_code: 400, message: 'some message' } }], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ImportRulesResponse.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); + test('it should validate an empty import response with multiple errors', () => { const payload: ImportRulesResponse = { success: false, @@ -357,33 +282,12 @@ describe('Import rules schema', () => { action_connectors_errors: [{ error: { status_code: 400, message: 'some message' } }], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ImportRulesResponse.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); + test('it should NOT validate action_connectors_success that is not boolean', () => { - type UnsafeCastForTest = Either< - Errors, - { - success: boolean; - action_connectors_success: string; - success_count: number; - errors: Array< - { - id?: string | undefined; - rule_id?: string | undefined; - } & { - error: { - status_code: number; - message: string; - }; - } - >; - } - >; const payload: Omit & { action_connectors_success: string; } = { @@ -399,15 +303,13 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded as UnsafeCastForTest); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "invalid" supplied to "action_connectors_success"', - ]); - expect(message.schema).toEqual({}); + const result = ImportRulesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + 'action_connectors_success: Expected boolean, received string' + ); }); + test('it should NOT validate a action_connectors_success_count that is a negative number', () => { const payload: ImportRulesResponse = { success: false, @@ -422,16 +324,13 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: [], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "-1" supplied to "action_connectors_success_count"', - ]); - expect(message.schema).toEqual({}); + const result = ImportRulesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + 'action_connectors_success_count: Number must be greater than or equal to 0' + ); }); - test('it should validate a action_connectors_warnings after importing successfully', () => { + test('it should validate a action_connectors_warnings after importing successfully', () => { const payload: ImportRulesResponse = { success: false, success_count: 0, @@ -447,33 +346,12 @@ describe('Import rules schema', () => { { type: 'type', message: 'message', actionPath: 'actionPath' }, ], }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + const result = ImportRulesResponse.safeParse(payload); + expectParseSuccess(result); + expect(result.data).toEqual(payload); }); + test('it should NOT validate a action_connectors_warnings that is not WarningSchema', () => { - type UnsafeCastForTest = Either< - Errors, - { - success: boolean; - action_connectors_warnings: string; - success_count: number; - errors: Array< - { - id?: string | undefined; - rule_id?: string | undefined; - } & { - error: { - status_code: number; - message: string; - }; - } - >; - } - >; const payload: Omit & { action_connectors_warnings: string; } = { @@ -489,14 +367,11 @@ describe('Import rules schema', () => { action_connectors_errors: [], action_connectors_warnings: 'invalid', }; - const decoded = ImportRulesResponse.decode(payload); - const checked = exactCheck(payload, decoded as UnsafeCastForTest); - const message = pipe(checked, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "invalid" supplied to "action_connectors_warnings"', - ]); - expect(message.schema).toEqual({}); + const result = ImportRulesResponse.safeParse(payload); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual( + 'action_connectors_warnings: Expected array, received string' + ); }); }); }); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.ts deleted file mode 100644 index 8a427f16fd8c6..0000000000000 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/import_rules/import_rules_route.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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 * as t from 'io-ts'; -import { DefaultStringBooleanFalse, PositiveInteger } from '@kbn/securitysolution-io-ts-types'; -import { errorSchema, warningSchema } from '../../model'; - -export const ImportRulesRequestQuery = t.exact( - t.partial({ - overwrite: DefaultStringBooleanFalse, - overwrite_exceptions: DefaultStringBooleanFalse, - overwrite_action_connectors: DefaultStringBooleanFalse, - as_new_list: DefaultStringBooleanFalse, - }) -); - -export type ImportRulesRequestQuery = t.TypeOf; -export interface ImportRulesRequestQueryDecoded { - overwrite: boolean; - overwrite_exceptions: boolean; - overwrite_action_connectors: boolean; - as_new_list: boolean; -} - -export type ImportRulesResponse = t.TypeOf; -export const ImportRulesResponse = t.exact( - t.type({ - exceptions_success: t.boolean, - exceptions_success_count: PositiveInteger, - exceptions_errors: t.array(errorSchema), - rules_count: PositiveInteger, - success: t.boolean, - success_count: PositiveInteger, - errors: t.array(errorSchema), - action_connectors_errors: t.array(errorSchema), - action_connectors_warnings: t.array(warningSchema), - action_connectors_success: t.boolean, - action_connectors_success_count: PositiveInteger, - }) -); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/index.ts index bfb1d0d16a34e..a9100970ca753 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/index.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/index.ts @@ -21,14 +21,14 @@ export * from './crud/read_rule/read_rule_route'; export * from './crud/update_rule/request_schema_validation'; export * from './crud/update_rule/update_rule_route'; export * from './export_rules/export_rules_details_schema'; -export * from './export_rules/export_rules_route'; +export * from './export_rules/export_rules_route.gen'; export * from './find_rules/find_rules_route'; export * from './find_rules/request_schema_validation'; export * from './get_rule_management_filters/get_rule_management_filters_route'; -export * from './import_rules/import_rules_route'; +export * from './import_rules/import_rules_route.gen'; export * from './import_rules/rule_to_import_validation'; export * from './import_rules/rule_to_import'; export * from './model/query_rule_by_ids_validation'; export * from './model/query_rule_by_ids'; export * from './urls'; -export * from './read_tags/read_tags_route'; +export * from './read_tags/read_tags_route.gen'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.gen.ts new file mode 100644 index 0000000000000..a2a52f4a1f7e9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.gen.ts @@ -0,0 +1,18 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +import { RuleTagArray } from '../../model/rule_schema/common_attributes.gen'; + +export type ReadTagsResponse = z.infer; +export const ReadTagsResponse = RuleTagArray; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.schema.yaml index 5f4da0a62fcea..b9e79f252a269 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/read_tags/read_tags_route.schema.yaml @@ -6,8 +6,8 @@ paths: /api/detection_engine/tags: summary: Aggregates and returns rule tags get: - operationId: GetTags - x-codegen-enabled: false + operationId: ReadTags + x-codegen-enabled: true summary: Aggregates and returns all unique tags from all rules tags: - Tags API diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/index.ts index b23603ef8084e..1494e09b9c51a 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/index.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/index.ts @@ -11,13 +11,14 @@ export * from './detection_engine_health/get_space_health/get_space_health_route export * from './detection_engine_health/setup_health/setup_health_route'; export * from './detection_engine_health/model'; export * from './rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route'; -export * from './rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route'; +export * from './rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.gen'; export * from './urls'; export * from './model/execution_event'; export * from './model/execution_metrics'; -export * from './model/execution_result'; +export * from './model/execution_result.gen'; export * from './model/execution_settings'; +export * from './model/execution_status.gen'; export * from './model/execution_status'; export * from './model/execution_summary'; export * from './model/log_level'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.gen.ts new file mode 100644 index 0000000000000..41eab285ac3f0 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.gen.ts @@ -0,0 +1,55 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +/** + * Rule execution result is an aggregate that groups plain rule execution events by execution UUID. It contains such information as execution UUID, date, status and metrics. + */ +export type RuleExecutionResult = z.infer; +export const RuleExecutionResult = z.object({ + execution_uuid: z.string(), + timestamp: z.string().datetime(), + duration_ms: z.number().int(), + status: z.string(), + message: z.string(), + num_active_alerts: z.number().int(), + num_new_alerts: z.number().int(), + num_recovered_alerts: z.number().int(), + num_triggered_actions: z.number().int(), + num_succeeded_actions: z.number().int(), + num_errored_actions: z.number().int(), + total_search_duration_ms: z.number().int(), + es_search_duration_ms: z.number().int(), + schedule_delay_ms: z.number().int(), + timed_out: z.boolean(), + indexing_duration_ms: z.number().int(), + search_duration_ms: z.number().int(), + gap_duration_s: z.number().int(), + security_status: z.string(), + security_message: z.string(), +}); + +/** + * We support sorting rule execution results by these fields. + */ +export type SortFieldOfRuleExecutionResult = z.infer; +export const SortFieldOfRuleExecutionResult = z.enum([ + 'timestamp', + 'duration_ms', + 'gap_duration_s', + 'indexing_duration_ms', + 'search_duration_ms', + 'schedule_delay_ms', +]); +export const SortFieldOfRuleExecutionResultEnum = SortFieldOfRuleExecutionResult.enum; +export type SortFieldOfRuleExecutionResultEnum = typeof SortFieldOfRuleExecutionResult.enum; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.mock.ts index 4a039ca949c82..4016d02ec4ebc 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.mock.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { RuleExecutionResult } from './execution_result'; +import type { RuleExecutionResult } from './execution_result.gen'; const getSomeResults = (): RuleExecutionResult[] => [ { diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.schema.yaml new file mode 100644 index 0000000000000..3c8d91d3b9d7f --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_result.schema.yaml @@ -0,0 +1,86 @@ +openapi: 3.0.0 +info: + title: Execution Result Schema + version: not applicable +paths: {} +components: + x-codegen-enabled: true + schemas: + RuleExecutionResult: + type: object + description: |- + Rule execution result is an aggregate that groups plain rule execution events by execution UUID. It contains such information as execution UUID, date, status and metrics. + properties: + execution_uuid: + type: string + timestamp: + type: string + format: date-time + duration_ms: + type: integer + status: + type: string + message: + type: string + num_active_alerts: + type: integer + num_new_alerts: + type: integer + num_recovered_alerts: + type: integer + num_triggered_actions: + type: integer + num_succeeded_actions: + type: integer + num_errored_actions: + type: integer + total_search_duration_ms: + type: integer + es_search_duration_ms: + type: integer + schedule_delay_ms: + type: integer + timed_out: + type: boolean + indexing_duration_ms: + type: integer + search_duration_ms: + type: integer + gap_duration_s: + type: integer + security_status: + type: string + security_message: + type: string + required: + - execution_uuid + - timestamp + - duration_ms + - status + - message + - num_active_alerts + - num_new_alerts + - num_recovered_alerts + - num_triggered_actions + - num_succeeded_actions + - num_errored_actions + - total_search_duration_ms + - es_search_duration_ms + - schedule_delay_ms + - timed_out + - indexing_duration_ms + - search_duration_ms + - gap_duration_s + - security_status + - security_message + + SortFieldOfRuleExecutionResult: + type: string + description: We support sorting rule execution results by these fields. + enum: + - timestamp + - duration_ms + - gap_duration_s + - indexing_duration_ms + - search_duration_ms + - schedule_delay_ms diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.gen.ts new file mode 100644 index 0000000000000..2357e95dae817 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.gen.ts @@ -0,0 +1,32 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +/** + * Custom execution status of Security rules that is different from the status used in the Alerting Framework. We merge our custom status with the Framework's status to determine the resulting status of a rule. +- going to run - @deprecated Replaced by the 'running' status but left for backwards compatibility with rule execution events already written to Event Log in the prior versions of Kibana. Don't use when writing rule status changes. +- running - Rule execution started but not reached any intermediate or final status. +- partial failure - Rule can partially fail for various reasons either in the middle of an execution (in this case we update its status right away) or in the end of it. So currently this status can be both intermediate and final at the same time. A typical reason for a partial failure: not all the indices that the rule searches over actually exist. +- failed - Rule failed to execute due to unhandled exception or a reason defined in the business logic of its executor function. +- succeeded - Rule executed successfully without any issues. Note: this status is just an indication of a rule's "health". The rule might or might not generate any alerts despite of it. + */ +export type RuleExecutionStatus = z.infer; +export const RuleExecutionStatus = z.enum([ + 'going to run', + 'running', + 'partial failure', + 'failed', + 'succeeded', +]); +export const RuleExecutionStatusEnum = RuleExecutionStatus.enum; +export type RuleExecutionStatusEnum = typeof RuleExecutionStatus.enum; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.schema.yaml new file mode 100644 index 0000000000000..7675b21786188 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.schema.yaml @@ -0,0 +1,24 @@ +openapi: 3.0.0 +info: + title: Execution Status Schema + version: not applicable +paths: {} +components: + x-codegen-enabled: true + schemas: + RuleExecutionStatus: + type: string + description: |- + Custom execution status of Security rules that is different from the status used in the Alerting Framework. We merge our custom status with the Framework's status to determine the resulting status of a rule. + - going to run - @deprecated Replaced by the 'running' status but left for backwards compatibility with rule execution events already written to Event Log in the prior versions of Kibana. Don't use when writing rule status changes. + - running - Rule execution started but not reached any intermediate or final status. + - partial failure - Rule can partially fail for various reasons either in the middle of an execution (in this case we update its status right away) or in the end of it. So currently this status can be both intermediate and final at the same time. A typical reason for a partial failure: not all the indices that the rule searches over actually exist. + - failed - Rule failed to execute due to unhandled exception or a reason defined in the business logic of its executor function. + - succeeded - Rule executed successfully without any issues. Note: this status is just an indication of a rule's "health". The rule might or might not generate any alerts despite of it. + + enum: + - going to run + - running + - partial failure + - failed + - succeeded diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.ts index 1e95e3a812054..c168cdc837fc3 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_status.ts @@ -5,57 +5,15 @@ * 2.0. */ -import type * as t from 'io-ts'; -import { enumeration, PositiveInteger } from '@kbn/securitysolution-io-ts-types'; import type { RuleLastRunOutcomes } from '@kbn/alerting-plugin/common'; +import { enumeration, PositiveInteger } from '@kbn/securitysolution-io-ts-types'; +import type * as t from 'io-ts'; import { assertUnreachable } from '../../../../utility_types'; +import type { RuleExecutionStatus } from './execution_status.gen'; +import { RuleExecutionStatusEnum } from './execution_status.gen'; -/** - * Custom execution status of Security rules that is different from the status - * used in the Alerting Framework. We merge our custom status with the - * Framework's status to determine the resulting status of a rule. - */ -export enum RuleExecutionStatus { - /** - * @deprecated Replaced by the 'running' status but left for backwards compatibility - * with rule execution events already written to Event Log in the prior versions of Kibana. - * Don't use when writing rule status changes. - */ - 'going to run' = 'going to run', - - /** - * Rule execution started but not reached any intermediate or final status. - */ - 'running' = 'running', - - /** - * Rule can partially fail for various reasons either in the middle of an execution - * (in this case we update its status right away) or in the end of it. So currently - * this status can be both intermediate and final at the same time. - * A typical reason for a partial failure: not all the indices that the rule searches - * over actually exist. - */ - 'partial failure' = 'partial failure', - - /** - * Rule failed to execute due to unhandled exception or a reason defined in the - * business logic of its executor function. - */ - 'failed' = 'failed', - - /** - * Rule executed successfully without any issues. Note: this status is just an indication - * of a rule's "health". The rule might or might not generate any alerts despite of it. - */ - 'succeeded' = 'succeeded', -} - -export const TRuleExecutionStatus = enumeration('RuleExecutionStatus', RuleExecutionStatus); - -/** - * An array of supported rule execution statuses. - */ -export const RULE_EXECUTION_STATUSES = Object.values(RuleExecutionStatus); +// TODO remove after the migration to Zod is done +export const TRuleExecutionStatus = enumeration('RuleExecutionStatus', RuleExecutionStatusEnum); export type RuleExecutionStatusOrder = t.TypeOf; export const RuleExecutionStatusOrder = PositiveInteger; @@ -64,15 +22,15 @@ export const ruleExecutionStatusToNumber = ( status: RuleExecutionStatus ): RuleExecutionStatusOrder => { switch (status) { - case RuleExecutionStatus.succeeded: + case RuleExecutionStatusEnum.succeeded: return 0; - case RuleExecutionStatus['going to run']: + case RuleExecutionStatusEnum['going to run']: return 10; - case RuleExecutionStatus.running: + case RuleExecutionStatusEnum.running: return 15; - case RuleExecutionStatus['partial failure']: + case RuleExecutionStatusEnum['partial failure']: return 20; - case RuleExecutionStatus.failed: + case RuleExecutionStatusEnum.failed: return 30; default: assertUnreachable(status); @@ -85,13 +43,13 @@ export const ruleLastRunOutcomeToExecutionStatus = ( ): RuleExecutionStatus => { switch (outcome) { case 'succeeded': - return RuleExecutionStatus.succeeded; + return RuleExecutionStatusEnum.succeeded; case 'warning': - return RuleExecutionStatus['partial failure']; + return RuleExecutionStatusEnum['partial failure']; case 'failed': - return RuleExecutionStatus.failed; + return RuleExecutionStatusEnum.failed; default: assertUnreachable(outcome); - return RuleExecutionStatus.failed; + return RuleExecutionStatusEnum.failed; } }; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_summary.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_summary.mock.ts index 3224b84ba2cef..59482e759f902 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_summary.mock.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/execution_summary.mock.ts @@ -5,13 +5,13 @@ * 2.0. */ -import { RuleExecutionStatus } from './execution_status'; +import { RuleExecutionStatusEnum } from './execution_status.gen'; import type { RuleExecutionSummary } from './execution_summary'; const getSummarySucceeded = (): RuleExecutionSummary => ({ last_execution: { date: '2020-02-18T15:26:49.783Z', - status: RuleExecutionStatus.succeeded, + status: RuleExecutionStatusEnum.succeeded, status_order: 0, message: 'succeeded', metrics: { @@ -25,7 +25,7 @@ const getSummarySucceeded = (): RuleExecutionSummary => ({ const getSummaryFailed = (): RuleExecutionSummary => ({ last_execution: { date: '2020-02-18T15:15:58.806Z', - status: RuleExecutionStatus.failed, + status: RuleExecutionStatusEnum.failed, status_order: 30, message: 'Signal rule name: "Query with a rule id Number 1", id: "1ea5a820-4da1-4e82-92a1-2b43a7bece08", rule_id: "query-rule-id-1" has a time gap of 5 days (412682928ms), and could be missing signals within that time. Consider increasing your look behind time or adding more Kibana instances.', diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/index.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/index.ts index b486b9d80957a..b4f003cf48228 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/index.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/index.ts @@ -7,8 +7,8 @@ export * from './execution_event'; export * from './execution_metrics'; -export * from './execution_result'; +export * from './execution_result.gen'; export * from './execution_settings'; -export * from './execution_status'; +export * from './execution_status.gen'; export * from './execution_summary'; export * from './log_level'; diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/log_level.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/log_level.ts index 9add3b46b3ef3..495589b3cd432 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/log_level.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/model/log_level.ts @@ -8,7 +8,8 @@ import { enumeration } from '@kbn/securitysolution-io-ts-types'; import { enumFromString } from '../../../../utils/enum_from_string'; import { assertUnreachable } from '../../../../utility_types'; -import { RuleExecutionStatus } from './execution_status'; +import type { RuleExecutionStatus } from './execution_status.gen'; +import { RuleExecutionStatusEnum } from './execution_status.gen'; export enum LogLevel { 'trace' = 'trace', @@ -67,13 +68,13 @@ export const logLevelFromString = enumFromString(LogLevel); export const logLevelFromExecutionStatus = (status: RuleExecutionStatus): LogLevel => { switch (status) { - case RuleExecutionStatus['going to run']: - case RuleExecutionStatus.running: - case RuleExecutionStatus.succeeded: + case RuleExecutionStatusEnum['going to run']: + case RuleExecutionStatusEnum.running: + case RuleExecutionStatusEnum.succeeded: return LogLevel.info; - case RuleExecutionStatus['partial failure']: + case RuleExecutionStatusEnum['partial failure']: return LogLevel.warn; - case RuleExecutionStatus.failed: + case RuleExecutionStatusEnum.failed: return LogLevel.error; default: assertUnreachable(status); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.gen.ts new file mode 100644 index 0000000000000..751e571aae3fd --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.gen.ts @@ -0,0 +1,85 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +import { RuleExecutionStatus } from '../../model/execution_status.gen'; +import { + SortFieldOfRuleExecutionResult, + RuleExecutionResult, +} from '../../model/execution_result.gen'; +import { SortOrder } from '../../../model/sorting.gen'; + +export type GetRuleExecutionEventsRequestQuery = z.infer; +export const GetRuleExecutionEventsRequestQuery = z.object({ + /** + * Start date of the time range to query + */ + start: z.string().datetime(), + /** + * End date of the time range to query + */ + end: z.string().datetime(), + /** + * Query text to filter results by + */ + query_text: z.string().optional().default(''), + /** + * Comma-separated list of rule execution statuses to filter results by + */ + status_filters: z + .preprocess( + (value: unknown) => + typeof value === 'string' ? (value === '' ? [] : value.split(',')) : value, + z.array(RuleExecutionStatus) + ) + .optional() + .default([]), + /** + * Field to sort results by + */ + sort_field: SortFieldOfRuleExecutionResult.optional().default('timestamp'), + /** + * Sort order to sort results by + */ + sort_order: SortOrder.optional().default('desc'), + /** + * Page number to return + */ + page: z.coerce.number().int().optional().default(1), + /** + * Number of results per page + */ + per_page: z.coerce.number().int().optional().default(20), +}); +export type GetRuleExecutionEventsRequestQueryInput = z.input< + typeof GetRuleExecutionEventsRequestQuery +>; + +export type GetRuleExecutionEventsRequestParams = z.infer< + typeof GetRuleExecutionEventsRequestParams +>; +export const GetRuleExecutionEventsRequestParams = z.object({ + /** + * Saved object ID of the rule to get execution results for + */ + ruleId: z.string().min(1), +}); +export type GetRuleExecutionEventsRequestParamsInput = z.input< + typeof GetRuleExecutionEventsRequestParams +>; + +export type GetRuleExecutionEventsResponse = z.infer; +export const GetRuleExecutionEventsResponse = z.object({ + events: z.array(RuleExecutionResult).optional(), + total: z.number().int().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.schema.yaml new file mode 100644 index 0000000000000..677213bae4f2e --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_events/get_rule_execution_events_route.schema.yaml @@ -0,0 +1,92 @@ +openapi: 3.0.0 +info: + title: Get Rule Execution Events API endpoint + version: '1' +paths: + /internal/detection_engine/rules/{ruleId}/execution/events: + put: + operationId: GetRuleExecutionEvents + x-codegen-enabled: true + summary: Returns execution events of a given rule (aggregated by execution UUID) from Event Log. + tags: + - Rule Execution Log API + parameters: + - name: ruleId + in: path + required: true + description: Saved object ID of the rule to get execution results for + schema: + type: string + minLength: 1 + - name: start + in: query + required: true + description: Start date of the time range to query + schema: + type: string + format: date-time + - name: end + in: query + required: true + description: End date of the time range to query + schema: + type: string + format: date-time + - name: query_text + in: query + required: false + description: Query text to filter results by + schema: + type: string + default: '' + - name: status_filters + in: query + required: false + description: Comma-separated list of rule execution statuses to filter results by + schema: + type: array + items: + $ref: '../../model/execution_status.schema.yaml#/components/schemas/RuleExecutionStatus' + default: [] + - name: sort_field + in: query + required: false + description: Field to sort results by + schema: + $ref: '../../model/execution_result.schema.yaml#/components/schemas/SortFieldOfRuleExecutionResult' + default: timestamp + - name: sort_order + in: query + required: false + description: Sort order to sort results by + schema: + $ref: '../../../model/sorting.schema.yaml#/components/schemas/SortOrder' + default: desc + - name: page + in: query + required: false + description: Page number to return + schema: + type: integer + default: 1 + - name: per_page + in: query + required: false + description: Number of results per page + schema: + type: integer + default: 20 + responses: + 200: + description: Indicates a successful call + content: + application/json: + schema: + type: object + properties: + events: + type: array + items: + $ref: '../../model/execution_result.schema.yaml#/components/schemas/RuleExecutionResult' + total: + type: integer diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.gen.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.gen.ts new file mode 100644 index 0000000000000..442c45f3e8dc9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.gen.ts @@ -0,0 +1,87 @@ +/* + * 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 { z } from 'zod'; + +/* + * NOTICE: Do not edit this file manually. + * This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator. + */ + +import { RuleExecutionStatus } from '../../model/execution_status.gen'; +import { + SortFieldOfRuleExecutionResult, + RuleExecutionResult, +} from '../../model/execution_result.gen'; +import { SortOrder } from '../../../model/sorting.gen'; + +export type GetRuleExecutionResultsRequestQuery = z.infer< + typeof GetRuleExecutionResultsRequestQuery +>; +export const GetRuleExecutionResultsRequestQuery = z.object({ + /** + * Start date of the time range to query + */ + start: z.string().datetime(), + /** + * End date of the time range to query + */ + end: z.string().datetime(), + /** + * Query text to filter results by + */ + query_text: z.string().optional().default(''), + /** + * Comma-separated list of rule execution statuses to filter results by + */ + status_filters: z + .preprocess( + (value: unknown) => + typeof value === 'string' ? (value === '' ? [] : value.split(',')) : value, + z.array(RuleExecutionStatus) + ) + .optional() + .default([]), + /** + * Field to sort results by + */ + sort_field: SortFieldOfRuleExecutionResult.optional().default('timestamp'), + /** + * Sort order to sort results by + */ + sort_order: SortOrder.optional().default('desc'), + /** + * Page number to return + */ + page: z.coerce.number().int().optional().default(1), + /** + * Number of results per page + */ + per_page: z.coerce.number().int().optional().default(20), +}); +export type GetRuleExecutionResultsRequestQueryInput = z.input< + typeof GetRuleExecutionResultsRequestQuery +>; + +export type GetRuleExecutionResultsRequestParams = z.infer< + typeof GetRuleExecutionResultsRequestParams +>; +export const GetRuleExecutionResultsRequestParams = z.object({ + /** + * Saved object ID of the rule to get execution results for + */ + ruleId: z.string().min(1), +}); +export type GetRuleExecutionResultsRequestParamsInput = z.input< + typeof GetRuleExecutionResultsRequestParams +>; + +export type GetRuleExecutionResultsResponse = z.infer; +export const GetRuleExecutionResultsResponse = z.object({ + events: z.array(RuleExecutionResult).optional(), + total: z.number().int().optional(), +}); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.mock.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.mock.ts index 9584616343332..cfcf0e6fe441a 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.mock.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.mock.ts @@ -6,7 +6,7 @@ */ import { ruleExecutionResultMock } from '../../model/execution_result.mock'; -import type { GetRuleExecutionResultsResponse } from './get_rule_execution_results_route'; +import type { GetRuleExecutionResultsResponse } from './get_rule_execution_results_route.gen'; const getSomeResponse = (): GetRuleExecutionResultsResponse => { const results = ruleExecutionResultMock.getSomeResults(); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.schema.yaml b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.schema.yaml new file mode 100644 index 0000000000000..8bba4b2811e31 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.schema.yaml @@ -0,0 +1,92 @@ +openapi: 3.0.0 +info: + title: Get Rule Execution Results API endpoint + version: '1' +paths: + /internal/detection_engine/rules/{ruleId}/execution/results: + put: + operationId: GetRuleExecutionResults + x-codegen-enabled: true + summary: Returns execution results of a given rule (aggregated by execution UUID) from Event Log. + tags: + - Rule Execution Log API + parameters: + - name: ruleId + in: path + required: true + description: Saved object ID of the rule to get execution results for + schema: + type: string + minLength: 1 + - name: start + in: query + required: true + description: Start date of the time range to query + schema: + type: string + format: date-time + - name: end + in: query + required: true + description: End date of the time range to query + schema: + type: string + format: date-time + - name: query_text + in: query + required: false + description: Query text to filter results by + schema: + type: string + default: '' + - name: status_filters + in: query + required: false + description: Comma-separated list of rule execution statuses to filter results by + schema: + type: array + items: + $ref: '../../model/execution_status.schema.yaml#/components/schemas/RuleExecutionStatus' + default: [] + - name: sort_field + in: query + required: false + description: Field to sort results by + schema: + $ref: '../../model/execution_result.schema.yaml#/components/schemas/SortFieldOfRuleExecutionResult' + default: timestamp + - name: sort_order + in: query + required: false + description: Sort order to sort results by + schema: + $ref: '../../../model/sorting.schema.yaml#/components/schemas/SortOrder' + default: desc + - name: page + in: query + required: false + description: Page number to return + schema: + type: integer + default: 1 + - name: per_page + in: query + required: false + description: Number of results per page + schema: + type: integer + default: 20 + responses: + 200: + description: Indicates a successful call + content: + application/json: + schema: + type: object + properties: + events: + type: array + items: + $ref: '../../model/execution_result.schema.yaml#/components/schemas/RuleExecutionResult' + total: + type: integer diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.test.ts index 53ed03003487e..9dc071b7ca1ea 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.test.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.test.ts @@ -5,32 +5,29 @@ * 2.0. */ -import { pipe } from 'fp-ts/lib/pipeable'; -import { left } from 'fp-ts/lib/Either'; -import { foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; +import { stringifyZodError } from '@kbn/securitysolution-es-utils'; +import { expectParseError, expectParseSuccess } from '../../../../../test/zod_helpers'; +import { RuleExecutionStatus } from '../../model'; +import { GetRuleExecutionResultsRequestQuery } from './get_rule_execution_results_route.gen'; -import { RULE_EXECUTION_STATUSES } from '../../model/execution_status'; -import { - DefaultSortField, - DefaultRuleExecutionStatusCsvArray, -} from './get_rule_execution_results_route'; +const StatusFiltersSchema = GetRuleExecutionResultsRequestQuery.shape.status_filters; +const SortFieldSchema = GetRuleExecutionResultsRequestQuery.shape.sort_field; describe('Request schema of Get rule execution results', () => { describe('DefaultRuleExecutionStatusCsvArray', () => { describe('Validation succeeds', () => { describe('when input is a single rule execution status', () => { - const cases = RULE_EXECUTION_STATUSES.map((supportedStatus) => { + const cases = RuleExecutionStatus.options.map((supportedStatus) => { return { input: supportedStatus }; }); cases.forEach(({ input }) => { it(`${input}`, () => { - const decoded = DefaultRuleExecutionStatusCsvArray.decode(input); - const message = pipe(decoded, foldLeftRight); const expectedOutput = [input]; // note that it's an array after decode + const result = StatusFiltersSchema.safeParse(input); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(expectedOutput); + expectParseSuccess(result); + expect(result.data).toEqual(expectedOutput); }); }); }); @@ -43,12 +40,11 @@ describe('Request schema of Get rule execution results', () => { cases.forEach(({ input }) => { it(`${input}`, () => { - const decoded = DefaultRuleExecutionStatusCsvArray.decode(input); - const message = pipe(decoded, foldLeftRight); const expectedOutput = input; + const result = StatusFiltersSchema.safeParse(input); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(expectedOutput); + expectParseSuccess(result); + expect(result.data).toEqual(expectedOutput); }); }); }); @@ -67,11 +63,10 @@ describe('Request schema of Get rule execution results', () => { cases.forEach(({ input, expectedOutput }) => { it(`${input}`, () => { - const decoded = DefaultRuleExecutionStatusCsvArray.decode(input); - const message = pipe(decoded, foldLeftRight); + const result = StatusFiltersSchema.safeParse(input); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(expectedOutput); + expectParseSuccess(result); + expect(result.data).toEqual(expectedOutput); }); }); }); @@ -82,37 +77,30 @@ describe('Request schema of Get rule execution results', () => { const cases = [ { input: 'val', - expectedErrors: [ - 'Invalid value "val" supplied to "DefaultCsvArray"', - ], + expectedErrors: + "0: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received 'val'", }, { input: '5', - expectedErrors: [ - 'Invalid value "5" supplied to "DefaultCsvArray"', - ], + expectedErrors: + "0: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received '5'", }, { input: 5, - expectedErrors: [ - 'Invalid value "5" supplied to "DefaultCsvArray"', - ], + expectedErrors: 'Expected array, received number', }, { input: {}, - expectedErrors: [ - 'Invalid value "{}" supplied to "DefaultCsvArray"', - ], + expectedErrors: 'Expected array, received object', }, ]; cases.forEach(({ input, expectedErrors }) => { it(`${input}`, () => { - const decoded = DefaultRuleExecutionStatusCsvArray.decode(input); - const message = pipe(decoded, foldLeftRight); + const result = StatusFiltersSchema.safeParse(input); - expect(getPaths(left(message.errors))).toEqual(expectedErrors); - expect(message.schema).toEqual({}); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual(expectedErrors); }); }); }); @@ -121,34 +109,27 @@ describe('Request schema of Get rule execution results', () => { const cases = [ { input: ['value 1', 5], - expectedErrors: [ - 'Invalid value "value 1" supplied to "DefaultCsvArray"', - 'Invalid value "5" supplied to "DefaultCsvArray"', - ], + expectedErrors: + "0: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received 'value 1', 1: Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received number", }, { input: ['value 1', 'succeeded'], - expectedErrors: [ - 'Invalid value "value 1" supplied to "DefaultCsvArray"', - ], + expectedErrors: + "0: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received 'value 1'", }, { input: ['', 5, {}], - expectedErrors: [ - 'Invalid value "" supplied to "DefaultCsvArray"', - 'Invalid value "5" supplied to "DefaultCsvArray"', - 'Invalid value "{}" supplied to "DefaultCsvArray"', - ], + expectedErrors: + "0: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received '', 1: Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received number, 2: Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received object", }, ]; cases.forEach(({ input, expectedErrors }) => { it(`${input}`, () => { - const decoded = DefaultRuleExecutionStatusCsvArray.decode(input); - const message = pipe(decoded, foldLeftRight); + const result = StatusFiltersSchema.safeParse(input); - expect(getPaths(left(message.errors))).toEqual(expectedErrors); - expect(message.schema).toEqual({}); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual(expectedErrors); }); }); }); @@ -157,34 +138,27 @@ describe('Request schema of Get rule execution results', () => { const cases = [ { input: 'value 1,5', - expectedErrors: [ - 'Invalid value "value 1" supplied to "DefaultCsvArray"', - 'Invalid value "5" supplied to "DefaultCsvArray"', - ], + expectedErrors: + "0: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received 'value 1', 1: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received '5'", }, { input: 'value 1,succeeded', - expectedErrors: [ - 'Invalid value "value 1" supplied to "DefaultCsvArray"', - ], + expectedErrors: + "0: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received 'value 1'", }, { input: ',5,{}', - expectedErrors: [ - 'Invalid value "" supplied to "DefaultCsvArray"', - 'Invalid value "5" supplied to "DefaultCsvArray"', - 'Invalid value "{}" supplied to "DefaultCsvArray"', - ], + expectedErrors: + "0: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received '', 1: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received '5', 2: Invalid enum value. Expected 'going to run' | 'running' | 'partial failure' | 'failed' | 'succeeded', received '{}'", }, ]; cases.forEach(({ input, expectedErrors }) => { it(`${input}`, () => { - const decoded = DefaultRuleExecutionStatusCsvArray.decode(input); - const message = pipe(decoded, foldLeftRight); + const result = StatusFiltersSchema.safeParse(input); - expect(getPaths(left(message.errors))).toEqual(expectedErrors); - expect(message.schema).toEqual({}); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual(expectedErrors); }); }); }); @@ -192,16 +166,14 @@ describe('Request schema of Get rule execution results', () => { describe('Validation returns default value (an empty array)', () => { describe('when input is', () => { - const cases = [{ input: null }, { input: undefined }, { input: '' }, { input: [] }]; + const cases = [{ input: undefined }, { input: '' }, { input: [] }]; cases.forEach(({ input }) => { it(`${input}`, () => { - const decoded = DefaultRuleExecutionStatusCsvArray.decode(input); - const message = pipe(decoded, foldLeftRight); - const expectedOutput: string[] = []; + const result = StatusFiltersSchema.safeParse(input); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(expectedOutput); + expectParseSuccess(result); + expect(result.data).toEqual([]); }); }); }); @@ -222,11 +194,9 @@ describe('Request schema of Get rule execution results', () => { cases.forEach(({ input }) => { it(`${input}`, () => { - const decoded = DefaultSortField.decode(input); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(input); + const result = SortFieldSchema.safeParse(input); + expectParseSuccess(result); + expect(result.data).toEqual(input); }); }); }); @@ -244,12 +214,10 @@ describe('Request schema of Get rule execution results', () => { cases.forEach(({ input }) => { it(`${input}`, () => { - const decoded = DefaultSortField.decode(input); - const message = pipe(decoded, foldLeftRight); - const expectedErrors = [`Invalid value "${input}" supplied to "DefaultSortField"`]; - - expect(getPaths(left(message.errors))).toEqual(expectedErrors); - expect(message.schema).toEqual({}); + const expectedErrors = `Invalid enum value. Expected 'timestamp' | 'duration_ms' | 'gap_duration_s' | 'indexing_duration_ms' | 'search_duration_ms' | 'schedule_delay_ms', received '${input}'`; + const result = SortFieldSchema.safeParse(input); + expectParseError(result); + expect(stringifyZodError(result.error)).toEqual(expectedErrors); }); }); }); @@ -257,18 +225,38 @@ describe('Request schema of Get rule execution results', () => { describe('Validation returns the default sort field "timestamp"', () => { describe('when input is', () => { - const cases = [{ input: null }, { input: undefined }]; + const cases = [{ input: undefined }]; cases.forEach(({ input }) => { it(`${input}`, () => { - const decoded = DefaultSortField.decode(input); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual('timestamp'); + const result = SortFieldSchema.safeParse(input); + expectParseSuccess(result); + expect(result.data).toEqual('timestamp'); }); }); }); }); }); + + describe('GetRuleExecutionResultsRequestQuery', () => { + it('should convert string values to numbers', () => { + const result = GetRuleExecutionResultsRequestQuery.safeParse({ + start: '2021-08-01T00:00:00.000Z', + end: '2021-08-02T00:00:00.000Z', + page: '1', + per_page: '10', + }); + expectParseSuccess(result); + expect(result.data).toEqual({ + end: '2021-08-02T00:00:00.000Z', + page: 1, + per_page: 10, + query_text: '', + sort_field: 'timestamp', + sort_order: 'desc', + start: '2021-08-01T00:00:00.000Z', + status_filters: [], + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts deleted file mode 100644 index 56370acbce5bd..0000000000000 --- a/x-pack/plugins/security_solution/common/api/detection_engine/rule_monitoring/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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 * as t from 'io-ts'; - -import { DefaultPage, DefaultPerPage } from '@kbn/securitysolution-io-ts-alerting-types'; -import { - defaultCsvArray, - DefaultEmptyString, - defaultValue, - IsoDateString, - NonEmptyString, -} from '@kbn/securitysolution-io-ts-types'; - -import { DefaultSortOrderDesc } from '../../../model'; -import { - RuleExecutionResult, - SortFieldOfRuleExecutionResult, - TRuleExecutionStatus, -} from '../../model'; - -/** - * Types the DefaultRuleExecutionStatusCsvArray as: - * - If not specified, then a default empty array will be set - * - If an array is sent in, then the array will be validated to ensure all elements are a RuleExecutionStatus - * (or that the array is empty) - * - If a CSV string is sent in, then it will be parsed to an array which will be validated - */ -export const DefaultRuleExecutionStatusCsvArray = defaultCsvArray(TRuleExecutionStatus); - -/** - * Types the DefaultSortField as: - * - If undefined, then a default sort field of 'timestamp' will be set - * - If a string is sent in, then the string will be validated to ensure it is as valid sortFields - */ -export const DefaultSortField = defaultValue( - SortFieldOfRuleExecutionResult, - 'timestamp', - 'DefaultSortField' -); - -/** - * Path parameters of the API route. - */ -export type GetRuleExecutionResultsRequestParams = t.TypeOf< - typeof GetRuleExecutionResultsRequestParams ->; -export const GetRuleExecutionResultsRequestParams = t.exact( - t.type({ - ruleId: NonEmptyString, - }) -); - -/** - * Query string parameters of the API route. - */ -export type GetRuleExecutionResultsRequestQuery = t.TypeOf< - typeof GetRuleExecutionResultsRequestQuery ->; -export const GetRuleExecutionResultsRequestQuery = t.exact( - t.type({ - start: IsoDateString, - end: IsoDateString, - query_text: DefaultEmptyString, // defaults to '' - status_filters: DefaultRuleExecutionStatusCsvArray, // defaults to [] - sort_field: DefaultSortField, // defaults to 'timestamp' - sort_order: DefaultSortOrderDesc, // defaults to 'desc' - page: DefaultPage, // defaults to 1 - per_page: DefaultPerPage, // defaults to 20 - }) -); - -/** - * Response body of the API route. - */ -export type GetRuleExecutionResultsResponse = t.TypeOf; -export const GetRuleExecutionResultsResponse = t.exact( - t.type({ - events: t.array(RuleExecutionResult), - total: t.number, - }) -); diff --git a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts index 24a9a24f8fd8e..c164068f23709 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts @@ -19,7 +19,7 @@ import { SavedObjectResolveAliasTargetId, SavedObjectResolveOutcome, } from '../../detection_engine/model/rule_schema'; -import { errorSchema, success, success_count as successCount } from '../../detection_engine'; +import { ErrorSchema, success, success_count as successCount } from '../../detection_engine'; export const BareNoteSchema = runtimeTypes.intersection([ runtimeTypes.type({ @@ -499,7 +499,7 @@ export const importTimelineResultSchema = runtimeTypes.exact( success_count: successCount, timelines_installed: PositiveInteger, timelines_updated: PositiveInteger, - errors: runtimeTypes.array(errorSchema), + errors: runtimeTypes.array(ErrorSchema), }) ); diff --git a/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.ts b/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.ts index b8fe93efc722a..bfc31df88ec97 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/rule_management/rule_filtering.ts @@ -6,7 +6,8 @@ */ import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; -import { RuleExecutionStatus } from '../../api/detection_engine'; +import type { RuleExecutionStatus } from '../../api/detection_engine'; +import { RuleExecutionStatusEnum } from '../../api/detection_engine'; import { prepareKQLStringParam } from '../../utils/kql'; import { ENABLED_FIELD, @@ -75,11 +76,11 @@ export function convertRulesFilterToKQL({ kql.push(`NOT ${convertRuleTypesToKQL(excludeRuleTypes)}`); } - if (ruleExecutionStatus === RuleExecutionStatus.succeeded) { + if (ruleExecutionStatus === RuleExecutionStatusEnum.succeeded) { kql.push(`${LAST_RUN_OUTCOME_FIELD}: "succeeded"`); - } else if (ruleExecutionStatus === RuleExecutionStatus['partial failure']) { + } else if (ruleExecutionStatus === RuleExecutionStatusEnum['partial failure']) { kql.push(`${LAST_RUN_OUTCOME_FIELD}: "warning"`); - } else if (ruleExecutionStatus === RuleExecutionStatus.failed) { + } else if (ruleExecutionStatus === RuleExecutionStatusEnum.failed) { kql.push(`${LAST_RUN_OUTCOME_FIELD}: "failed"`); } diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts index 8c3c697b80e2c..a3b3ebaf26142 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts @@ -11,8 +11,8 @@ import { createFleetAuthzMock } from '@kbn/fleet-plugin/common/mocks'; import { createLicenseServiceMock } from '../../../license/mocks'; import type { EndpointAuthzKeyList } from '../../types/authz'; import { - RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL, CONSOLE_RESPONSE_ACTION_COMMANDS, + RESPONSE_CONSOLE_ACTION_COMMANDS_TO_RBAC_FEATURE_CONTROL, type ResponseConsoleRbacControls, } from '../response_actions/constants'; @@ -150,6 +150,14 @@ describe('Endpoint Authz service', () => { expect(authz[auth]).toBe(true); }); + it.each<[EndpointAuthzKeyList[number], string]>([ + ['canReadEndpointExceptions', 'showEndpointExceptions'], + ['canWriteEndpointExceptions', 'crudEndpointExceptions'], + ])('%s should be true if `endpointExceptionsPrivileges.%s` is `true`', (auth) => { + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles); + expect(authz[auth]).toBe(true); + }); + it.each<[EndpointAuthzKeyList[number], string[]]>([ ['canWriteEndpointList', ['writeEndpointList']], ['canReadEndpointList', ['readEndpointList']], @@ -181,6 +189,20 @@ describe('Endpoint Authz service', () => { privileges.forEach((privilege) => { fleetAuthz.packagePrivileges!.endpoint.actions[privilege].executePackageAction = false; }); + + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles); + expect(authz[auth]).toBe(false); + }); + + it.each<[EndpointAuthzKeyList[number], string[]]>([ + ['canReadEndpointExceptions', ['showEndpointExceptions']], + ['canWriteEndpointExceptions', ['crudEndpointExceptions']], + ])('%s should be false if `endpointExceptionsPrivileges.%s` is `false`', (auth, privileges) => { + privileges.forEach((privilege) => { + // @ts-ignore + fleetAuthz.endpointExceptionsPrivileges!.actions[privilege] = false; + }); + const authz = calculateEndpointAuthz(licenseService, fleetAuthz, userRoles); expect(authz[auth]).toBe(false); }); @@ -281,6 +303,8 @@ describe('Endpoint Authz service', () => { canReadBlocklist: false, canWriteEventFilters: false, canReadEventFilters: false, + canReadEndpointExceptions: false, + canWriteEndpointExceptions: false, }); }); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts index c0c5ee06d5488..922e8c3cdd383 100644 --- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts +++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts @@ -30,6 +30,13 @@ export function hasKibanaPrivilege( return fleetAuthz.packagePrivileges?.endpoint?.actions[privilege].executePackageAction ?? false; } +export function hasEndpointExceptionsPrivilege( + fleetAuthz: FleetAuthz, + privilege: 'showEndpointExceptions' | 'crudEndpointExceptions' +): boolean { + return fleetAuthz.endpointExceptionsPrivileges?.actions[privilege] ?? false; +} + /** * Used by both the server and the UI to generate the Authorization for access to Endpoint related * functionality @@ -84,6 +91,15 @@ export const calculateEndpointAuthz = ( const canWriteExecuteOperations = hasKibanaPrivilege(fleetAuthz, 'writeExecuteOperations'); + const canReadEndpointExceptions = hasEndpointExceptionsPrivilege( + fleetAuthz, + 'showEndpointExceptions' + ); + const canWriteEndpointExceptions = hasEndpointExceptionsPrivilege( + fleetAuthz, + 'crudEndpointExceptions' + ); + const authz: EndpointAuthz = { canWriteSecuritySolution, canReadSecuritySolution, @@ -123,6 +139,8 @@ export const calculateEndpointAuthz = ( canReadBlocklist, canWriteEventFilters, canReadEventFilters, + canReadEndpointExceptions, + canWriteEndpointExceptions, }; // Response console is only accessible when license is Enterprise and user has access to any @@ -172,5 +190,7 @@ export const getEndpointAuthzInitialState = (): EndpointAuthz => { canReadBlocklist: false, canWriteEventFilters: false, canReadEventFilters: false, + canReadEndpointExceptions: false, + canWriteEndpointExceptions: false, }; }; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts index 23ed5cbe7c439..1ec8f84a87073 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts @@ -10,74 +10,79 @@ * used both on the client and server for consistency */ export interface EndpointAuthz { - /** if user has write permissions to the security solution app */ + /** If the user has write permissions to the security solution app */ canWriteSecuritySolution: boolean; - /** if user has read permissions to the security solution app */ + /** If the user has read permissions to the security solution app */ canReadSecuritySolution: boolean; - /** If user has permissions to access Fleet */ + /** If the user has permissions to access Fleet */ canAccessFleet: boolean; - /** If user has permissions to access Endpoint management (includes check to ensure they also have access to fleet) */ + /** If the user has permissions to access Endpoint management (includes check to ensure they also have access to fleet) */ canAccessEndpointManagement: boolean; - /** If user has permissions to access Actions Log management and also has a platinum license (used for endpoint details flyout) */ + /** If the user has permissions to access Actions Log management and also has a platinum license (used for endpoint details flyout) */ canAccessEndpointActionsLogManagement: boolean; - /** if user has permissions to create Artifacts by Policy */ + /** If the user has permissions to create Artifacts by Policy */ canCreateArtifactsByPolicy: boolean; - /** if user has write permissions to endpoint list */ + /** If the user has write permissions to endpoint list */ canWriteEndpointList: boolean; - /** if user has read permissions to endpoint list */ + /** If the user has read permissions to endpoint list */ canReadEndpointList: boolean; - /** if user has write permissions for policy management */ + /** If the user has write permissions for policy management */ canWritePolicyManagement: boolean; - /** if user has read permissions for policy management */ + /** If the user has read permissions for policy management */ canReadPolicyManagement: boolean; - /** if user has write permissions for actions log management */ + /** If the user has write permissions for actions log management */ canWriteActionsLogManagement: boolean; - /** if user has read permissions for actions log management */ + /** If the user has read permissions for actions log management */ canReadActionsLogManagement: boolean; - /** If user has permissions to isolate hosts */ + /** If the user has permissions to isolate hosts */ canIsolateHost: boolean; - /** If user has permissions to un-isolate (release) hosts */ + /** If the user has permissions to un-isolate (release) hosts */ canUnIsolateHost: boolean; - /** If user has permissions to kill process on hosts */ + /** If the user has permissions to kill process on hosts */ canKillProcess: boolean; - /** If user has permissions to suspend process on hosts */ + /** If the user has permissions to suspend process on hosts */ canSuspendProcess: boolean; - /** If user has permissions to get running processes on hosts */ + /** If the user has permissions to get running processes on hosts */ canGetRunningProcesses: boolean; - /** If user has permissions to use the Response Actions Console */ + /** If the user has permissions to use the Response Actions Console */ canAccessResponseConsole: boolean; - /** If user has write permissions to use execute action */ + /** If the user has write permissions to use execute action */ canWriteExecuteOperations: boolean; - /** If user has write permissions to use file operations */ + /** If the user has write permissions to use file operations */ canWriteFileOperations: boolean; - /** if user has write permissions for trusted applications */ + /** If the user has write permissions for trusted applications */ canWriteTrustedApplications: boolean; - /** if user has read permissions for trusted applications */ + /** If the user has read permissions for trusted applications */ canReadTrustedApplications: boolean; - /** if user has write permissions for host isolation exceptions */ + /** If the user has write permissions for host isolation exceptions */ canWriteHostIsolationExceptions: boolean; - /** if user has read permissions for host isolation exceptions */ + /** If the user has read permissions for host isolation exceptions */ canReadHostIsolationExceptions: boolean; /** - * if user has permissions to access host isolation exceptions. This could be set to false, while + * If the user has permissions to access host isolation exceptions. This could be set to false, while * `canReadHostIsolationExceptions` is true in cases where the license might have been downgraded. * It is used to show the UI elements that allow users to navigate to the host isolation exceptions. */ canAccessHostIsolationExceptions: boolean; /** - * if user has permissions to delete host isolation exceptions. This could be set to true, while + * If the user has permissions to delete host isolation exceptions. This could be set to true, while * `canWriteHostIsolationExceptions` is false in cases where the license might have been downgraded. * In that use case, users should still be allowed to ONLY delete entries. */ canDeleteHostIsolationExceptions: boolean; - /** if user has write permissions for blocklist entries */ + /** If the user has write permissions for blocklist entries */ canWriteBlocklist: boolean; - /** if user has read permissions for blocklist entries */ + /** If the user has read permissions for blocklist entries */ canReadBlocklist: boolean; - /** if user has write permissions for event filters */ + /** If the user has write permissions for event filters */ canWriteEventFilters: boolean; - /** if user has read permissions for event filters */ + /** If the user has read permissions for event filters */ canReadEventFilters: boolean; + + /** if the user has write permissions for endpoint exceptions */ + canReadEndpointExceptions: boolean; + /** if the user has read permissions for endpoint exceptions */ + canWriteEndpointExceptions: boolean; } export type EndpointAuthzKeyList = Array; diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index 4b9941b1cbe5d..fcf4fd666a58a 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -38,7 +38,6 @@ interface StartAppComponent { children: React.ReactNode; history: History; onAppLeave: (handler: AppLeaveHandler) => void; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; store: Store; theme$: AppMountParameters['theme$']; } @@ -46,7 +45,6 @@ interface StartAppComponent { const StartAppComponent: FC = ({ children, history, - setHeaderActionMenu, onAppLeave, store, theme$, @@ -79,11 +77,7 @@ const StartAppComponent: FC = ({ > - + {children} @@ -113,7 +107,6 @@ interface SecurityAppComponentProps { history: History; onAppLeave: (handler: AppLeaveHandler) => void; services: StartServices; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; store: Store; theme$: AppMountParameters['theme$']; } @@ -123,7 +116,6 @@ const SecurityAppComponent: React.FC = ({ history, onAppLeave, services, - setHeaderActionMenu, store, theme$, }) => { @@ -137,13 +129,7 @@ const SecurityAppComponent: React.FC = ({ }} > - + {children} diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx index fe0b5bd500dc8..bfce21c47867c 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.test.tsx @@ -49,7 +49,6 @@ jest.mock('react-reverse-portal', () => ({ })); describe('global header', () => { - const mockSetHeaderActionMenu = jest.fn(); const state = { ...mockGlobalState, timeline: { @@ -75,7 +74,7 @@ describe('global header', () => { ]); const { getByText } = render( - + ); expect(getByText('Add integrations')).toBeInTheDocument(); @@ -87,7 +86,7 @@ describe('global header', () => { ]); const { queryByTestId } = render( - + ); const link = queryByTestId('add-data'); @@ -98,7 +97,7 @@ describe('global header', () => { (useLocation as jest.Mock).mockReturnValue({ pathname: THREAT_INTELLIGENCE_PATH }); const { queryByTestId } = render( - + ); const link = queryByTestId('add-data'); @@ -118,7 +117,7 @@ describe('global header', () => { ); const { queryByTestId } = render( - + ); const link = queryByTestId('add-data'); @@ -130,7 +129,7 @@ describe('global header', () => { const { getByTestId } = render( - + ); expect(getByTestId('sourcerer-trigger')).toBeInTheDocument(); @@ -141,7 +140,7 @@ describe('global header', () => { const { getByTestId } = render( - + ); expect(getByTestId('sourcerer-trigger')).toBeInTheDocument(); @@ -166,7 +165,7 @@ describe('global header', () => { const { queryByTestId } = render( - + ); @@ -180,7 +179,7 @@ describe('global header', () => { const { findByTestId } = render( - + ); diff --git a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx index bde0b71a43270..e5a12721a6292 100644 --- a/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/global_header/index.tsx @@ -15,11 +15,10 @@ import { useLocation } from 'react-router-dom'; import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { i18n } from '@kbn/i18n'; -import type { AppMountParameters } from '@kbn/core/public'; -import { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import { toMountPoint } from '@kbn/react-kibana-mount'; import { MlPopover } from '../../../common/components/ml_popover/ml_popover'; import { useKibana } from '../../../common/lib/kibana'; -import { isDetectionsPath } from '../../../helpers'; +import { isDetectionsPath, isDashboardViewPath } from '../../../helpers'; import { Sourcerer } from '../../../common/components/sourcerer'; import { TimelineId } from '../../../../common/types/timeline'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; @@ -37,63 +36,69 @@ const BUTTON_ADD_DATA = i18n.translate('xpack.securitySolution.globalHeader.butt * This component uses the reverse portal to add the Add Data, ML job settings, and AI Assistant buttons on the * right hand side of the Kibana global header */ -export const GlobalHeader = React.memo( - ({ setHeaderActionMenu }: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'] }) => { - const portalNode = useMemo(() => createHtmlPortalNode(), []); - const { theme } = useKibana().services; - const { pathname } = useLocation(); +export const GlobalHeader = React.memo(() => { + const portalNode = useMemo(() => createHtmlPortalNode(), []); + const { theme, setHeaderActionMenu, i18n: kibanaServiceI18n } = useKibana().services; + const { pathname } = useLocation(); - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const showTimeline = useShallowEqualSelector( - (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show - ); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const showTimeline = useShallowEqualSelector( + (state) => (getTimeline(state, TimelineId.active) ?? timelineDefaults).show + ); - const sourcererScope = getScopeFromPath(pathname); - const showSourcerer = showSourcererByPath(pathname); + const sourcererScope = getScopeFromPath(pathname); + const showSourcerer = showSourcererByPath(pathname); + const dashboardViewPath = isDashboardViewPath(pathname); - const { href, onClick } = useAddIntegrationsUrl(); + const { href, onClick } = useAddIntegrationsUrl(); - useEffect(() => { - setHeaderActionMenu((element) => { - const mount = toMountPoint(, { theme$: theme.theme$ }); - return mount(element); + useEffect(() => { + setHeaderActionMenu((element) => { + const mount = toMountPoint(, { + theme, + i18n: kibanaServiceI18n, }); + return mount(element); + }); - return () => { - portalNode.unmount(); - setHeaderActionMenu(undefined); - }; - }, [portalNode, setHeaderActionMenu, theme.theme$]); - - return ( - - - {isDetectionsPath(pathname) && ( - - - - )} + return () => { + /* Dashboard mounts an edit toolbar, it should be restored when leaving dashboard editing page */ + if (dashboardViewPath) { + return; + } + portalNode.unmount(); + setHeaderActionMenu(undefined); + }; + }, [portalNode, setHeaderActionMenu, theme, kibanaServiceI18n, dashboardViewPath]); + return ( + + + {isDetectionsPath(pathname) && ( - - - {BUTTON_ADD_DATA} - - {showSourcerer && !showTimeline && ( - - )} - - + - - - ); - } -); + )} + + + + + {BUTTON_ADD_DATA} + + {showSourcerer && !showTimeline && ( + + )} + + + + + + ); +}); GlobalHeader.displayName = 'GlobalHeader'; diff --git a/x-pack/plugins/security_solution/public/app/home/index.test.tsx b/x-pack/plugins/security_solution/public/app/home/index.test.tsx index 9bce539a0b311..fc62a8236f9cc 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.test.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.test.tsx @@ -106,6 +106,7 @@ jest.mock('../../timelines/store/timeline', () => ({ const mockedFilterManager = new FilterManager(coreMock.createStart().uiSettings); const mockGetSavedQuery = jest.fn(); +const mockSetHeaderActionMenu = jest.fn(); const dummyFilter: Filter = { meta: { @@ -198,6 +199,7 @@ jest.mock('../../common/lib/kibana', () => { savedQueries: { getSavedQuery: mockGetSavedQuery }, }, }, + setHeaderActionMenu: mockSetHeaderActionMenu, }, }), KibanaServices: { @@ -226,7 +228,7 @@ describe('HomePage', () => { it('calls useInitializeUrlParam for appQuery, filters and savedQuery', () => { render( - + @@ -252,7 +254,7 @@ describe('HomePage', () => { render( - + @@ -294,7 +296,7 @@ describe('HomePage', () => { render( - + @@ -326,7 +328,7 @@ describe('HomePage', () => { render( - + @@ -361,7 +363,7 @@ describe('HomePage', () => { render( - + @@ -378,7 +380,7 @@ describe('HomePage', () => { render( - + @@ -420,7 +422,7 @@ describe('HomePage', () => { render( - + @@ -465,7 +467,7 @@ describe('HomePage', () => { render( - + @@ -515,7 +517,7 @@ describe('HomePage', () => { const TestComponent = () => ( - + @@ -572,7 +574,7 @@ describe('HomePage', () => { const TestComponent = () => ( - + @@ -612,7 +614,7 @@ describe('HomePage', () => { render( - + @@ -637,7 +639,7 @@ describe('HomePage', () => { const TestComponent = () => ( - + @@ -669,7 +671,7 @@ describe('HomePage', () => { const TestComponent = () => ( - + diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index b951501b16cb7..bded1d58c8d84 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { useLocation } from 'react-router-dom'; -import type { AppMountParameters } from '@kbn/core/public'; import { DragDropContextWrapper } from '../../common/components/drag_and_drop/drag_drop_context_wrapper'; import { SecuritySolutionAppWrapper } from '../../common/components/page'; @@ -33,10 +32,9 @@ import { AssistantOverlay } from '../../assistant/overlay'; interface HomePageProps { children: React.ReactNode; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } -const HomePageComponent: React.FC = ({ children, setHeaderActionMenu }) => { +const HomePageComponent: React.FC = ({ children }) => { const { pathname } = useLocation(); useInitSourcerer(getScopeFromPath(pathname)); useUrlState(); @@ -58,7 +56,7 @@ const HomePageComponent: React.FC = ({ children, setHeaderActionM <> - + {children} diff --git a/x-pack/plugins/security_solution/public/app/index.tsx b/x-pack/plugins/security_solution/public/app/index.tsx index e635c2d9fd3d3..6f0fc3eb8d01c 100644 --- a/x-pack/plugins/security_solution/public/app/index.tsx +++ b/x-pack/plugins/security_solution/public/app/index.tsx @@ -16,7 +16,6 @@ export const renderApp = ({ element, history, onAppLeave, - setHeaderActionMenu, services, store, usageCollection, @@ -31,7 +30,6 @@ export const renderApp = ({ history={history} onAppLeave={onAppLeave} services={services} - setHeaderActionMenu={setHeaderActionMenu} store={store} theme$={theme$} > diff --git a/x-pack/plugins/security_solution/public/app/routes.tsx b/x-pack/plugins/security_solution/public/app/routes.tsx index af5aaba76363f..73fe2615b0e5a 100644 --- a/x-pack/plugins/security_solution/public/app/routes.tsx +++ b/x-pack/plugins/security_solution/public/app/routes.tsx @@ -10,7 +10,7 @@ import type { FC } from 'react'; import React, { memo, useEffect } from 'react'; import { Router, Routes, Route } from '@kbn/shared-ux-router'; import { useDispatch } from 'react-redux'; -import type { AppLeaveHandler, AppMountParameters } from '@kbn/core/public'; +import type { AppLeaveHandler } from '@kbn/core/public'; import { APP_ID } from '../../common/constants'; import { RouteCapture } from '../common/components/endpoint/route_capture'; @@ -24,15 +24,9 @@ interface RouterProps { children: React.ReactNode; history: History; onAppLeave: (handler: AppLeaveHandler) => void; - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; } -const PageRouterComponent: FC = ({ - children, - history, - onAppLeave, - setHeaderActionMenu, -}) => { +const PageRouterComponent: FC = ({ children, history, onAppLeave }) => { const { cases } = useKibana().services; const CasesContext = cases.ui.getCasesContext(); const userCasesPermissions = useGetUserCasesPermissions(); @@ -55,7 +49,7 @@ const PageRouterComponent: FC = ({ - {children} + {children} diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.ts rename to x-pack/plugins/security_solution/public/common/components/visualization_actions/use_actions.test.tsx diff --git a/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts b/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts index d7401ff19d916..9d13b857c155e 100644 --- a/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/common/containers/tags/__mocks__/api.ts @@ -34,3 +34,14 @@ export const getTagsByName = jest export const createTag = jest .fn() .mockImplementation(() => Promise.resolve(DEFAULT_CREATE_TAGS_RESPONSE[0])); + +export const fetchTags = jest.fn().mockImplementation(({ tagIds }: { tagIds: string[] }) => + Promise.resolve( + tagIds.map((id, i) => ({ + id, + name: `${MOCK_TAG_NAME}-${i}`, + description: 'test tag description', + color: '#2c7b8', + })) + ) +); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts index 9c78db17abf37..7c9d7c5f0656c 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.mock.ts @@ -121,6 +121,7 @@ export const createStartServicesMock = ( const cloudExperiments = cloudExperimentsMock.createStartMock(); const guidedOnboarding = guidedOnboardingMock.createStart(); const cloud = cloudMock.createStart(); + const mockSetHeaderActionMenu = jest.fn(); return { ...core, @@ -220,6 +221,7 @@ export const createStartServicesMock = ( customDataService, uiActions: uiActionsPluginMock.createStartContract(), savedSearch: savedSearchPluginMock.createStartContract(), + setHeaderActionMenu: mockSetHeaderActionMenu, } as unknown as StartServices; }; diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.test.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.test.tsx index c01f07fa36653..9c6df7bb6e395 100644 --- a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.test.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.test.tsx @@ -12,13 +12,10 @@ import { DashboardRenderer as DashboardContainerRenderer } from '@kbn/dashboard- import { TestProviders } from '../../common/mock'; import { DashboardRenderer } from './dashboard_renderer'; -jest.mock('@kbn/dashboard-plugin/public', () => { - const actual = jest.requireActual('@kbn/dashboard-plugin/public'); - return { - ...actual, - DashboardRenderer: jest.fn().mockReturnValue(
), - }; -}); +jest.mock('@kbn/dashboard-plugin/public', () => ({ + DashboardRenderer: jest.fn().mockReturnValue(
), + DashboardTopNav: jest.fn().mockReturnValue(), +})); jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.tsx index aa51842a33c1e..73538439de568 100644 --- a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_renderer.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React, { useCallback, useEffect, useState } from 'react'; -import type { DashboardAPI } from '@kbn/dashboard-plugin/public'; +import type { DashboardAPI, DashboardCreationOptions } from '@kbn/dashboard-plugin/public'; import { DashboardRenderer as DashboardContainerRenderer } from '@kbn/dashboard-plugin/public'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import type { Filter, Query } from '@kbn/es-query'; @@ -13,9 +13,14 @@ import type { Filter, Query } from '@kbn/es-query'; import { useDispatch } from 'react-redux'; import { InputsModelId } from '../../common/store/inputs/constants'; import { inputsActions } from '../../common/store/inputs'; +import { useKibana } from '../../common/lib/kibana'; +import { APP_UI_ID } from '../../../common'; +import { useSecurityTags } from '../context/dashboard_context'; +import { DASHBOARDS_PATH } from '../../../common/constants'; const DashboardRendererComponent = ({ canReadDashboard, + dashboardContainer, filters, id, inputId = InputsModelId.global, @@ -23,8 +28,10 @@ const DashboardRendererComponent = ({ query, savedObjectId, timeRange, + viewMode = ViewMode.VIEW, }: { canReadDashboard: boolean; + dashboardContainer?: DashboardAPI; filters?: Filter[]; id: string; inputId?: InputsModelId.global | InputsModelId.timeline; @@ -37,17 +44,36 @@ const DashboardRendererComponent = ({ to: string; toStr?: string | undefined; }; + viewMode?: ViewMode; }) => { + const { embeddable } = useKibana().services; const dispatch = useDispatch(); - const [dashboardContainer, setDashboardContainer] = useState(); - const getCreationOptions = useCallback( + const securityTags = useSecurityTags(); + const firstSecurityTagId = securityTags?.[0]?.id; + + const isCreateDashboard = !savedObjectId; + + const getCreationOptions: () => Promise = useCallback( () => Promise.resolve({ - getInitialInput: () => ({ timeRange, viewMode: ViewMode.VIEW, query, filters }), + useSessionStorageIntegration: true, useControlGroupIntegration: true, + getInitialInput: () => ({ + timeRange, + viewMode, + query, + filters, + }), + getIncomingEmbeddable: () => + embeddable.getStateTransfer().getIncomingEmbeddablePackage(APP_UI_ID, true), + getEmbeddableAppContext: (dashboardId?: string) => ({ + getCurrentPath: () => + dashboardId ? `${DASHBOARDS_PATH}/${dashboardId}/edit` : `${DASHBOARDS_PATH}/create`, + currentAppId: APP_UI_ID, + }), }), - [filters, query, timeRange] + [embeddable, filters, query, timeRange, viewMode] ); const refetchByForceRefresh = useCallback(() => { @@ -73,20 +99,33 @@ const DashboardRendererComponent = ({ dashboardContainer?.updateInput({ timeRange, query, filters }); }, [dashboardContainer, filters, query, timeRange]); - const handleDashboardLoaded = useCallback( - (container: DashboardAPI) => { - setDashboardContainer(container); - onDashboardContainerLoaded?.(container); - }, - [onDashboardContainerLoaded] - ); - return savedObjectId && canReadDashboard ? ( - - ) : null; + useEffect(() => { + if (isCreateDashboard && firstSecurityTagId) + dashboardContainer?.updateInput({ tags: [firstSecurityTagId] }); + }, [dashboardContainer, firstSecurityTagId, isCreateDashboard]); + + /** Dashboard renderer is stored in the state as it's a temporary solution for + * https://github.com/elastic/kibana/issues/167751 + **/ + const [dashboardContainerRenderer, setDashboardContainerRenderer] = useState< + React.ReactElement | undefined + >(undefined); + + useEffect(() => { + setDashboardContainerRenderer( + + ); + + return () => { + setDashboardContainerRenderer(undefined); + }; + }, [getCreationOptions, onDashboardContainerLoaded, refetchByForceRefresh, savedObjectId]); + + return canReadDashboard ? <>{dashboardContainerRenderer} : null; }; DashboardRendererComponent.displayName = 'DashboardRendererComponent'; export const DashboardRenderer = React.memo(DashboardRendererComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_title.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_title.tsx new file mode 100644 index 0000000000000..67d8e73bacdb6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_title.tsx @@ -0,0 +1,31 @@ +/* + * 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, { useEffect } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import type { DashboardAPI } from '@kbn/dashboard-plugin/public'; +import { EDIT_DASHBOARD_TITLE } from '../pages/details/translations'; + +const DashboardTitleComponent = ({ + dashboardContainer, + onTitleLoaded, +}: { + dashboardContainer: DashboardAPI; + onTitleLoaded: (title: string) => void; +}) => { + const dashboardTitle = dashboardContainer.select((state) => state.explicitInput.title).trim(); + const title = + dashboardTitle && dashboardTitle.length !== 0 ? dashboardTitle : EDIT_DASHBOARD_TITLE; + + useEffect(() => { + onTitleLoaded(title); + }, [dashboardContainer, title, onTitleLoaded]); + + return dashboardTitle != null ? {title} : ; +}; + +export const DashboardTitle = React.memo(DashboardTitleComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.test.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.test.tsx new file mode 100644 index 0000000000000..da0bf3e3dbcea --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 { DashboardToolBar } from './dashboard_tool_bar'; +import type { DashboardAPI } from '@kbn/dashboard-plugin/public'; +import { coreMock } from '@kbn/core/public/mocks'; +import { DashboardTopNav } from '@kbn/dashboard-plugin/public'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; +import { APP_NAME } from '../../../common/constants'; +import { NavigationProvider, SecurityPageName } from '@kbn/security-solution-navigation'; +import { TestProviders } from '../../common/mock'; +import { useNavigation } from '../../common/lib/kibana'; + +const mockDashboardTopNav = DashboardTopNav as jest.Mock; + +jest.mock('../../common/lib/kibana', () => { + const actual = jest.requireActual('../../common/lib/kibana'); + return { + ...actual, + useNavigation: jest.fn(), + useCapabilities: jest.fn(() => ({ showWriteControls: true })), + }; +}); +jest.mock('../../common/components/link_to', () => ({ useGetSecuritySolutionUrl: jest.fn() })); +jest.mock('@kbn/dashboard-plugin/public', () => ({ + DashboardTopNav: jest.fn(() =>
), +})); +const mockCore = coreMock.createStart(); +const mockNavigateTo = jest.fn(); +const mockGetAppUrl = jest.fn(); +const mockDashboardContainer = { + select: jest.fn(), +} as unknown as DashboardAPI; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +describe('DashboardToolBar', () => { + const mockOnLoad = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useNavigation as jest.Mock).mockReturnValue({ + navigateTo: mockNavigateTo, + getAppUrl: mockGetAppUrl, + }); + render(, { + wrapper, + }); + }); + it('should render the DashboardToolBar component', () => { + expect(screen.getByTestId('dashboard-top-nav')).toBeInTheDocument(); + }); + + it('should render the DashboardToolBar component with the correct props for view mode', () => { + expect(mockOnLoad).toHaveBeenCalledWith(ViewMode.VIEW); + }); + + it('should render the DashboardTopNav component with the correct redirect to listing url', () => { + mockDashboardTopNav.mock.calls[0][0].redirectTo({ destination: 'listing' }); + }); + + it('should render the DashboardTopNav component with the correct breadcrumb', () => { + expect(mockGetAppUrl.mock.calls[0][0].deepLinkId).toEqual(SecurityPageName.landing); + expect(mockDashboardTopNav.mock.calls[0][0].customLeadingBreadCrumbs[0].text).toEqual(APP_NAME); + }); + + it('should render the DashboardTopNav component with the correct redirect to create dashboard url', () => { + mockDashboardTopNav.mock.calls[0][0].redirectTo({ destination: 'dashboard' }); + + expect(mockNavigateTo.mock.calls[0][0].deepLinkId).toEqual(SecurityPageName.dashboards); + expect(mockNavigateTo.mock.calls[0][0].path).toEqual(`/create`); + }); + + it('should render the DashboardTopNav component with the correct redirect to edit dashboard url', () => { + const mockDashboardId = 'dashboard123'; + + mockDashboardTopNav.mock.calls[0][0].redirectTo({ + destination: 'dashboard', + id: mockDashboardId, + }); + expect(mockNavigateTo.mock.calls[0][0].deepLinkId).toEqual(SecurityPageName.dashboards); + expect(mockNavigateTo.mock.calls[0][0].path).toEqual(`${mockDashboardId}/edit`); + }); + + it('should render the DashboardTopNav component with the correct props', () => { + expect(mockDashboardTopNav.mock.calls[0][0].embedSettings).toEqual( + expect.objectContaining({ + forceHideFilterBar: true, + forceShowTopNavMenu: true, + forceShowDatePicker: false, + forceShowQueryInput: false, + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.tsx b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.tsx new file mode 100644 index 0000000000000..eb74f7c563500 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/components/dashboard_tool_bar.tsx @@ -0,0 +1,86 @@ +/* + * 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, useEffect, useMemo } from 'react'; +import type { DashboardAPI } from '@kbn/dashboard-plugin/public'; +import { DashboardTopNav, LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; + +import type { ChromeBreadcrumb } from '@kbn/core/public'; +import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common'; +import { SecurityPageName } from '../../../common'; +import { useCapabilities, useKibana, useNavigation } from '../../common/lib/kibana'; +import { APP_NAME } from '../../../common/constants'; + +const DashboardToolBarComponent = ({ + dashboardContainer, + onLoad, +}: { + dashboardContainer: DashboardAPI; + onLoad?: (mode: ViewMode) => void; +}) => { + const { setHeaderActionMenu } = useKibana().services; + + const viewMode = + dashboardContainer?.select((state) => state.explicitInput.viewMode) ?? ViewMode.VIEW; + + const { navigateTo, getAppUrl } = useNavigation(); + const redirectTo = useCallback( + ({ destination, id }) => { + if (destination === 'listing') { + navigateTo({ deepLinkId: SecurityPageName.dashboards }); + } + if (destination === 'dashboard') { + navigateTo({ + deepLinkId: SecurityPageName.dashboards, + path: id ? `${id}/edit` : `/create`, + }); + } + }, + [navigateTo] + ); + + const landingBreadcrumb: ChromeBreadcrumb[] = useMemo( + () => [ + { + text: APP_NAME, + href: getAppUrl({ deepLinkId: SecurityPageName.landing }), + }, + ], + [getAppUrl] + ); + + useEffect(() => { + onLoad?.(viewMode); + }, [onLoad, viewMode]); + + const embedSettings = useMemo( + () => ({ + forceHideFilterBar: true, + forceShowTopNavMenu: true, + forceShowQueryInput: false, + forceShowDatePicker: false, + }), + [] + ); + const { showWriteControls } = useCapabilities(LEGACY_DASHBOARD_APP_ID); + + return showWriteControls ? ( + + ) : null; +}; + +export const DashboardToolBar = React.memo(DashboardToolBarComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.test.tsx b/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.test.tsx deleted file mode 100644 index 43afa552d50fd..0000000000000 --- a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 { RenderResult } from '@testing-library/react'; -import { fireEvent, render } from '@testing-library/react'; -import React from 'react'; -import type { Query } from '@kbn/es-query'; - -import { useKibana } from '../../common/lib/kibana'; -import { TestProviders } from '../../common/mock/test_providers'; -import type { EditDashboardButtonComponentProps } from './edit_dashboard_button'; -import { EditDashboardButton } from './edit_dashboard_button'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; - -jest.mock('../../common/lib/kibana/kibana_react', () => { - return { - useKibana: jest.fn(), - }; -}); - -describe('EditDashboardButton', () => { - const timeRange = { - from: '2023-03-24T00:00:00.000Z', - to: '2023-03-24T23:59:59.999Z', - }; - - const props = { - filters: [], - query: { query: '', language: '' } as Query, - savedObjectId: 'mockSavedObjectId', - timeRange, - }; - const servicesMock = { - dashboard: { locator: { getRedirectUrl: jest.fn() } }, - application: { - navigateToApp: jest.fn(), - navigateToUrl: jest.fn(), - }, - }; - - const renderButton = (testProps: EditDashboardButtonComponentProps) => { - return render( - - - - ); - }; - - let renderResult: RenderResult; - beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: servicesMock, - }); - renderResult = renderButton(props); - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should render', () => { - expect(renderResult.queryByTestId('dashboardEditButton')).toBeInTheDocument(); - }); - - it('should render dashboard edit url', () => { - fireEvent.click(renderResult.getByTestId('dashboardEditButton')); - expect(servicesMock.dashboard?.locator?.getRedirectUrl).toHaveBeenCalledWith( - expect.objectContaining({ - query: props.query, - filters: props.filters, - timeRange: props.timeRange, - dashboardId: props.savedObjectId, - viewMode: ViewMode.EDIT, - }) - ); - }); -}); diff --git a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.tsx b/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.tsx deleted file mode 100644 index bd360229c7e1f..0000000000000 --- a/x-pack/plugins/security_solution/public/dashboards/components/edit_dashboard_button.tsx +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 type { Query, Filter } from '@kbn/es-query'; -import { EuiButton } from '@elastic/eui'; -import { ViewMode } from '@kbn/embeddable-plugin/public'; -import { EDIT_DASHBOARD_BUTTON_TITLE } from '../pages/details/translations'; -import { useKibana, useNavigation } from '../../common/lib/kibana'; - -export interface EditDashboardButtonComponentProps { - filters?: Filter[]; - query?: Query; - savedObjectId: string | undefined; - timeRange: { - from: string; - to: string; - fromStr?: string | undefined; - toStr?: string | undefined; - }; -} - -const EditDashboardButtonComponent: React.FC = ({ - filters, - query, - savedObjectId, - timeRange, -}) => { - const { - services: { dashboard }, - } = useKibana(); - const { navigateTo } = useNavigation(); - - const onClick = useCallback( - (e) => { - e.preventDefault(); - const url = dashboard?.locator?.getRedirectUrl({ - query, - filters, - timeRange, - dashboardId: savedObjectId, - viewMode: ViewMode.EDIT, - }); - if (url) { - navigateTo({ url }); - } - }, - [dashboard?.locator, query, filters, timeRange, savedObjectId, navigateTo] - ); - return ( - - {EDIT_DASHBOARD_BUTTON_TITLE} - - ); -}; - -EditDashboardButtonComponent.displayName = 'EditDashboardComponent'; -export const EditDashboardButton = React.memo(EditDashboardButtonComponent); diff --git a/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx b/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx index cb8b40b1c0907..02bccd69eb253 100644 --- a/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/context/dashboard_context.tsx @@ -20,7 +20,6 @@ const DashboardContext = React.createContext({ secu export const DashboardContextProvider: React.FC = ({ children }) => { const { tags, isLoading } = useFetchSecurityTags(); - const securityTags = isLoading || !tags ? null : tags; return {children}; diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/translations.ts b/x-pack/plugins/security_solution/public/dashboards/hooks/translations.ts deleted file mode 100644 index 58254aa8fe9f6..0000000000000 --- a/x-pack/plugins/security_solution/public/dashboards/hooks/translations.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 DASHBOARD_TITLE = i18n.translate('xpack.securitySolution.dashboards.title', { - defaultMessage: 'Title', -}); - -export const DASHBOARDS_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.dashboards.description', - { - defaultMessage: 'Description', - } -); diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.ts b/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.tsx similarity index 69% rename from x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.ts rename to x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.tsx index 47509521e5574..52540aff6aa7e 100644 --- a/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.ts +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.test.tsx @@ -6,23 +6,37 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import { useKibana } from '../../common/lib/kibana'; import { useCreateSecurityDashboardLink } from './use_create_security_dashboard_link'; import { DashboardContextProvider } from '../context/dashboard_context'; import { getTagsByName } from '../../common/containers/tags/api'; +import React from 'react'; +import { TestProviders } from '../../common/mock'; -jest.mock('../../common/lib/kibana'); +jest.mock('@kbn/security-solution-navigation/src/context'); +jest.mock('../../common/lib/kibana', () => ({ + useKibana: jest.fn(), +})); jest.mock('../../common/containers/tags/api'); -const URL = '/path'; +jest.mock('../../common/lib/apm/use_track_http_request'); +jest.mock('../../common/components/link_to', () => ({ + useGetSecuritySolutionUrl: jest + .fn() + .mockReturnValue(jest.fn().mockReturnValue('/app/security/dashboards/create')), +})); const renderUseCreateSecurityDashboardLink = () => renderHook(() => useCreateSecurityDashboardLink(), { - wrapper: DashboardContextProvider, + wrapper: ({ children }) => ( + + {children} + + ), }); const asyncRenderUseCreateSecurityDashboard = async () => { const renderedHook = renderUseCreateSecurityDashboardLink(); + await act(async () => { await renderedHook.waitForNextUpdate(); }); @@ -30,12 +44,15 @@ const asyncRenderUseCreateSecurityDashboard = async () => { }; describe('useCreateSecurityDashboardLink', () => { - const mockGetRedirectUrl = jest.fn(() => URL); - beforeAll(() => { - useKibana().services.dashboard = { - locator: { getRedirectUrl: mockGetRedirectUrl }, - } as unknown as DashboardStart; + (useKibana as jest.Mock).mockReturnValue({ + services: { + savedObjectsTagging: { + create: jest.fn(), + }, + http: { get: jest.fn() }, + }, + }); }); afterEach(() => { @@ -55,8 +72,7 @@ describe('useCreateSecurityDashboardLink', () => { const result1 = result.current; act(() => rerender()); const result2 = result.current; - - expect(result1).toBe(result2); + expect(result1).toEqual(result2); }); it('should not re-request tag id when re-rendered', async () => { @@ -71,14 +87,14 @@ describe('useCreateSecurityDashboardLink', () => { const { result, waitForNextUpdate } = renderUseCreateSecurityDashboardLink(); expect(result.current.isLoading).toEqual(true); - expect(result.current.url).toEqual(''); + expect(result.current.url).toEqual('/app/security/dashboards/create'); await act(async () => { await waitForNextUpdate(); }); expect(result.current.isLoading).toEqual(false); - expect(result.current.url).toEqual(URL); + expect(result.current.url).toEqual('/app/security/dashboards/create'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.ts b/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.ts index 633d9d01efe17..24f91eec5211c 100644 --- a/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.ts +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_create_security_dashboard_link.ts @@ -7,24 +7,28 @@ import { useMemo } from 'react'; import { useSecurityTags } from '../context/dashboard_context'; -import { useKibana } from '../../common/lib/kibana'; +import { useGetSecuritySolutionUrl } from '../../common/components/link_to'; +import { SecurityPageName } from '../../../common'; type UseCreateDashboard = () => { isLoading: boolean; url: string }; export const useCreateSecurityDashboardLink: UseCreateDashboard = () => { - const { dashboard } = useKibana().services; + const getSecuritySolutionUrl = useGetSecuritySolutionUrl(); const securityTags = useSecurityTags(); - + const url = getSecuritySolutionUrl({ + deepLinkId: SecurityPageName.dashboards, + path: 'create', + }); const result = useMemo(() => { const firstSecurityTagId = securityTags?.[0]?.id; if (!firstSecurityTagId) { - return { isLoading: true, url: '' }; + return { isLoading: true, url }; } return { isLoading: false, - url: dashboard?.locator?.getRedirectUrl({ tags: [firstSecurityTagId] }) ?? '', + url, }; - }, [securityTags, dashboard?.locator]); + }, [securityTags, url]); return result; }; diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.test.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.test.tsx new file mode 100644 index 0000000000000..d8f5bae2361c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.test.tsx @@ -0,0 +1,26 @@ +/* + * 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, act } from '@testing-library/react-hooks'; +import type { DashboardAPI } from '@kbn/dashboard-plugin/public'; + +import { useDashboardRenderer } from './use_dashboard_renderer'; + +jest.mock('../../common/lib/kibana'); + +const mockDashboardContainer = { getExplicitInput: () => ({ tags: ['tagId'] }) } as DashboardAPI; + +describe('useDashboardRenderer', () => { + it('should set dashboard container correctly when dashboard is loaded', async () => { + const { result } = renderHook(() => useDashboardRenderer()); + + await act(async () => { + await result.current.handleDashboardLoaded(mockDashboardContainer); + }); + + expect(result.current.dashboardContainer).toEqual(mockDashboardContainer); + }); +}); diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.tsx new file mode 100644 index 0000000000000..104692e62f2bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_dashboard_renderer.tsx @@ -0,0 +1,25 @@ +/* + * 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, useState } from 'react'; +import type { DashboardAPI } from '@kbn/dashboard-plugin/public'; + +export const useDashboardRenderer = () => { + const [dashboardContainer, setDashboardContainer] = useState(); + + const handleDashboardLoaded = useCallback((container: DashboardAPI) => { + setDashboardContainer(container); + }, []); + + return useMemo( + () => ({ + dashboardContainer, + handleDashboardLoaded, + }), + [dashboardContainer, handleDashboardLoaded] + ); +}; diff --git a/x-pack/plugins/security_solution/public/dashboards/hooks/use_security_dashboards_table.tsx b/x-pack/plugins/security_solution/public/dashboards/hooks/use_security_dashboards_table.tsx index 7f58f804d488c..07446294ab203 100644 --- a/x-pack/plugins/security_solution/public/dashboards/hooks/use_security_dashboards_table.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/hooks/use_security_dashboards_table.tsx @@ -8,9 +8,9 @@ import React, { useMemo, useCallback } from 'react'; import type { MouseEventHandler } from 'react'; import type { EuiBasicTableColumn } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { LinkAnchor } from '../../common/components/links'; import { useKibana, useNavigateTo } from '../../common/lib/kibana'; -import * as i18n from './translations'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../common/lib/telemetry'; import { SecurityPageName } from '../../../common/constants'; import { useGetSecuritySolutionUrl } from '../../common/components/link_to'; @@ -56,7 +56,9 @@ export const useSecurityDashboardsTableColumns = (): Array< (): Array> => [ { field: 'title', - name: i18n.DASHBOARD_TITLE, + name: i18n.translate('xpack.securitySolution.dashboards.title', { + defaultMessage: 'Title', + }), sortable: true, render: (title: string, { id }) => { const href = `${getSecuritySolutionUrl({ @@ -75,7 +77,9 @@ export const useSecurityDashboardsTableColumns = (): Array< }, { field: 'description', - name: i18n.DASHBOARDS_DESCRIPTION, + name: i18n.translate('xpack.securitySolution.dashboards.description', { + defaultMessage: 'Description', + }), sortable: true, render: (description: string) => description || getEmptyValue(), 'data-test-subj': 'dashboardTableDescriptionCell', diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/breadcrumbs.ts b/x-pack/plugins/security_solution/public/dashboards/pages/breadcrumbs.ts index 01e663e8abb7e..f59369cb4e755 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/breadcrumbs.ts +++ b/x-pack/plugins/security_solution/public/dashboards/pages/breadcrumbs.ts @@ -5,7 +5,9 @@ * 2.0. */ +import { matchPath } from 'react-router-dom'; import type { GetTrailingBreadcrumbs } from '../../common/components/navigation/breadcrumbs/types'; +import { CREATE_DASHBOARD_TITLE } from './translations'; /** * This module should only export this function. @@ -13,6 +15,10 @@ import type { GetTrailingBreadcrumbs } from '../../common/components/navigation/ * We should be careful to not import unnecessary modules in this file to avoid increasing the main app bundle size. */ export const getTrailingBreadcrumbs: GetTrailingBreadcrumbs = (params, getSecuritySolutionUrl) => { + if (matchPath(params.pathName, { path: '/create' })) { + return [{ text: CREATE_DASHBOARD_TITLE }]; + } + const breadcrumbName = params?.state?.dashboardName; if (breadcrumbName) { return [{ text: breadcrumbName }]; diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/details/index.test.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.test.tsx index 9411bda35a632..3c85a18f2d3aa 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.test.tsx @@ -11,6 +11,7 @@ import { Router } from '@kbn/shared-ux-router'; import { DashboardView } from '.'; import { useCapabilities } from '../../../common/lib/kibana'; import { TestProviders } from '../../../common/mock'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; jest.mock('react-router-dom', () => { const actual = jest.requireActual('react-router-dom'); @@ -68,7 +69,7 @@ describe('DashboardView', () => { test('render when no error state', () => { const { queryByTestId } = render( - + , { wrapper: TestProviders } ); @@ -83,7 +84,7 @@ describe('DashboardView', () => { }); const { queryByTestId } = render( - + , { wrapper: TestProviders } ); @@ -95,7 +96,7 @@ describe('DashboardView', () => { test('render dashboard view with height', () => { const { queryByTestId } = render( - + , { wrapper: TestProviders } ); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx index 8bbfec9f99218..6f07b377a22d0 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/details/index.tsx @@ -7,13 +7,12 @@ import React, { useState, useCallback, useMemo } from 'react'; import { LEGACY_DASHBOARD_APP_ID } from '@kbn/dashboard-plugin/public'; -import type { DashboardAPI } from '@kbn/dashboard-plugin/public'; import type { DashboardCapabilities } from '@kbn/dashboard-plugin/common/types'; import { useParams } from 'react-router-dom'; - import { pick } from 'lodash/fp'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import type { ViewMode } from '@kbn/embeddable-plugin/common'; import { SecurityPageName } from '../../../../common/constants'; import { SpyRoute } from '../../../common/utils/route/spy_routes'; import { useCapabilities } from '../../../common/lib/kibana'; @@ -26,16 +25,22 @@ import { FiltersGlobal } from '../../../common/components/filters_global'; import { InputsModelId } from '../../../common/store/inputs/constants'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; import { HeaderPage } from '../../../common/components/header_page'; -import { DASHBOARD_NOT_FOUND_TITLE } from './translations'; import { inputsSelectors } from '../../../common/store'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; -import { EditDashboardButton } from '../../components/edit_dashboard_button'; +import { DashboardToolBar } from '../../components/dashboard_tool_bar'; + +import { useDashboardRenderer } from '../../hooks/use_dashboard_renderer'; +import { DashboardTitle } from '../../components/dashboard_title'; -type DashboardDetails = Record; +interface DashboardViewProps { + initialViewMode: ViewMode; +} const dashboardViewFlexGroupStyle = { minHeight: `calc(100vh - 140px)` }; -const DashboardViewComponent: React.FC = () => { +const DashboardViewComponent: React.FC = ({ + initialViewMode, +}: DashboardViewProps) => { const { fromStr, toStr, from, to } = useDeepEqualSelector((state) => pick(['fromStr', 'toStr', 'from', 'to'], inputsSelectors.globalTimeRangeSelector(state)) ); @@ -47,36 +52,28 @@ const DashboardViewComponent: React.FC = () => { ); const query = useDeepEqualSelector(getGlobalQuerySelector); const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); - const { indexPattern, indicesExist } = useSourcererDataView(); + const { indexPattern } = useSourcererDataView(); - const { show: canReadDashboard, showWriteControls } = + const { show: canReadDashboard } = useCapabilities(LEGACY_DASHBOARD_APP_ID); const errorState = useMemo( () => (canReadDashboard ? null : DashboardViewPromptState.NoReadPermission), [canReadDashboard] ); - const [dashboardDetails, setDashboardDetails] = useState(); - const onDashboardContainerLoaded = useCallback((dashboard: DashboardAPI) => { - if (dashboard) { - const title = dashboard.getTitle().trim(); - if (title) { - setDashboardDetails({ title }); - } else { - setDashboardDetails({ title: DASHBOARD_NOT_FOUND_TITLE }); - } - } - }, []); - - const dashboardExists = useMemo(() => dashboardDetails != null, [dashboardDetails]); + const [viewMode, setViewMode] = useState(initialViewMode); const { detailName: savedObjectId } = useParams<{ detailName?: string }>(); + const [dashboardTitle, setDashboardTitle] = useState(); + + const { dashboardContainer, handleDashboardLoaded } = useDashboardRenderer(); + const onDashboardToolBarLoad = useCallback((mode: ViewMode) => { + setViewMode(mode); + }, []); return ( <> - {indicesExist && ( - - - - )} + + + { data-test-subj="dashboard-view-wrapper" > - }> - {showWriteControls && dashboardExists && ( - - )} - + {dashboardContainer && ( + + } + subtitle={ + + } + /> + )} {!errorState && ( @@ -102,10 +106,12 @@ const DashboardViewComponent: React.FC = () => { query={query} filters={filters} canReadDashboard={canReadDashboard} + dashboardContainer={dashboardContainer} id={`dashboard-view-${savedObjectId}`} - onDashboardContainerLoaded={onDashboardContainerLoaded} + onDashboardContainerLoaded={handleDashboardLoaded} savedObjectId={savedObjectId} timeRange={timeRange} + viewMode={viewMode} /> )} @@ -116,7 +122,7 @@ const DashboardViewComponent: React.FC = () => { )} diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts b/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts index ddfa94bd75584..a760d79a8e30d 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts +++ b/x-pack/plugins/security_solution/public/dashboards/pages/details/translations.ts @@ -40,3 +40,24 @@ export const EDIT_DASHBOARD_BUTTON_TITLE = i18n.translate( defaultMessage: `Edit`, } ); + +export const EDIT_DASHBOARD_TITLE = i18n.translate( + 'xpack.securitySolution.dashboards.dashboard.editDashboardTitle', + { + defaultMessage: `Editing new dashboard`, + } +); + +export const VIEW_DASHBOARD_BUTTON_TITLE = i18n.translate( + 'xpack.securitySolution.dashboards.dashboard.viewDashboardButtonTitle', + { + defaultMessage: `Switch to view mode`, + } +); + +export const SAVE_DASHBOARD_BUTTON_TITLE = i18n.translate( + 'xpack.securitySolution.dashboards.dashboard.saveDashboardButtonTitle', + { + defaultMessage: `Save`, + } +); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx index 0bd521f47a69c..993a4b37a1ec7 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/index.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { Routes, Route } from '@kbn/shared-ux-router'; +import { ViewMode } from '@kbn/embeddable-plugin/public'; import { DashboardsLandingPage } from './landing_page'; import { DashboardView } from './details'; import { DASHBOARDS_PATH } from '../../../common/constants'; @@ -16,8 +17,14 @@ const DashboardsContainerComponent = () => { return ( + + + + + + - + diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx index ac26f9038d5d4..8723bfb69f326 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.test.tsx @@ -23,13 +23,10 @@ import { DASHBOARDS_PAGE_SECTION_CUSTOM } from './translations'; jest.mock('../../../common/containers/tags/api'); jest.mock('../../../common/lib/kibana'); jest.mock('../../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null })); -jest.mock('@kbn/dashboard-plugin/public', () => { - const actual = jest.requireActual('@kbn/dashboard-plugin/public'); - return { - ...actual, - DashboardListingTable: jest.fn().mockReturnValue(), - }; -}); +jest.mock('@kbn/dashboard-plugin/public', () => ({ + DashboardListingTable: jest.fn().mockReturnValue(), + DashboardTopNav: jest.fn().mockReturnValue(), +})); const mockUseObservable = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx index 2af7ef2f9902b..10fb3c060548f 100644 --- a/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx +++ b/x-pack/plugins/security_solution/public/dashboards/pages/landing_page/index.tsx @@ -99,20 +99,18 @@ export const DashboardsLandingPage = () => { })}`, [getSecuritySolutionUrl] ); - const { isLoading: loadingCreateDashboardUrl, url: createDashboardUrl } = - useCreateSecurityDashboardLink(); - - const getHref = useCallback( - (id: string | undefined) => (id ? getSecuritySolutionDashboardUrl(id) : createDashboardUrl), - [createDashboardUrl, getSecuritySolutionDashboardUrl] - ); const goToDashboard = useCallback( (dashboardId: string | undefined) => { track(METRIC_TYPE.CLICK, TELEMETRY_EVENT.DASHBOARD); - navigateTo({ url: getHref(dashboardId) }); + navigateTo({ + url: getSecuritySolutionUrl({ + deepLinkId: SecurityPageName.dashboards, + path: dashboardId ?? 'create', + }), + }); }, - [getHref, navigateTo] + [getSecuritySolutionUrl, navigateTo] ); const securityTags = useSecurityTags(); @@ -151,7 +149,7 @@ export const DashboardsLandingPage = () => { void; + rule: Rule | null; + 'data-test-subj': string; +} + +export const EndpointExceptionsViewer = memo( + ({ + isViewReadOnly, + onRuleChange, + rule, + 'data-test-subj': dataTestSubj, + }: EndpointExceptionsViewerProps) => { + const EndpointExceptionsUnavailableComponent = useGetEndpointExceptionsUnavailableComponent(); + return ( + <> + {!EndpointExceptionsUnavailableComponent ? ( + + ) : ( + + + + + + )} + + ); + } +); + +EndpointExceptionsViewer.displayName = 'EndpointExceptionsViewer'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/endpoint_exceptions/use_get_endpoint_exceptions_unavailablle_component.tsx b/x-pack/plugins/security_solution/public/detection_engine/endpoint_exceptions/use_get_endpoint_exceptions_unavailablle_component.tsx new file mode 100644 index 0000000000000..a3b0afb2caba5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/endpoint_exceptions/use_get_endpoint_exceptions_unavailablle_component.tsx @@ -0,0 +1,13 @@ +/* + * 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 React from 'react'; +import { useUpsellingComponent } from '../../common/hooks/use_upselling'; + +export const useGetEndpointExceptionsUnavailableComponent = (): React.ComponentType | null => { + return useUpsellingComponent('ruleDetailsEndpointExceptions'); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_search_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_search_bar.tsx index 0fd306e413912..3622182aa7fc3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_search_bar.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/execution_log_table/execution_log_search_bar.tsx @@ -9,7 +9,8 @@ import React, { useCallback } from 'react'; import { replace } from 'lodash'; import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { RuleExecutionStatus } from '../../../../../../common/api/detection_engine/rule_monitoring'; +import type { RuleExecutionStatus } from '../../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../../common/api/detection_engine/rule_monitoring'; import { ExecutionStatusFilter } from '../../../../rule_monitoring'; import * as i18n from './translations'; @@ -36,10 +37,10 @@ export const replaceQueryTextAliases = (queryText: string): string => { }; // This only includes statuses which are or can be final -const STATUS_FILTERS = [ - RuleExecutionStatus.succeeded, - RuleExecutionStatus.failed, - RuleExecutionStatus['partial failure'], +const STATUS_FILTERS: RuleExecutionStatus[] = [ + RuleExecutionStatusEnum.succeeded, + RuleExecutionStatusEnum.failed, + RuleExecutionStatusEnum['partial failure'], ]; interface ExecutionLogTableSearchProps { diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 97f02b8e35cf2..223593ef3e095 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -19,7 +19,7 @@ import { EuiWindowEvent, } from '@elastic/eui'; import type { Filter } from '@kbn/es-query'; -import { Routes, Route } from '@kbn/shared-ux-router'; +import { Route, Routes } from '@kbn/shared-ux-router'; import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -32,12 +32,13 @@ import type { Dispatch } from 'redux'; import { isTab } from '@kbn/timelines-plugin/public'; import { - tableDefaults, dataTableActions, dataTableSelectors, FILTER_OPEN, + tableDefaults, TableId, } from '@kbn/securitysolution-data-table'; +import { EndpointExceptionsViewer } from '../../../endpoint_exceptions/endpoint_exceptions_viewer'; import { AlertsTableComponent } from '../../../../detections/components/alerts_table'; import { GroupedAlertsTable } from '../../../../detections/components/alerts_table/alerts_grouping'; import { useDataTableFilters } from '../../../../common/hooks/use_data_table_filters'; @@ -100,10 +101,10 @@ import { import { useSourcererDataView } from '../../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../../common/store/sourcerer/model'; import { - explainLackOfPermission, canEditRuleWithActions, - isBoolean, + explainLackOfPermission, hasUserCRUDPermission, + isBoolean, } from '../../../../common/utils/privileges'; import { @@ -149,8 +150,6 @@ const RULE_EXCEPTION_LIST_TYPES = [ ExceptionListTypeEnum.RULE_DEFAULT, ]; -const RULE_ENDPOINT_EXCEPTION_LIST_TYPE = [ExceptionListTypeEnum.ENDPOINT]; - /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. */ @@ -780,9 +779,8 @@ const RuleDetailsPageComponent: React.FC = ({ - { beforeAll(() => { - (useRuleExecutionSettings as jest.Mock).mockReturnValue({ + mockUseRuleExecutionSettings.mockReturnValue({ extendedLogging: { isEnabled: false, minLevel: 'debug', }, }); + mockUseEndpointExceptionsCapability.mockReturnValue(true); }); beforeEach(() => { @@ -119,6 +125,32 @@ describe('useRuleDetailsTabs', () => { expect(tabsNames).toContain(RuleDetailTabs.endpointExceptions); }); + it('hides endpoint exceptions tab when rule includes endpoint list but no endpoint PLI', async () => { + mockUseEndpointExceptionsCapability.mockReturnValue(false); + const tabs = render({ + rule: { + ...mockRule, + outcome: 'conflict', + alias_target_id: 'aliased_rule_id', + alias_purpose: 'savedObjectConversion', + exceptions_list: [ + { + id: 'endpoint_list', + list_id: 'endpoint_list', + type: 'endpoint', + namespace_type: 'agnostic', + }, + ], + }, + ruleId: mockRule.rule_id, + isExistingRule: true, + hasIndexRead: true, + }); + const tabsNames = Object.keys(tabs.result.current); + + expect(tabsNames).not.toContain(RuleDetailTabs.endpointExceptions); + }); + it('does not return the execution events tab if extended logging is disabled', async () => { const tabs = render({ rule: mockRule, @@ -132,7 +164,7 @@ describe('useRuleDetailsTabs', () => { }); it('returns the execution events tab if extended logging is enabled', async () => { - (useRuleExecutionSettings as jest.Mock).mockReturnValue({ + mockUseRuleExecutionSettings.mockReturnValue({ extendedLogging: { isEnabled: true, minLevel: 'debug', diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs.tsx index 42a42caeb732d..8c73bafd049aa 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/use_rule_details_tabs.tsx @@ -8,6 +8,7 @@ import { useEffect, useMemo, useState } from 'react'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { omit } from 'lodash/fp'; +import { useEndpointExceptionsCapability } from '../../../../exceptions/hooks/use_endpoint_exceptions_capability'; import * as detectionI18n from '../../../../detections/pages/detection_engine/translations'; import * as i18n from './translations'; import type { Rule } from '../../../rule_management/logic'; @@ -80,9 +81,10 @@ export const useRuleDetailsTabs = ({ ); const [pageTabs, setTabs] = useState>>(ruleDetailTabs); - const ruleExecutionSettings = useRuleExecutionSettings(); + const canReadEndpointExceptions = useEndpointExceptionsCapability('showEndpointExceptions'); + useEffect(() => { const hiddenTabs = []; @@ -92,6 +94,9 @@ export const useRuleDetailsTabs = ({ if (!ruleExecutionSettings.extendedLogging.isEnabled) { hiddenTabs.push(RuleDetailTabs.executionEvents); } + if (!canReadEndpointExceptions) { + hiddenTabs.push(RuleDetailTabs.endpointExceptions); + } if (rule != null) { const hasEndpointList = (rule.exceptions_list ?? []).some( (list) => list.type === ExceptionListTypeEnum.ENDPOINT @@ -104,7 +109,7 @@ export const useRuleDetailsTabs = ({ const tabs = omit>(hiddenTabs, ruleDetailTabs); setTabs(tabs); - }, [hasIndexRead, rule, ruleDetailTabs, ruleExecutionSettings]); + }, [canReadEndpointExceptions, hasIndexRead, rule, ruleDetailTabs, ruleExecutionSettings]); return pageTabs; }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx index 1d1dd112da377..05e6e0513f741 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.test.tsx @@ -19,7 +19,9 @@ import type { Rule } from '../../../rule_management/logic/types'; import { mockRule } from '../../../rule_management_ui/components/rules_table/__mocks__/mock'; import { useFindExceptionListReferences } from '../../logic/use_find_references'; import * as i18n from './translations'; +import { useEndpointExceptionsCapability } from '../../../../exceptions/hooks/use_endpoint_exceptions_capability'; +jest.mock('../../../../exceptions/hooks/use_endpoint_exceptions_capability'); jest.mock('../../../../common/lib/kibana'); jest.mock('@kbn/securitysolution-list-hooks'); jest.mock('@kbn/securitysolution-list-api'); @@ -29,6 +31,8 @@ jest.mock('react', () => { return { ...r, useReducer: jest.fn() }; }); +const mockUseEndpointExceptionsCapability = useEndpointExceptionsCapability as jest.Mock; + const sampleExceptionItem = { _version: 'WzEwMjM4MSwxXQ==', comments: [], @@ -81,6 +85,8 @@ describe('ExceptionsViewer', () => { }, }); + mockUseEndpointExceptionsCapability.mockReturnValue(true); + (fetchExceptionListsItemsByListIds as jest.Mock).mockReturnValue({ total: 0 }); (useFindExceptionListReferences as jest.Mock).mockReturnValue([ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx index df6a6c2835a1d..cc86bbf02291b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/all_exception_items_table/index.tsx @@ -5,18 +5,18 @@ * 2.0. */ -import React, { useCallback, useMemo, useEffect, useReducer } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import styled from 'styled-components'; import { EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionListItemSchema, - UseExceptionListItemsSuccess, - Pagination, ExceptionListSchema, + Pagination, + UseExceptionListItemsSuccess, } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { transformInput } from '@kbn/securitysolution-list-hooks'; import { @@ -29,6 +29,7 @@ import { buildShowExpiredExceptionsFilter, getSavedObjectTypes, } from '@kbn/securitysolution-list-utils'; +import { useEndpointExceptionsCapability } from '../../../../exceptions/hooks/use_endpoint_exceptions_capability'; import { useUserData } from '../../../../detections/components/user_info'; import { useKibana, useToasts } from '../../../../common/lib/kibana'; import { ExceptionsViewerSearchBar } from './search_bar'; @@ -120,6 +121,8 @@ const ExceptionsViewerComponent = ({ [listTypes] ); + const canWriteEndpointExceptions = useEndpointExceptionsCapability('crudEndpointExceptions'); + // Reducer state const [ { @@ -531,7 +534,7 @@ const ExceptionsViewerComponent = ({ ); - const popoverTitle = getCapitalizedStatusText(RuleExecutionStatus['partial failure']); + const popoverTitle = getCapitalizedStatusText(RuleExecutionStatusEnum['partial failure']); return ( ); - const popoverTitle = getCapitalizedStatusText(RuleExecutionStatus['partial failure']); + const popoverTitle = getCapitalizedStatusText(RuleExecutionStatusEnum['partial failure']); return ( theme.eui.euiSizeXS}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx index 012b0dc65b82f..b2039befafd8a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_exception_actions.tsx @@ -8,8 +8,7 @@ import { useCallback, useMemo } from 'react'; import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { useListsConfig } from '../../../containers/detection_engine/lists/use_lists_config'; -import { useHasSecurityCapability } from '../../../../helper_hooks'; +import { useEndpointExceptionsCapability } from '../../../../exceptions/hooks/use_endpoint_exceptions_capability'; import { useUserData } from '../../user_info'; import { ACTION_ADD_ENDPOINT_EXCEPTION, ACTION_ADD_EXCEPTION } from '../translations'; import type { AlertTableContextMenuItem } from '../types'; @@ -76,14 +75,7 @@ export const useAlertExceptionActions = ({ onAddExceptionTypeClick, }); - const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = - useListsConfig(); - const canReadEndpointExceptions = useHasSecurityCapability('crudEndpointExceptions'); - - const canWriteEndpointExceptions = useMemo( - () => !listsConfigLoading && !needsListsConfiguration && canReadEndpointExceptions, - [canReadEndpointExceptions, listsConfigLoading, needsListsConfiguration] - ); + const canWriteEndpointExceptions = useEndpointExceptionsCapability('crudEndpointExceptions'); // Endpoint exceptions are available for: // Serverless Endpoint Essentials/Complete PLI and // on ESS Security Kibana sub-feature Endpoint Exceptions (enabled when Security feature is enabled) diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_badge.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_badge.test.tsx index 7d202129a0013..5d31247e1f6ff 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_badge.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_badge.test.tsx @@ -8,12 +8,12 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../common/api/detection_engine/rule_monitoring'; import { RuleStatusBadge } from './rule_status_badge'; describe('RuleStatusBadge', () => { it('renders capitalized status text', () => { - render(); + render(); expect(screen.getByText('Succeeded')).toBeInTheDocument(); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_badge.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_badge.tsx index 89d1a63305faa..d52190839c37f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_badge.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_badge.tsx @@ -11,7 +11,8 @@ import { getEmptyTagValue } from '../../../../common/components/empty_value'; import { HealthTruncateText } from '../../../../common/components/health_truncate_text'; import { getCapitalizedStatusText, getStatusColor } from './utils'; -import { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import type { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../common/api/detection_engine/rule_monitoring'; interface RuleStatusBadgeProps { status: RuleExecutionStatus | null | undefined; @@ -29,7 +30,8 @@ const RuleStatusBadgeComponent = ({ showTooltip = true, }: RuleStatusBadgeProps) => { const isFailedStatus = - status === RuleExecutionStatus.failed || status === RuleExecutionStatus['partial failure']; + status === RuleExecutionStatusEnum.failed || + status === RuleExecutionStatusEnum['partial failure']; const statusText = getCapitalizedStatusText(status); const statusTooltip = isFailedStatus && message ? message : statusText; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx index 68927bf64faa3..4902ac0115fee 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.test.tsx @@ -8,7 +8,8 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import type { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../common/api/detection_engine/rule_monitoring'; import { RuleStatusFailedCallOut } from './rule_status_failed_callout'; jest.mock('../../../../common/lib/kibana'); @@ -32,22 +33,22 @@ describe('RuleStatusFailedCallOut', () => { }); it('is hidden if status is "going to run"', () => { - const result = renderWith(RuleExecutionStatus['going to run']); + const result = renderWith(RuleExecutionStatusEnum['going to run']); expect(result.queryByTestId(TEST_ID)).toBe(null); }); it('is hidden if status is "running"', () => { - const result = renderWith(RuleExecutionStatus.running); + const result = renderWith(RuleExecutionStatusEnum.running); expect(result.queryByTestId(TEST_ID)).toBe(null); }); it('is hidden if status is "succeeded"', () => { - const result = renderWith(RuleExecutionStatus.succeeded); + const result = renderWith(RuleExecutionStatusEnum.succeeded); expect(result.queryByTestId(TEST_ID)).toBe(null); }); it('is visible if status is "partial failure"', () => { - const result = renderWith(RuleExecutionStatus['partial failure']); + const result = renderWith(RuleExecutionStatusEnum['partial failure']); result.getByTestId(TEST_ID); result.getByText('Warning at'); result.getByText('Jan 27, 2022 @ 15:03:31.176'); @@ -55,7 +56,7 @@ describe('RuleStatusFailedCallOut', () => { }); it('is visible if status is "failed"', () => { - const result = renderWith(RuleExecutionStatus.failed); + const result = renderWith(RuleExecutionStatusEnum.failed); result.getByTestId(TEST_ID); result.getByText('Rule failure at'); result.getByText('Jan 27, 2022 @ 15:03:31.176'); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.tsx index 83cd1e650c150..b9e26dc162207 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/rule_status_failed_callout.tsx @@ -10,7 +10,8 @@ import React from 'react'; import { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; import { FormattedDate } from '../../../../common/components/formatted_date'; -import { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import type { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../common/api/detection_engine/rule_monitoring'; import * as i18n from './translations'; @@ -75,13 +76,13 @@ interface HelperProps { const getPropsByStatus = (status: RuleExecutionStatus | null | undefined): HelperProps => { switch (status) { - case RuleExecutionStatus.failed: + case RuleExecutionStatusEnum.failed: return { shouldBeDisplayed: true, color: 'danger', title: i18n.ERROR_CALLOUT_TITLE, }; - case RuleExecutionStatus['partial failure']: + case RuleExecutionStatusEnum['partial failure']: return { shouldBeDisplayed: true, color: 'warning', diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/utils.ts b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/utils.ts index 899323c9faa2f..c13af24b63c25 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_execution_status/utils.ts @@ -8,13 +8,14 @@ import type { IconColor } from '@elastic/eui'; import { capitalize } from 'lodash'; import { assertUnreachable } from '../../../../../common/utility_types'; -import { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import type { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../common/api/detection_engine/rule_monitoring'; export const getStatusText = (value: RuleExecutionStatus | null | undefined): string | null => { if (value == null) { return null; } - if (value === RuleExecutionStatus['partial failure']) { + if (value === RuleExecutionStatusEnum['partial failure']) { return 'warning'; } return value; @@ -31,16 +32,16 @@ export const getStatusColor = (status: RuleExecutionStatus | null | undefined): if (status == null) { return 'subdued'; } - if (status === RuleExecutionStatus.succeeded) { + if (status === RuleExecutionStatusEnum.succeeded) { return 'success'; } - if (status === RuleExecutionStatus.failed) { + if (status === RuleExecutionStatusEnum.failed) { return 'danger'; } if ( - status === RuleExecutionStatus.running || - status === RuleExecutionStatus['partial failure'] || - status === RuleExecutionStatus['going to run'] + status === RuleExecutionStatusEnum.running || + status === RuleExecutionStatusEnum['partial failure'] || + status === RuleExecutionStatusEnum['going to run'] ) { return 'warning'; } diff --git a/x-pack/plugins/security_solution/public/exceptions/components/list_exception_items/index.tsx b/x-pack/plugins/security_solution/public/exceptions/components/list_exception_items/index.tsx index 1b1b6ad36d1ea..990fd818505b7 100644 --- a/x-pack/plugins/security_solution/public/exceptions/components/list_exception_items/index.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/components/list_exception_items/index.tsx @@ -4,8 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; import type { FC } from 'react'; +import React, { useMemo } from 'react'; import type { ExceptionListItemIdentifiers, GetExceptionItemProps, @@ -13,8 +13,8 @@ import type { ViewerStatus, } from '@kbn/securitysolution-exception-list-components'; import { ExceptionItems } from '@kbn/securitysolution-exception-list-components'; -import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import type { Pagination } from '@elastic/eui'; import { FormattedDate } from '../../../common/components/formatted_date'; @@ -22,6 +22,7 @@ import { getFormattedComments } from '../../utils/ui.helpers'; import { LinkToRuleDetails } from '../link_to_rule_details'; import { ExceptionsUtility } from '../exceptions_utility'; import * as i18n from '../../translations/list_exception_items'; +import { useEndpointExceptionsCapability } from '../../hooks/use_endpoint_exceptions_capability'; interface ListExceptionItemsProps { isReadOnly: boolean; @@ -58,6 +59,8 @@ const ListExceptionItemsComponent: FC = ({ onPaginationChange, onCreateExceptionListItem, }) => { + const canWriteEndpointExceptions = useEndpointExceptionsCapability('crudEndpointExceptions'); + const editButtonText = useMemo(() => { return listType === ExceptionListTypeEnum.ENDPOINT ? i18n.EXCEPTION_ITEM_CARD_EDIT_ENDPOINT_LABEL @@ -76,7 +79,7 @@ const ListExceptionItemsComponent: FC = ({ viewerStatus={viewerStatus as ViewerStatus} listType={listType as ExceptionListTypeEnum} ruleReferences={ruleReferences} - isReadOnly={isReadOnly} + isReadOnly={isReadOnly || !canWriteEndpointExceptions} exceptions={exceptions} emptyViewerTitle={emptyViewerTitle} emptyViewerBody={emptyViewerBody} diff --git a/x-pack/plugins/security_solution/public/exceptions/hooks/use_endpoint_exceptions_capability/index.tsx b/x-pack/plugins/security_solution/public/exceptions/hooks/use_endpoint_exceptions_capability/index.tsx new file mode 100644 index 0000000000000..a96b11225e659 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/hooks/use_endpoint_exceptions_capability/index.tsx @@ -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 { useMemo } from 'react'; +import { useListsConfig } from '../../../detections/containers/detection_engine/lists/use_lists_config'; +import { useHasSecurityCapability } from '../../../helper_hooks'; + +export const useEndpointExceptionsCapability = ( + capability: 'showEndpointExceptions' | 'crudEndpointExceptions' +) => { + const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = + useListsConfig(); + const hasEndpointExceptionCapability = useHasSecurityCapability(capability); + + return useMemo( + () => !listsConfigLoading && !needsListsConfiguration && hasEndpointExceptionCapability, + [hasEndpointExceptionCapability, listsConfigLoading, needsListsConfiguration] + ); +}; diff --git a/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx index 0e457d520984c..3c0fab0343a36 100644 --- a/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx @@ -28,7 +28,6 @@ import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; import { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks'; import { EmptyViewerState, ViewerStatus } from '@kbn/securitysolution-exception-list-components'; -import { useHasSecurityCapability } from '../../../helper_hooks'; import { AutoDownload } from '../../../common/components/auto_download/auto_download'; import { useKibana } from '../../../common/lib/kibana'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; @@ -52,6 +51,7 @@ import { MissingPrivilegesCallOut } from '../../../detections/components/callout import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../../common/endpoint/service/artifacts/constants'; import { AddExceptionFlyout } from '../../../detection_engine/rule_exceptions/components/add_exception_flyout'; +import { useEndpointExceptionsCapability } from '../../hooks/use_endpoint_exceptions_capability'; export type Func = () => Promise; @@ -82,15 +82,10 @@ const SORT_FIELDS: Array<{ field: string; label: string; defaultOrder: 'asc' | ' export const SharedLists = React.memo(() => { const [{ loading: userInfoLoading, canUserCRUD, canUserREAD }] = useUserData(); - const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = - useListsConfig(); + const { loading: listsConfigLoading } = useListsConfig(); const loading = userInfoLoading || listsConfigLoading; - const canShowEndpointExceptions = useHasSecurityCapability('showEndpointExceptions'); - const canAccessEndpointExceptions = useMemo( - () => !listsConfigLoading && !needsListsConfiguration && canShowEndpointExceptions, - [canShowEndpointExceptions, listsConfigLoading, needsListsConfiguration] - ); + const canAccessEndpointExceptions = useEndpointExceptionsCapability('showEndpointExceptions'); const { services: { http, diff --git a/x-pack/plugins/security_solution/public/exceptions/routes.tsx b/x-pack/plugins/security_solution/public/exceptions/routes.tsx index 747a96cb72eea..e6faf794dcdbf 100644 --- a/x-pack/plugins/security_solution/public/exceptions/routes.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/routes.tsx @@ -5,21 +5,22 @@ * 2.0. */ import React from 'react'; -import { Routes, Route } from '@kbn/shared-ux-router'; +import { Route, Routes } from '@kbn/shared-ux-router'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import * as i18n from './translations'; import { + EXCEPTION_LIST_DETAIL_PATH, EXCEPTIONS_PATH, SecurityPageName, - EXCEPTION_LIST_DETAIL_PATH, } from '../../common/constants'; -import { SharedLists, ListsDetailView } from './pages'; +import { ListsDetailView, SharedLists } from './pages'; import { SpyRoute } from '../common/utils/route/spy_routes'; import { NotFoundPage } from '../app/404'; import { useReadonlyHeader } from '../use_readonly_header'; import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper'; +import { SecurityRoutePageWrapper } from '../common/components/security_route_page_wrapper'; const ExceptionsRoutes = () => ( @@ -32,9 +33,9 @@ const ExceptionsRoutes = () => ( const ExceptionsListDetailRoute = () => ( - + - + ); diff --git a/x-pack/plugins/security_solution/public/helpers.tsx b/x-pack/plugins/security_solution/public/helpers.tsx index 0dfc0878631cc..b245a6ae2f6bc 100644 --- a/x-pack/plugins/security_solution/public/helpers.tsx +++ b/x-pack/plugins/security_solution/public/helpers.tsx @@ -21,6 +21,7 @@ import { APP_UI_ID, CASES_FEATURE_ID, CASES_PATH, + DASHBOARDS_PATH, EXCEPTIONS_PATH, RULES_PATH, SERVER_APP_ID, @@ -173,6 +174,13 @@ export const isDetectionsPath = (pathname: string): boolean => { }); }; +export const isDashboardViewPath = (pathname: string): boolean => + matchPath(pathname, { + path: `/${DASHBOARDS_PATH}/:id`, + exact: false, + strict: false, + }) != null; + const isAlertsPath = (pathname: string): boolean => { return !!matchPath(pathname, { path: `${ALERTS_PATH}`, diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 904ec870e9a2c..65453e37d686b 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -187,6 +187,7 @@ export class Plugin implements IPlugin void; /** diff --git a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts index 917d366354270..3fb28c1c5099d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts +++ b/x-pack/plugins/security_solution/server/endpoint/endpoint_app_context_services.ts @@ -6,27 +6,27 @@ */ import type { + ElasticsearchClient, KibanaRequest, Logger, - ElasticsearchClient, SavedObjectsClientContract, } from '@kbn/core/server'; import type { ExceptionListClient, ListsServerExtensionRegistrar } from '@kbn/lists-plugin/server'; import type { CasesClient, CasesStart } from '@kbn/cases-plugin/server'; import type { SecurityPluginStart } from '@kbn/security-plugin/server'; import type { + FleetFromHostFileClientInterface, FleetStartContract, MessageSigningServiceInterface, - FleetFromHostFileClientInterface, } from '@kbn/fleet-plugin/server'; import type { PluginStartContract as AlertsPluginStartContract } from '@kbn/alerting-plugin/server'; import type { CloudSetup } from '@kbn/cloud-plugin/server'; import type { FleetActionsClientInterface } from '@kbn/fleet-plugin/server/services/actions/types'; import { getPackagePolicyCreateCallback, - getPackagePolicyUpdateCallback, getPackagePolicyDeleteCallback, getPackagePolicyPostCreateCallback, + getPackagePolicyUpdateCallback, } from '../fleet_integration/fleet_integration'; import type { ManifestManager } from './services/artifacts'; import type { ConfigType } from '../config'; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts index f736fffffcf17..ffebaac4420fc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/feature_usage/service.ts @@ -24,6 +24,7 @@ const FEATURES = { GET_FILE: 'Get file', EXECUTE: 'Execute command', ALERTS_BY_PROCESS_ANCESTRY: 'Get related alerts by process ancestry', + ENDPOINT_EXCEPTIONS: 'Endpoint exceptions', } as const; export type FeatureKeys = keyof typeof FEATURES; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts index 92f610391753e..546256889ecfb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/install_prebuilt_rules_and_timelines/install_prebuilt_rules_and_timelines_route.ts @@ -8,7 +8,6 @@ import type { RulesClient } from '@kbn/alerting-plugin/server'; import type { ExceptionListClient } from '@kbn/lists-plugin/server'; import { transformError } from '@kbn/securitysolution-es-utils'; -import { validate } from '@kbn/securitysolution-io-ts-utils'; import moment from 'moment'; import { InstallPrebuiltRulesAndTimelinesResponse, @@ -116,9 +115,7 @@ export const createPrepackagedRules = async ( throw new AggregateError(result.errors, 'Error installing new prebuilt rules'); } - const { result: timelinesResult, error: timelinesError } = await performTimelinesInstallation( - context - ); + const { result: timelinesResult } = await performTimelinesInstallation(context); await upgradePrebuiltRules(rulesClient, rulesToUpdate); @@ -129,17 +126,5 @@ export const createPrepackagedRules = async ( timelines_updated: timelinesResult?.timelines_updated ?? 0, }; - const [validated, genericErrors] = validate( - prebuiltRulesOutput, - InstallPrebuiltRulesAndTimelinesResponse - ); - - if (genericErrors != null && timelinesError != null) { - throw new PrepackagedRulesError( - [genericErrors, timelinesError].filter((msg) => msg != null).join(', '), - 500 - ); - } - - return validated; + return InstallPrebuiltRulesAndTimelinesResponse.parse(prebuiltRulesOutput); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts index 674b8433d5c00..97aaff3373e01 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/export_rules/route.ts @@ -14,7 +14,7 @@ import { ExportRulesRequestQuery, } from '../../../../../../../common/api/detection_engine/rule_management'; -import { buildRouteValidation } from '../../../../../../utils/build_validation/route_validation'; +import { buildRouteValidationWithZod } from '../../../../../../utils/build_validation/route_validation'; import type { SecuritySolutionPluginRouter } from '../../../../../../types'; import type { ConfigType } from '../../../../../../config'; import { getNonPackagedRulesCount } from '../../../logic/search/get_existing_prepackaged_rules'; @@ -40,8 +40,8 @@ export const exportRulesRoute = ( version: '2023-10-31', validate: { request: { - query: buildRouteValidation(ExportRulesRequestQuery), - body: buildRouteValidation(ExportRulesRequestBody), + query: buildRouteValidationWithZod(ExportRulesRequestQuery), + body: buildRouteValidationWithZod(ExportRulesRequestBody), }, }, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts index 3ec268676801d..eb622884bc7a9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts @@ -5,40 +5,34 @@ * 2.0. */ -import { chunk } from 'lodash/fp'; -import { extname } from 'path'; import { schema } from '@kbn/config-schema'; -import { createPromiseFromStreams } from '@kbn/utils'; - -import { transformError } from '@kbn/securitysolution-es-utils'; -import { validate } from '@kbn/securitysolution-io-ts-utils'; - import type { IKibanaResponse } from '@kbn/core/server'; -import { DETECTION_ENGINE_RULES_URL } from '../../../../../../../common/constants'; -import type { ImportRulesRequestQueryDecoded } from '../../../../../../../common/api/detection_engine/rule_management'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { createPromiseFromStreams } from '@kbn/utils'; +import { chunk } from 'lodash/fp'; +import { extname } from 'path'; import { ImportRulesRequestQuery, ImportRulesResponse, } from '../../../../../../../common/api/detection_engine/rule_management'; - -import type { HapiReadableStream, SecuritySolutionPluginRouter } from '../../../../../../types'; +import { DETECTION_ENGINE_RULES_URL } from '../../../../../../../common/constants'; import type { ConfigType } from '../../../../../../config'; import type { SetupPlugins } from '../../../../../../plugin'; +import type { HapiReadableStream, SecuritySolutionPluginRouter } from '../../../../../../types'; +import { buildRouteValidationWithZod } from '../../../../../../utils/build_validation/route_validation'; import { buildMlAuthz } from '../../../../../machine_learning/authz'; -import type { ImportRuleResponse, BulkError } from '../../../../routes/utils'; -import { isBulkError, isImportRegular, buildSiemResponse } from '../../../../routes/utils'; - -import { - getTupleDuplicateErrorsAndUniqueRules, - migrateLegacyActionsIds, -} from '../../../utils/utils'; +import type { BulkError, ImportRuleResponse } from '../../../../routes/utils'; +import { buildSiemResponse, isBulkError, isImportRegular } from '../../../../routes/utils'; +import { importRuleActionConnectors } from '../../../logic/import/action_connectors/import_rule_action_connectors'; import { createRulesAndExceptionsStreamFromNdJson } from '../../../logic/import/create_rules_stream_from_ndjson'; -import { buildRouteValidation } from '../../../../../../utils/build_validation/route_validation'; +import { getReferencedExceptionLists } from '../../../logic/import/gather_referenced_exceptions'; import type { RuleExceptionsPromiseFromStreams } from '../../../logic/import/import_rules_utils'; import { importRules as importRulesHelper } from '../../../logic/import/import_rules_utils'; -import { getReferencedExceptionLists } from '../../../logic/import/gather_referenced_exceptions'; import { importRuleExceptions } from '../../../logic/import/import_rule_exceptions'; -import { importRuleActionConnectors } from '../../../logic/import/action_connectors/import_rule_action_connectors'; +import { + getTupleDuplicateErrorsAndUniqueRules, + migrateLegacyActionsIds, +} from '../../../utils/utils'; const CHUNK_PARSED_OBJECT_SIZE = 50; @@ -64,10 +58,7 @@ export const importRulesRoute = ( version: '2023-10-31', validate: { request: { - query: buildRouteValidation< - typeof ImportRulesRequestQuery, - ImportRulesRequestQueryDecoded - >(ImportRulesRequestQuery), + query: buildRouteValidationWithZod(ImportRulesRequestQuery), body: schema.any(), // validation on file object is accomplished later in the handler. }, }, @@ -202,12 +193,7 @@ export const importRulesRoute = ( action_connectors_warnings: actionConnectorWarnings, }; - const [validated, errors] = validate(importRules, ImportRulesResponse); - if (errors != null) { - return siemResponse.error({ statusCode: 500, body: errors }); - } else { - return response.ok({ body: validated ?? {} }); - } + return response.ok({ body: ImportRulesResponse.parse(importRules) }); } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/tags/read_tags/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/tags/read_tags/route.ts index 12eb6dbd309a8..5120603f9f674 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/tags/read_tags/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/tags/read_tags/route.ts @@ -29,11 +29,8 @@ export const readTagsRoute = (router: SecuritySolutionPluginRouter) => { }, async (context, request, response): Promise> => { const siemResponse = buildSiemResponse(response); - const rulesClient = (await context.alerting)?.getRulesClient(); - - if (!rulesClient) { - return siemResponse.error({ statusCode: 404 }); - } + const ctx = await context.resolve(['alerting']); + const rulesClient = ctx.alerting.getRulesClient(); try { const tags = await readTags({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts index 24b8949120cf5..e1981cf9a3937 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/api/rule_execution_logs/get_rule_execution_results/get_rule_execution_results_route.ts @@ -5,17 +5,17 @@ * 2.0. */ -import { transformError } from '@kbn/securitysolution-es-utils'; import type { IKibanaResponse } from '@kbn/core/server'; -import { buildRouteValidation } from '../../../../../../utils/build_validation/route_validation'; -import { buildSiemResponse } from '../../../../routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; import type { SecuritySolutionPluginRouter } from '../../../../../../types'; +import { buildRouteValidationWithZod } from '../../../../../../utils/build_validation/route_validation'; +import { buildSiemResponse } from '../../../../routes/utils'; import type { GetRuleExecutionResultsResponse } from '../../../../../../../common/api/detection_engine/rule_monitoring'; import { - GET_RULE_EXECUTION_RESULTS_URL, GetRuleExecutionResultsRequestParams, GetRuleExecutionResultsRequestQuery, + GET_RULE_EXECUTION_RESULTS_URL, } from '../../../../../../../common/api/detection_engine/rule_monitoring'; /** @@ -36,8 +36,8 @@ export const getRuleExecutionResultsRoute = (router: SecuritySolutionPluginRoute version: '1', validate: { request: { - params: buildRouteValidation(GetRuleExecutionResultsRequestParams), - query: buildRouteValidation(GetRuleExecutionResultsRequestQuery), + params: buildRouteValidationWithZod(GetRuleExecutionResultsRequestParams), + query: buildRouteValidationWithZod(GetRuleExecutionResultsRequestQuery), }, }, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/detection_engine_health/event_log/aggregations/rule_execution_stats.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/detection_engine_health/event_log/aggregations/rule_execution_stats.ts index fe5da1322fc45..7c3b595be1565 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/detection_engine_health/event_log/aggregations/rule_execution_stats.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/detection_engine_health/event_log/aggregations/rule_execution_stats.ts @@ -15,10 +15,11 @@ import type { NumberOfLoggedMessages, RuleExecutionStats, TopMessages, + RuleExecutionStatus, } from '../../../../../../../../common/api/detection_engine/rule_monitoring'; import { RuleExecutionEventType, - RuleExecutionStatus, + RuleExecutionStatusEnum, LogLevel, } from '../../../../../../../../common/api/detection_engine/rule_monitoring'; @@ -72,8 +73,8 @@ export const getRuleExecutionStatsAggregation = ( { terms: { [f.RULE_EXECUTION_STATUS]: [ - RuleExecutionStatus.running, - RuleExecutionStatus['going to run'], + RuleExecutionStatusEnum.running, + RuleExecutionStatusEnum['going to run'], ], }, }, @@ -223,9 +224,9 @@ const normalizeNumberOfExecutions = ( return { total: Number(totalExecutions.value || 0), by_outcome: { - succeeded: getStatusCount(RuleExecutionStatus.succeeded), - warning: getStatusCount(RuleExecutionStatus['partial failure']), - failed: getStatusCount(RuleExecutionStatus.failed), + succeeded: getStatusCount(RuleExecutionStatusEnum.succeeded), + warning: getStatusCount(RuleExecutionStatusEnum['partial failure']), + failed: getStatusCount(RuleExecutionStatusEnum.failed), }, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/client_for_executors/client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/client_for_executors/client.ts index 2d14f62156db8..84cdc041f9a88 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/client_for_executors/client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/client_for_executors/client.ts @@ -5,30 +5,31 @@ * 2.0. */ +import type { Logger } from '@kbn/core/server'; import { sum } from 'lodash'; import type { Duration } from 'moment'; -import type { Logger } from '@kbn/core/server'; import type { - PublicRuleResultService, PublicRuleMonitoringService, + PublicRuleResultService, } from '@kbn/alerting-plugin/server/types'; import type { RuleExecutionMetrics, RuleExecutionSettings, + RuleExecutionStatus, } from '../../../../../../../common/api/detection_engine/rule_monitoring'; import { LogLevel, logLevelFromExecutionStatus, LogLevelSetting, logLevelToNumber, - RuleExecutionStatus, + RuleExecutionStatusEnum, } from '../../../../../../../common/api/detection_engine/rule_monitoring'; import { assertUnreachable } from '../../../../../../../common/utility_types'; import { withSecuritySpan } from '../../../../../../utils/with_security_span'; -import { truncateValue } from '../../utils/normalization'; import type { ExtMeta } from '../../utils/console_logging'; +import { truncateValue } from '../../utils/normalization'; import { getCorrelationIds } from './correlation_ids'; import type { IEventLogWriter } from '../event_log/event_log_writer'; @@ -164,7 +165,7 @@ export const createRuleExecutionLogClientForExecutors = ( const writeStatusChangeToRuleObject = async (args: NormalizedStatusChangeArgs): Promise => { const { newStatus, message, metrics } = args; - if (newStatus === RuleExecutionStatus.running) { + if (newStatus === RuleExecutionStatusEnum.running) { return; } @@ -186,9 +187,9 @@ export const createRuleExecutionLogClientForExecutors = ( ruleMonitoringService.setLastRunMetricsGapDurationS(executionGapDurationS); } - if (newStatus === RuleExecutionStatus.failed) { + if (newStatus === RuleExecutionStatusEnum.failed) { ruleResultService.addLastRunError(message); - } else if (newStatus === RuleExecutionStatus['partial failure']) { + } else if (newStatus === RuleExecutionStatusEnum['partial failure']) { ruleResultService.addLastRunWarning(message); } @@ -234,7 +235,7 @@ interface NormalizedStatusChangeArgs { } const normalizeStatusChangeArgs = (args: StatusChangeArgs): NormalizedStatusChangeArgs => { - if (args.newStatus === RuleExecutionStatus.running) { + if (args.newStatus === RuleExecutionStatusEnum.running) { return { newStatus: args.newStatus, message: '', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/client_for_executors/client_interface.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/client_for_executors/client_interface.ts index e1d0998a21225..c6a133dce01d7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/client_for_executors/client_interface.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/client_for_executors/client_interface.ts @@ -6,7 +6,10 @@ */ import type { Duration } from 'moment'; -import type { RuleExecutionStatus } from '../../../../../../../common/api/detection_engine/rule_monitoring'; +import type { + RuleExecutionStatus, + RuleExecutionStatusEnum, +} from '../../../../../../../common/api/detection_engine/rule_monitoring'; /** * Used from rule executors to log various information about the rule execution: @@ -109,7 +112,7 @@ export interface RuleExecutionContext { } export interface RunningStatusChangeArgs { - newStatus: RuleExecutionStatus.running; + newStatus: RuleExecutionStatusEnum['running']; } /** diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/create_rule_execution_summary.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/create_rule_execution_summary.ts index 7dbac0ded96e5..f72b110a844b3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/create_rule_execution_summary.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/create_rule_execution_summary.ts @@ -9,9 +9,9 @@ import type { ResolvedSanitizedRule, SanitizedRule } from '@kbn/alerting-plugin/ import type { RuleExecutionSummary } from '../../../../../../common/api/detection_engine/rule_monitoring'; import { - ruleLastRunOutcomeToExecutionStatus, + RuleExecutionStatusEnum, ruleExecutionStatusToNumber, - RuleExecutionStatus, + ruleLastRunOutcomeToExecutionStatus, } from '../../../../../../common/api/detection_engine/rule_monitoring'; import type { RuleParams } from '../../../rule_schema'; @@ -39,8 +39,8 @@ export const createRuleExecutionSummary = ( return { last_execution: { date: lastRunInternal.timestamp, - status: RuleExecutionStatus.running, - status_order: ruleExecutionStatusToNumber(RuleExecutionStatus.running), + status: RuleExecutionStatusEnum.running, + status_order: ruleExecutionStatusToNumber(RuleExecutionStatusEnum.running), message: '', metrics: {}, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/event_log/aggregations/execution_results/index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/event_log/aggregations/execution_results/index.test.ts index 234eac9d594e6..79ce2503cbe9b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/event_log/aggregations/execution_results/index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/event_log/aggregations/execution_results/index.test.ts @@ -13,7 +13,7 @@ */ import { MAX_EXECUTION_EVENTS_DISPLAYED } from '@kbn/securitysolution-rules'; -import { RuleExecutionStatus } from '../../../../../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../../../../../common/api/detection_engine/rule_monitoring'; import { formatExecutionEventResponse, @@ -1328,30 +1328,30 @@ describe('mapRuleStatusToPlatformStatus', () => { expect(mapRuleExecutionStatusToPlatformStatus([])).toEqual([]); }); - test('should correctly translate RuleExecutionStatus.failed to `failure` platform status', () => { - expect(mapRuleExecutionStatusToPlatformStatus([RuleExecutionStatus.failed])).toEqual([ + test('should correctly translate RuleExecutionStatusEnum.failed to `failure` platform status', () => { + expect(mapRuleExecutionStatusToPlatformStatus([RuleExecutionStatusEnum.failed])).toEqual([ 'failure', ]); }); - test('should correctly translate RuleExecutionStatus.succeeded to `success` platform status', () => { - expect(mapRuleExecutionStatusToPlatformStatus([RuleExecutionStatus.succeeded])).toEqual([ + test('should correctly translate RuleExecutionStatusEnum.succeeded to `success` platform status', () => { + expect(mapRuleExecutionStatusToPlatformStatus([RuleExecutionStatusEnum.succeeded])).toEqual([ 'success', ]); }); - test('should correctly translate RuleExecutionStatus.["going to run"] to empty array platform status', () => { - expect(mapRuleExecutionStatusToPlatformStatus([RuleExecutionStatus['going to run']])).toEqual( - [] - ); + test('should correctly translate RuleExecutionStatusEnum.["going to run"] to empty array platform status', () => { + expect( + mapRuleExecutionStatusToPlatformStatus([RuleExecutionStatusEnum['going to run']]) + ).toEqual([]); }); test("should correctly translate multiple RuleExecutionStatus's to platform statuses", () => { expect( mapRuleExecutionStatusToPlatformStatus([ - RuleExecutionStatus.succeeded, - RuleExecutionStatus.failed, - RuleExecutionStatus['going to run'], + RuleExecutionStatusEnum.succeeded, + RuleExecutionStatusEnum.failed, + RuleExecutionStatusEnum['going to run'], ]).sort() ).toEqual(['failure', 'success']); }); @@ -1362,13 +1362,15 @@ describe('mapPlatformStatusToRuleExecutionStatus', () => { expect(mapPlatformStatusToRuleExecutionStatus('')).toEqual(undefined); }); - test('should correctly translate `failure` platform status to `RuleExecutionStatus.failed`', () => { - expect(mapPlatformStatusToRuleExecutionStatus('failure')).toEqual(RuleExecutionStatus.failed); + test('should correctly translate `failure` platform status to `RuleExecutionStatusEnum.failed`', () => { + expect(mapPlatformStatusToRuleExecutionStatus('failure')).toEqual( + RuleExecutionStatusEnum.failed + ); }); - test('should correctly translate `success` platform status to `RuleExecutionStatus.succeeded`', () => { + test('should correctly translate `success` platform status to `RuleExecutionStatusEnum.succeeded`', () => { expect(mapPlatformStatusToRuleExecutionStatus('success')).toEqual( - RuleExecutionStatus.succeeded + RuleExecutionStatusEnum.succeeded ); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/event_log/aggregations/execution_results/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/event_log/aggregations/execution_results/index.ts index d2077208e718e..e4e394fd16149 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/event_log/aggregations/execution_results/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_monitoring/logic/rule_execution_log/event_log/aggregations/execution_results/index.ts @@ -5,23 +5,24 @@ * 2.0. */ -import { flatMap, get } from 'lodash'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { AggregateEventsBySavedObjectResult } from '@kbn/event-log-plugin/server'; import { BadRequestError } from '@kbn/securitysolution-es-utils'; import { MAX_EXECUTION_EVENTS_DISPLAYED } from '@kbn/securitysolution-rules'; -import type { AggregateEventsBySavedObjectResult } from '@kbn/event-log-plugin/server'; +import { flatMap, get } from 'lodash'; import type { - RuleExecutionResult, GetRuleExecutionResultsResponse, + RuleExecutionResult, + RuleExecutionStatus, } from '../../../../../../../../../common/api/detection_engine/rule_monitoring'; -import { RuleExecutionStatus } from '../../../../../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../../../../../common/api/detection_engine/rule_monitoring'; +import * as f from '../../../../event_log/event_log_fields'; import type { ExecutionEventAggregationOptions, - ExecutionUuidAggResult, ExecutionUuidAggBucket, + ExecutionUuidAggResult, } from './types'; -import * as f from '../../../../event_log/event_log_fields'; // TODO: https://github.com/elastic/kibana/issues/125642 Move the fields from this file to `event_log_fields.ts` @@ -378,9 +379,9 @@ export const mapRuleExecutionStatusToPlatformStatus = ( ): string[] => { return flatMap(ruleStatuses, (rs) => { switch (rs) { - case RuleExecutionStatus.failed: + case RuleExecutionStatusEnum.failed: return 'failure'; - case RuleExecutionStatus.succeeded: + case RuleExecutionStatusEnum.succeeded: return 'success'; default: return []; @@ -397,9 +398,9 @@ export const mapPlatformStatusToRuleExecutionStatus = ( ): RuleExecutionStatus | undefined => { switch (platformStatus) { case 'failure': - return RuleExecutionStatus.failed; + return RuleExecutionStatusEnum.failed; case 'success': - return RuleExecutionStatus.succeeded; + return RuleExecutionStatusEnum.succeeded; default: return undefined; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts index 6a30227b82f03..b41edf380fcfa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_preview/api/preview_rules/route.ts @@ -24,7 +24,7 @@ import { DETECTION_ENGINE_RULES_PREVIEW, } from '../../../../../../common/constants'; import { validateCreateRuleProps } from '../../../../../../common/api/detection_engine/rule_management'; -import { RuleExecutionStatus } from '../../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../../common/api/detection_engine/rule_monitoring'; import type { PreviewResponse, RulePreviewLogs, @@ -287,11 +287,11 @@ export const previewRulesRoute = async ( })) as { state: TState }); const errors = loggedStatusChanges - .filter((item) => item.newStatus === RuleExecutionStatus.failed) + .filter((item) => item.newStatus === RuleExecutionStatusEnum.failed) .map((item) => item.message ?? 'Unknown Error'); const warnings = loggedStatusChanges - .filter((item) => item.newStatus === RuleExecutionStatus['partial failure']) + .filter((item) => item.newStatus === RuleExecutionStatusEnum['partial failure']) .map((item) => item.message ?? 'Unknown Warning'); logs.push({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index a2cfc34b1d1d8..ec1db8812966e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -36,7 +36,7 @@ import { getNotificationResultsLink } from '../rule_actions_legacy'; import { formatAlertForNotificationActions } from '../rule_actions_legacy/logic/notifications/schedule_notification_actions'; import { createResultObject } from './utils'; import { bulkCreateFactory, wrapHitsFactory, wrapSequencesFactory } from './factories'; -import { RuleExecutionStatus } from '../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../common/api/detection_engine/rule_monitoring'; import { truncateList } from '../rule_monitoring'; import aadFieldConversion from '../routes/index/signal_aad_mapping.json'; import { extractReferences, injectReferences } from './saved_object_references'; @@ -179,7 +179,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = ruleExecutionLogger.debug(`Starting Security Rule execution (interval: ${interval})`); await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus.running, + newStatus: RuleExecutionStatusEnum.running, }); let result = createResultObject(state); @@ -240,7 +240,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = : `Check for indices to search failed ${exc}`; await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus.failed, + newStatus: RuleExecutionStatusEnum.failed, message: errorMessage, }); @@ -299,7 +299,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = } } catch (exc) { await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: `Check privileges failed to execute ${exc}`, }); wroteWarningStatus = true; @@ -321,7 +321,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = const gapDuration = `${remainingGap.humanize()} (${remainingGap.asMilliseconds()}ms)`; await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus.failed, + newStatus: RuleExecutionStatusEnum.failed, message: `${gapDuration} were not queried between this rule execution and the last execution, so signals may have been missed. Consider increasing your look behind time or adding more Kibana instances`, metrics: { executionGap: remainingGap }, }); @@ -459,7 +459,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = if (result.warningMessages.length) { await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: truncateList(result.warningMessages).join(), metrics: { searchDurations: result.searchAfterTimes, @@ -485,7 +485,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = if (!hasError && !wroteWarningStatus && !result.warning) { await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus.succeeded, + newStatus: RuleExecutionStatusEnum.succeeded, message: 'Rule execution completed successfully', metrics: { searchDurations: result.searchAfterTimes, @@ -495,7 +495,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = }); } else if (wroteWarningStatus && !hasError && !result.warning) { await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: warningMessage, metrics: { searchDurations: result.searchAfterTimes, @@ -506,7 +506,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = } } else { await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus.failed, + newStatus: RuleExecutionStatusEnum.failed, message: `Bulk Indexing of alerts failed: ${truncateList(result.errors).join()}`, metrics: { searchDurations: result.searchAfterTimes, @@ -519,7 +519,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = const errorMessage = error.message ?? '(no error message given)'; await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus.failed, + newStatus: RuleExecutionStatusEnum.failed, message: `An error occurred during rule execution: message: "${errorMessage}"`, metrics: { searchDurations: result.searchAfterTimes, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.test.ts index 0a18cd40b3a61..b2d93868cd976 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.test.ts @@ -14,7 +14,7 @@ import type { RuleExecutorServicesMock } from '@kbn/alerting-plugin/server/mocks import { alertsMock } from '@kbn/alerting-plugin/server/mocks'; import { listMock } from '@kbn/lists-plugin/server/mocks'; import type { ExceptionListClient } from '@kbn/lists-plugin/server'; -import { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../common/api/detection_engine/rule_monitoring'; import { getListArrayMock } from '../../../../../common/detection_engine/schemas/types/lists.mock'; import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock'; @@ -655,7 +655,7 @@ describe('utils', () => { expect(wroteWarningStatus).toBeTruthy(); expect(foundNoIndices).toBeFalsy(); expect(ruleExecutionLogger.logStatusChange).toHaveBeenCalledWith({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: 'The following indices are missing the timestamp override field "event.ingested": ["myfakeindex-1","myfakeindex-2"]', }); @@ -699,7 +699,7 @@ describe('utils', () => { expect(wroteWarningStatus).toBeTruthy(); expect(foundNoIndices).toBeFalsy(); expect(ruleExecutionLogger.logStatusChange).toHaveBeenCalledWith({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: 'The following indices are missing the timestamp field "@timestamp": ["myfakeindex-1","myfakeindex-2"]', }); @@ -732,7 +732,7 @@ describe('utils', () => { expect(wroteWarningStatus).toBeTruthy(); expect(foundNoIndices).toBeTruthy(); expect(ruleExecutionLogger.logStatusChange).toHaveBeenCalledWith({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: 'This rule is attempting to query data from Elasticsearch indices listed in the "Index patterns" section of the rule definition, however no index matching: ["logs-endpoint.alerts-*"] was found. This warning will continue to appear until a matching index is created or this rule is disabled. If you have recently enrolled agents enabled with Endpoint Security through Fleet, this warning should stop once an alert is sent from an agent.', }); @@ -766,7 +766,7 @@ describe('utils', () => { expect(wroteWarningStatus).toBeTruthy(); expect(foundNoIndices).toBeTruthy(); expect(ruleExecutionLogger.logStatusChange).toHaveBeenCalledWith({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: 'This rule is attempting to query data from Elasticsearch indices listed in the "Index patterns" section of the rule definition, however no index matching: ["logs-endpoint.alerts-*"] was found. This warning will continue to appear until a matching index is created or this rule is disabled.', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.ts index 27d7df31b9d0a..55728183b8bfd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/utils.ts @@ -32,7 +32,7 @@ import { parseDuration } from '@kbn/alerting-plugin/server'; import type { ExceptionListClient, ListClient, ListPluginSetup } from '@kbn/lists-plugin/server'; import type { TimestampOverride } from '../../../../../common/api/detection_engine/model/rule_schema'; import type { Privilege } from '../../../../../common/api/detection_engine'; -import { RuleExecutionStatus } from '../../../../../common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '../../../../../common/api/detection_engine/rule_monitoring'; import type { BulkResponseErrorAggregation, SignalHit, @@ -94,7 +94,7 @@ export const hasReadIndexPrivileges = async (args: { const indexesString = JSON.stringify(indexesWithNoReadPrivileges); warningStatusMessage = `This rule may not have the required read privileges to the following index patterns: ${indexesString}`; await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: warningStatusMessage, }); return { wroteWarningMessage: true, warningStatusMessage }; @@ -129,7 +129,7 @@ export const hasTimestampFields = async (args: { }`; await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: errorString.trimEnd(), }); @@ -157,7 +157,7 @@ export const hasTimestampFields = async (args: { )}`; await ruleExecutionLogger.logStatusChange({ - newStatus: RuleExecutionStatus['partial failure'], + newStatus: RuleExecutionStatusEnum['partial failure'], message: errorString, }); diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts index f87e845487a04..0e7a2db2d4e47 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_create_handler.ts @@ -11,10 +11,11 @@ import type { } from '@kbn/lists-plugin/server'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import { + BlocklistValidator, + EndpointExceptionsValidator, EventFilterValidator, - TrustedAppValidator, HostIsolationExceptionsValidator, - BlocklistValidator, + TrustedAppValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreCreateItemServerExtension['callback']; @@ -65,6 +66,17 @@ export const getExceptionsPreCreateItemHandler = ( return validatedItem; } + // validate endpoint exceptions + if (EndpointExceptionsValidator.isEndpointException(data)) { + const endpointExceptionValidator = new EndpointExceptionsValidator( + endpointAppContext, + request + ); + const validatedItem = await endpointExceptionValidator.validatePreCreateItem(data); + endpointExceptionValidator.notifyFeatureUsage(data, 'ENDPOINT_EXCEPTIONS'); + return validatedItem; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts index 66a9b4709bd2b..719cd7b655b22 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_delete_item_handler.ts @@ -9,10 +9,11 @@ import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-t import type { ExceptionsListPreDeleteItemServerExtension } from '@kbn/lists-plugin/server'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import { - TrustedAppValidator, - HostIsolationExceptionsValidator, - EventFilterValidator, BlocklistValidator, + EndpointExceptionsValidator, + EventFilterValidator, + HostIsolationExceptionsValidator, + TrustedAppValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreDeleteItemServerExtension['callback']; @@ -64,6 +65,15 @@ export const getExceptionsPreDeleteItemHandler = ( return data; } + // Validate Endpoint Exceptions + if (EndpointExceptionsValidator.isEndpointException({ listId })) { + await new EndpointExceptionsValidator( + endpointAppContextService, + request + ).validatePreDeleteItem(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts index 7dac876b34f22..47ed89ce19982 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_export_handler.ts @@ -8,10 +8,11 @@ import type { ExceptionsListPreExportServerExtension } from '@kbn/lists-plugin/server'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import { - TrustedAppValidator, - HostIsolationExceptionsValidator, - EventFilterValidator, BlocklistValidator, + EndpointExceptionsValidator, + EventFilterValidator, + HostIsolationExceptionsValidator, + TrustedAppValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreExportServerExtension['callback']; @@ -61,6 +62,12 @@ export const getExceptionsPreExportHandler = ( return data; } + // Validate Endpoint Exceptions + if (EndpointExceptionsValidator.isEndpointException({ listId })) { + await new EndpointExceptionsValidator(endpointAppContextService, request).validatePreExport(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts index 9ed81b1f0d585..15e9af5e61ac9 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_get_one_handler.ts @@ -9,10 +9,11 @@ import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-t import type { ExceptionsListPreGetOneItemServerExtension } from '@kbn/lists-plugin/server'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import { - TrustedAppValidator, - HostIsolationExceptionsValidator, - EventFilterValidator, BlocklistValidator, + EndpointExceptionsValidator, + EventFilterValidator, + HostIsolationExceptionsValidator, + TrustedAppValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreGetOneItemServerExtension['callback']; @@ -64,6 +65,15 @@ export const getExceptionsPreGetOneHandler = ( return data; } + // Validate Endpoint Exceptions + if (EndpointExceptionsValidator.isEndpointException({ listId })) { + await new EndpointExceptionsValidator( + endpointAppContextService, + request + ).validatePreGetOneItem(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts index 973bb6ce5072a..e3e66daebc57d 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_multi_list_find_handler.ts @@ -8,10 +8,11 @@ import type { ExceptionsListPreMultiListFindServerExtension } from '@kbn/lists-plugin/server'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import { - TrustedAppValidator, - HostIsolationExceptionsValidator, - EventFilterValidator, BlocklistValidator, + EndpointExceptionsValidator, + EventFilterValidator, + HostIsolationExceptionsValidator, + TrustedAppValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreMultiListFindServerExtension['callback']; @@ -54,6 +55,15 @@ export const getExceptionsPreMultiListFindHandler = ( return data; } + // Validate Endpoint Exceptions + if (data.listId.some((id) => EndpointExceptionsValidator.isEndpointException({ listId: id }))) { + await new EndpointExceptionsValidator( + endpointAppContextService, + request + ).validatePreMultiListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts index 946a0bf6d7c43..e647ea2a710f5 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_single_list_find_handler.ts @@ -8,10 +8,11 @@ import type { ExceptionsListPreSingleListFindServerExtension } from '@kbn/lists-plugin/server'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import { - TrustedAppValidator, - HostIsolationExceptionsValidator, - EventFilterValidator, BlocklistValidator, + EndpointExceptionsValidator, + EventFilterValidator, + HostIsolationExceptionsValidator, + TrustedAppValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSingleListFindServerExtension['callback']; @@ -55,6 +56,15 @@ export const getExceptionsPreSingleListFindHandler = ( return data; } + // Validate Endpoint Exceptions + if (EndpointExceptionsValidator.isEndpointException({ listId })) { + await new EndpointExceptionsValidator( + endpointAppContextService, + request + ).validatePreSingleListFind(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts index 6b9af37f877ab..d50504572b369 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_summary_handler.ts @@ -8,10 +8,11 @@ import type { ExceptionsListPreSummaryServerExtension } from '@kbn/lists-plugin/server'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import { - TrustedAppValidator, - HostIsolationExceptionsValidator, - EventFilterValidator, BlocklistValidator, + EndpointExceptionsValidator, + EventFilterValidator, + HostIsolationExceptionsValidator, + TrustedAppValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreSummaryServerExtension['callback']; @@ -61,6 +62,15 @@ export const getExceptionsPreSummaryHandler = ( return data; } + // Validate Endpoint Exceptions + if (EndpointExceptionsValidator.isEndpointException({ listId })) { + await new EndpointExceptionsValidator( + endpointAppContextService, + request + ).validatePreGetListSummary(); + return data; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts index 681d16a1e44b8..810b569ecf8a6 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/handlers/exceptions_pre_update_handler.ts @@ -12,10 +12,11 @@ import type { import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import type { ExceptionItemLikeOptions } from '../types'; import { + BlocklistValidator, + EndpointExceptionsValidator, EventFilterValidator, - TrustedAppValidator, HostIsolationExceptionsValidator, - BlocklistValidator, + TrustedAppValidator, } from '../validators'; type ValidatorCallback = ExceptionsListPreUpdateItemServerExtension['callback']; @@ -98,6 +99,20 @@ export const getExceptionsPreUpdateItemHandler = ( return validatedItem; } + // Validate Endpoint Exceptions + if (EndpointExceptionsValidator.isEndpointException({ listId })) { + const endpointExceptionValidator = new EndpointExceptionsValidator( + endpointAppContextService, + request + ); + const validatedItem = await endpointExceptionValidator.validatePreUpdateItem(data); + endpointExceptionValidator.notifyFeatureUsage( + data as ExceptionItemLikeOptions, + 'ENDPOINT_EXCEPTIONS' + ); + return validatedItem; + } + return data; }; }; diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts index ecc2ac7893336..4630ad9edec07 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/base_validator.ts @@ -10,6 +10,7 @@ import { schema } from '@kbn/config-schema'; import { isEqual } from 'lodash/fp'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { OperatingSystem } from '@kbn/securitysolution-utils'; + import type { EndpointAuthz } from '../../../../common/endpoint/types/authz'; import type { EndpointAppContextService } from '../../../endpoint/endpoint_app_context_services'; import type { ExceptionItemLikeOptions } from '../types'; @@ -19,6 +20,7 @@ import { isArtifactByPolicy, } from '../../../../common/endpoint/service/artifacts'; import { EndpointArtifactExceptionValidationError } from './errors'; +import { EndpointExceptionsValidationError } from './endpoint_exception_errors'; import type { FeatureKeys } from '../../../endpoint/services/feature_usage/service'; export const BasicEndpointExceptionDataSchema = schema.object( @@ -74,6 +76,14 @@ export class BaseValidator { } } + protected async validateHasEndpointExceptionsPrivileges( + privilege: keyof EndpointAuthz + ): Promise { + if (!(await this.endpointAuthzPromise)[privilege]) { + throw new EndpointExceptionsValidationError('Endpoint exceptions authorization failure', 403); + } + } + protected async validateHasPrivilege(privilege: keyof EndpointAuthz): Promise { if (!(await this.endpointAuthzPromise)[privilege]) { throw new EndpointArtifactExceptionValidationError('Endpoint authorization failure', 403); diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exception_errors.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exception_errors.ts new file mode 100644 index 0000000000000..d0f2ba0537b55 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exception_errors.ts @@ -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. + */ + +import { ListsErrorWithStatusCode } from '@kbn/lists-plugin/server'; + +export class EndpointExceptionsValidationError extends ListsErrorWithStatusCode { + constructor(message: string, statusCode: number = 400) { + super(`EndpointExceptionsError: ${message}`, statusCode); + } +} diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts new file mode 100644 index 0000000000000..23d1d28ba0a59 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/endpoint_exceptions_validator.ts @@ -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 { + CreateExceptionListItemOptions, + UpdateExceptionListItemOptions, +} from '@kbn/lists-plugin/server'; +import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; +import { BaseValidator } from './base_validator'; + +export class EndpointExceptionsValidator extends BaseValidator { + static isEndpointException(item: { listId: string }): boolean { + return item.listId === ENDPOINT_LIST_ID; + } + + protected async validateHasReadPrivilege(): Promise { + return this.validateHasEndpointExceptionsPrivileges('canReadEndpointExceptions'); + } + + protected async validateHasWritePrivilege(): Promise { + return this.validateHasEndpointExceptionsPrivileges('canWriteEndpointExceptions'); + } + + async validatePreCreateItem(item: CreateExceptionListItemOptions) { + await this.validateHasWritePrivilege(); + return item; + } + + async validatePreUpdateItem(item: UpdateExceptionListItemOptions) { + await this.validateHasWritePrivilege(); + return item; + } + + async validatePreDeleteItem(): Promise { + await this.validateHasWritePrivilege(); + } + + async validatePreGetOneItem(): Promise { + await this.validateHasReadPrivilege(); + } + + async validatePreMultiListFind(): Promise { + await this.validateHasReadPrivilege(); + } + + async validatePreExport(): Promise { + await this.validateHasReadPrivilege(); + } + + async validatePreSingleListFind(): Promise { + await this.validateHasReadPrivilege(); + } + + async validatePreGetListSummary(): Promise { + await this.validateHasReadPrivilege(); + } +} diff --git a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts index ccd6ebd8e08d6..8dd357b15ebd9 100644 --- a/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts +++ b/x-pack/plugins/security_solution/server/lists_integration/endpoint/validators/index.ts @@ -9,3 +9,4 @@ export { TrustedAppValidator } from './trusted_app_validator'; export { EventFilterValidator } from './event_filter_validator'; export { HostIsolationExceptionsValidator } from './host_isolation_exceptions_validator'; export { BlocklistValidator } from './blocklist_validator'; +export { EndpointExceptionsValidator } from './endpoint_exceptions_validator'; diff --git a/x-pack/plugins/security_solution/server/utils/build_validation/route_validation.ts b/x-pack/plugins/security_solution/server/utils/build_validation/route_validation.ts index 57692b515a230..a6e41257123ab 100644 --- a/x-pack/plugins/security_solution/server/utils/build_validation/route_validation.ts +++ b/x-pack/plugins/security_solution/server/utils/build_validation/route_validation.ts @@ -14,6 +14,8 @@ import type { RouteValidationResultFactory, RouteValidationError, } from '@kbn/core/server'; +import type { TypeOf, ZodType } from 'zod'; +import { stringifyZodError } from '@kbn/securitysolution-es-utils'; import type { GenericIntersectionC } from '../runtime_types'; import { excess } from '../runtime_types'; @@ -65,3 +67,14 @@ export const buildRouteValidationWithExcess = (validatedInput: A) => validationResult.ok(validatedInput) ) ); + +export const buildRouteValidationWithZod = + >(schema: T): RouteValidationFunction => + (inputValue: unknown, validationResult: RouteValidationResultFactory) => { + const decoded = schema.safeParse(inputValue); + if (decoded.success) { + return validationResult.ok(decoded.data); + } else { + return validationResult.badRequest(stringifyZodError(decoded.error)); + } + }; diff --git a/x-pack/plugins/security_solution/tsconfig.json b/x-pack/plugins/security_solution/tsconfig.json index 19ced3697e2c9..de11312c2f60e 100644 --- a/x-pack/plugins/security_solution/tsconfig.json +++ b/x-pack/plugins/security_solution/tsconfig.json @@ -176,6 +176,7 @@ "@kbn/subscription-tracking", "@kbn/core-application-common", "@kbn/openapi-generator", - "@kbn/es" + "@kbn/es", + "@kbn/react-kibana-mount" ] } diff --git a/x-pack/plugins/security_solution_serverless/common/pli/pli_config.ts b/x-pack/plugins/security_solution_serverless/common/pli/pli_config.ts index 1ef3da121b910..62acff6857a8e 100644 --- a/x-pack/plugins/security_solution_serverless/common/pli/pli_config.ts +++ b/x-pack/plugins/security_solution_serverless/common/pli/pli_config.ts @@ -33,6 +33,7 @@ export const PLI_APP_FEATURES: PliAppFeatures = { complete: [ AppFeatureKey.endpointResponseActions, AppFeatureKey.osqueryAutomatedResponseActions, + AppFeatureKey.endpointExceptions, ], }, cloud: { diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/lazy_upselling.tsx b/x-pack/plugins/security_solution_serverless/public/upselling/lazy_upselling.tsx index e24434ea0b9e8..71f787e19c3bd 100644 --- a/x-pack/plugins/security_solution_serverless/public/upselling/lazy_upselling.tsx +++ b/x-pack/plugins/security_solution_serverless/public/upselling/lazy_upselling.tsx @@ -27,6 +27,10 @@ export const OsqueryResponseActionsUpsellingSectionLazy = withSuspenseUpsell( lazy(() => import('./pages/osquery_automated_response_actions')) ); +export const EndpointExceptionsDetailsUpsellingLazy = withSuspenseUpsell( + lazy(() => import('./pages/endpoint_management/endpoint_exceptions_details')) +); + export const EntityAnalyticsUpsellingLazy = withSuspenseUpsell( lazy(() => import('@kbn/security-solution-upselling/pages/entity_analytics')) ); diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/pages/endpoint_management/endpoint_exceptions_details.tsx b/x-pack/plugins/security_solution_serverless/public/upselling/pages/endpoint_management/endpoint_exceptions_details.tsx new file mode 100644 index 0000000000000..fd514350eaf0a --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/upselling/pages/endpoint_management/endpoint_exceptions_details.tsx @@ -0,0 +1,47 @@ +/* + * 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 { EuiEmptyPrompt, EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { memo } from 'react'; +import type { AppFeatureKeyType } from '@kbn/security-solution-features/keys'; +import { getProductTypeByPLI } from '../../hooks/use_product_type_by_pli'; + +const EndpointExceptionsDetailsUpselling: React.FC<{ requiredPLI: AppFeatureKeyType }> = memo( + ({ requiredPLI }) => { + const productTypeRequired = getProductTypeByPLI(requiredPLI); + + return ( + } + color="subdued" + title={ +

+ +

+ } + body={ +

+ +

+ } + /> + ); + } +); + +EndpointExceptionsDetailsUpselling.displayName = 'EndpointExceptionsDetailsUpselling'; + +// eslint-disable-next-line import/no-default-export +export { EndpointExceptionsDetailsUpselling as default }; diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/pages/osquery_automated_response_actions.tsx b/x-pack/plugins/security_solution_serverless/public/upselling/pages/osquery_automated_response_actions.tsx index 3097d41819058..b1abd3400797c 100644 --- a/x-pack/plugins/security_solution_serverless/public/upselling/pages/osquery_automated_response_actions.tsx +++ b/x-pack/plugins/security_solution_serverless/public/upselling/pages/osquery_automated_response_actions.tsx @@ -8,6 +8,7 @@ import { EuiEmptyPrompt, EuiIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import React from 'react'; + import type { AppFeatureKeyType } from '@kbn/security-solution-features'; import { getProductTypeByPLI } from '../hooks/use_product_type_by_pli'; diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.tsx b/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.tsx index f1b8da6b1557d..4c9f9f65fc0a7 100644 --- a/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.tsx +++ b/x-pack/plugins/security_solution_serverless/public/upselling/register_upsellings.tsx @@ -17,10 +17,14 @@ import React from 'react'; import { UPGRADE_INVESTIGATION_GUIDE } from '@kbn/security-solution-upselling/messages'; import { AppFeatureKey } from '@kbn/security-solution-features/keys'; import type { AppFeatureKeyType } from '@kbn/security-solution-features'; -import { EndpointPolicyProtectionsLazy } from './sections/endpoint_management'; +import { + EndpointPolicyProtectionsLazy, + RuleDetailsEndpointExceptionsLazy, +} from './sections/endpoint_management'; import type { SecurityProductTypes } from '../../common/config'; import { getProductAppFeatures } from '../../common/pli/pli_features'; import { + EndpointExceptionsDetailsUpsellingLazy, EntityAnalyticsUpsellingLazy, OsqueryResponseActionsUpsellingSectionLazy, ThreatIntelligencePaywallLazy, @@ -86,7 +90,7 @@ export const registerUpsellings = ( upselling.setMessages(upsellingMessagesToRegister); }; -// Upsellings for entire pages, linked to a SecurityPageName +// Upselling for entire pages, linked to a SecurityPageName export const upsellingPages: UpsellingPages = [ // It is highly advisable to make use of lazy loaded components to minimize bundle size. { @@ -105,9 +109,16 @@ export const upsellingPages: UpsellingPages = [ ), }, + { + pageName: SecurityPageName.exceptions, + pli: AppFeatureKey.endpointExceptions, + component: () => ( + + ), + }, ]; -// Upsellings for sections, linked by arbitrary ids +// Upselling for sections, linked by arbitrary ids export const upsellingSections: UpsellingSections = [ // It is highly advisable to make use of lazy loaded components to minimize bundle size. { @@ -124,9 +135,14 @@ export const upsellingSections: UpsellingSections = [ pli: AppFeatureKey.endpointPolicyProtections, component: EndpointPolicyProtectionsLazy, }, + { + id: 'ruleDetailsEndpointExceptions', + pli: AppFeatureKey.endpointExceptions, + component: RuleDetailsEndpointExceptionsLazy, + }, ]; -// Upsellings for sections, linked by arbitrary ids +// Upselling for sections, linked by arbitrary ids export const upsellingMessages: UpsellingMessages = [ { id: 'investigation_guide', diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management/index.ts b/x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management/index.ts index a76b1cc0bacc8..e6db03492885e 100644 --- a/x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management/index.ts +++ b/x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management/index.ts @@ -12,3 +12,9 @@ export const EndpointPolicyProtectionsLazy = lazy(() => default: EndpointPolicyProtections, })) ); + +export const RuleDetailsEndpointExceptionsLazy = lazy(() => + import('./rule_details_endpoint_exceptions').then(({ RuleDetailsEndpointExceptions }) => ({ + default: RuleDetailsEndpointExceptions, + })) +); diff --git a/x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management/rule_details_endpoint_exceptions.tsx b/x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management/rule_details_endpoint_exceptions.tsx new file mode 100644 index 0000000000000..39bf086d7558b --- /dev/null +++ b/x-pack/plugins/security_solution_serverless/public/upselling/sections/endpoint_management/rule_details_endpoint_exceptions.tsx @@ -0,0 +1,62 @@ +/* + * 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, { memo } from 'react'; +import { EuiCard, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import styled from '@emotion/styled'; + +const BADGE_TEXT = i18n.translate( + 'xpack.securitySolutionServerless.rules.endpointSecurity.endpointExceptions.badgeText', + { + defaultMessage: 'Endpoint Essentials', + } +); +const CARD_TITLE = i18n.translate( + 'xpack.securitySolutionServerless.rules.endpointSecurity.endpointExceptions.cardTitle', + { + defaultMessage: 'Do more with Security!', + } +); +const CARD_MESSAGE = i18n.translate( + 'xpack.securitySolutionServerless.rules.endpointSecurity.endpointExceptions.cardMessage', + { + defaultMessage: + 'Upgrade your license to {productTypeRequired} to use Endpoint Security Exception List.', + values: { productTypeRequired: BADGE_TEXT }, + } +); + +const CardDescription = styled.p` + padding: 0 33.3%; +`; + +/** + * Component displayed trying to access endpoint exceptions tab on Endpoint security rule details. + */ +export const RuleDetailsEndpointExceptions = memo(() => { + return ( + } + betaBadgeProps={{ + 'data-test-subj': 'rules-endpointSecurity-endpointExceptionsLockedCard-badge', + label: BADGE_TEXT, + }} + title={ +

+ {CARD_TITLE} +

+ } + > + {CARD_MESSAGE} +
+ ); +}); +RuleDetailsEndpointExceptions.displayName = 'RuleDetailsEndpointExceptions'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/action_variables.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/action_variables.ts index 7ec8b3e3d5f6d..926d9c68be069 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/action_variables.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/action_variables.ts @@ -16,6 +16,17 @@ export const casesVars: ActionVariable[] = [ useWithTripleBracesInTemplates: true, }, { name: 'case.tags', description: i18n.CASE_TAGS_DESC, useWithTripleBracesInTemplates: true }, + { name: 'case.id', description: i18n.CASE_ID_DESC, useWithTripleBracesInTemplates: true }, + { + name: 'case.status', + description: i18n.CASE_SEVERITY_DESC, + useWithTripleBracesInTemplates: true, + }, + { + name: 'case.severity', + description: i18n.CASE_STATUS_DESC, + useWithTripleBracesInTemplates: true, + }, ]; export const commentVars: ActionVariable[] = [ diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/severity_filter.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/severity_filter.tsx new file mode 100644 index 0000000000000..619f4c58d12df --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/severity_filter.tsx @@ -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 { euiLightVars } from '@kbn/ui-theme'; +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiSuperSelect, + EuiSuperSelectOption, +} from '@elastic/eui'; +import * as i18n from './translations'; + +export enum CaseSeverity { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', + CRITICAL = 'critical', +} + +export const severities = { + [CaseSeverity.LOW]: { + color: euiLightVars.euiColorVis0, + label: i18n.SEVERITY_LOW_LABEL, + }, + [CaseSeverity.MEDIUM]: { + color: euiLightVars.euiColorVis5, + label: i18n.SEVERITY_MEDIUM_LABEL, + }, + [CaseSeverity.HIGH]: { + color: euiLightVars.euiColorVis7, + label: i18n.SEVERITY_HIGH_LABEL, + }, + [CaseSeverity.CRITICAL]: { + color: euiLightVars.euiColorVis9, + label: i18n.SEVERITY_CRITICAL_LABEL, + }, +}; + +interface Props { + selectedSeverity: CaseSeverity; + onSeverityChange: (status: CaseSeverity) => void; +} + +export const SeverityFilter: React.FC = ({ selectedSeverity, onSeverityChange }) => { + const caseSeverities = Object.keys(severities) as CaseSeverity[]; + const options: Array> = caseSeverities.map((severity) => { + const severityData = severities[severity]; + return { + value: severity, + inputDisplay: ( + + + {severityData.label} + + + ), + }; + }); + + return ( + + ); +}; +SeverityFilter.displayName = 'SeverityFilter'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/status_filter.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/status_filter.tsx new file mode 100644 index 0000000000000..01569027809f7 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/status_filter.tsx @@ -0,0 +1,73 @@ +/* + * 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 { CaseStatuses } from '@kbn/cases-components'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiSuperSelect, + EuiSuperSelectOption, +} from '@elastic/eui'; + +import * as i18n from './translations'; + +interface Props { + selectedStatus: CaseStatuses; + onStatusChanged: (status: CaseStatuses) => void; +} + +const caseStatuses = [CaseStatuses.open, CaseStatuses['in-progress'], CaseStatuses.closed]; +const statuses = { + [CaseStatuses.open]: { + color: 'primary', + label: i18n.STATUS_OPEN, + }, + [CaseStatuses['in-progress']]: { + color: 'warning', + label: i18n.STATUS_IN_PROGRESS, + }, + [CaseStatuses.closed]: { + color: 'default', + label: i18n.STATUS_CLOSED, + }, +} as const; + +export const StatusFilter: React.FC = ({ selectedStatus, onStatusChanged }) => { + const options: Array> = caseStatuses.map((status) => ({ + value: status, + inputDisplay: ( + + + + + {statuses[status]?.label} + + + + + ), + 'data-test-subj': `case-status-filter-${status}`, + })); + + return ( + + ); +}; + +StatusFilter.displayName = 'StatusFilter'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts index bbe7b09bd4509..cf5b0a37a06dd 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/translations.ts @@ -458,6 +458,27 @@ export const CASE_TAGS_DESC = i18n.translate( } ); +export const CASE_ID_DESC = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.caseIdDesc', + { + defaultMessage: 'Kibana case id', + } +); + +export const CASE_SEVERITY_DESC = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.caseSeverityDesc', + { + defaultMessage: 'Kibana case severity', + } +); + +export const CASE_STATUS_DESC = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.caseStatusDesc', + { + defaultMessage: 'Kibana case status', + } +); + export const CASE_COMMENT_DESC = i18n.translate( 'xpack.stackConnectors.components.casesWebhook.caseCommentDesc', { @@ -482,3 +503,47 @@ export const EXTERNAL_TITLE_DESC = i18n.translate( export const DOC_LINK = i18n.translate('xpack.stackConnectors.components.casesWebhook.docLink', { defaultMessage: 'Configuring Webhook - Case Management connector.', }); + +export const SEVERITY_CRITICAL_LABEL = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.criticalLabel', + { + defaultMessage: 'Critical', + } +); +export const SEVERITY_HIGH_LABEL = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.highLabel', + { + defaultMessage: 'High', + } +); +export const SEVERITY_MEDIUM_LABEL = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.mediumLabel', + { + defaultMessage: 'Medium', + } +); +export const SEVERITY_LOW_LABEL = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.lowLabel', + { + defaultMessage: 'Low', + } +); + +export const STATUS_OPEN = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.status.open', + { + defaultMessage: 'Open', + } +); +export const STATUS_CLOSED = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.status.closed', + { + defaultMessage: 'Closed', + } +); +export const STATUS_IN_PROGRESS = i18n.translate( + 'xpack.stackConnectors.components.casesWebhook.status.inProgress', + { + defaultMessage: 'In progress', + } +); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_params.test.tsx index b5e2cd3d10871..5e7d85a85d247 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_params.test.tsx @@ -31,6 +31,9 @@ const actionParams = { description: 'some description', tags: ['kibana'], externalId: null, + id: '10006', + severity: 'High', + status: 'Open', }, comments: [], }, @@ -65,6 +68,10 @@ describe('WebhookParamsFields renders', () => { expect(wrapper.find('[data-test-subj="descriptionTextArea"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="tagsComboBox"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="tagsComboBox"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="commentsTextArea"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="case-severity-selection"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="case-status-filter"]').length > 0).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').first().prop('disabled')).toEqual( false ); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_params.tsx index dab3f8cc95832..72bd3ea8ea843 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/cases_webhook/webhook_params.tsx @@ -13,6 +13,9 @@ import { TextAreaWithMessageVariables, TextFieldWithMessageVariables, } from '@kbn/triggers-actions-ui-plugin/public'; +import { CaseStatuses } from '@kbn/cases-components'; +import { CaseSeverity, SeverityFilter } from './severity_filter'; +import { StatusFilter } from './status_filter'; import { CasesWebhookActionConnector, CasesWebhookActionParams } from './types'; const CREATE_COMMENT_WARNING_TITLE = i18n.translate( @@ -169,6 +172,52 @@ const WebhookParamsFields: React.FunctionComponent + + editSubActionProperty('status', status)} + /> + + + editSubActionProperty('severity', severity)} + /> + + 0 && + incident.id !== undefined + } + label={i18n.translate('xpack.stackConnectors.components.casesWebhook.idFieldLabel', { + defaultMessage: 'Case ID', + })} + > + + <> { incident: { tags: ['kibana', 'elastic'], description: 'Incident description', + id: '10006', + severity: 'High', + status: 'Open', title: 'Incident title', }, }); @@ -148,6 +151,9 @@ describe('api', () => { incident: { tags: ['kibana', 'elastic'], description: 'Incident description', + id: '10006', + severity: 'High', + status: 'Open', title: 'Incident title', }, }); @@ -162,6 +168,9 @@ describe('api', () => { incidentId: 'incident-3', incident: { description: 'Incident description', + id: '10006', + severity: 'High', + status: 'Open', title: 'Incident title', tags: ['kibana', 'elastic'], }, diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/mock.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/mock.ts index 74627658cde81..e6d39dd0d6fe0 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/mock.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/mock.ts @@ -67,6 +67,9 @@ const executorParams: ExecutorSubActionPushParams = { title: 'Incident title', description: 'Incident description', tags: ['kibana', 'elastic'], + id: '10006', + severity: 'High', + status: 'Open', }, comments: [ { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/schema.ts index 1fb3edca6e4b7..9863b1457628a 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/schema.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/schema.ts @@ -71,6 +71,9 @@ export const ExecutorSubActionPushParamsSchema = schema.object({ incident: schema.object({ title: schema.string(), description: schema.nullable(schema.string()), + id: schema.nullable(schema.string()), + severity: schema.nullable(schema.string()), + status: schema.nullable(schema.string()), externalId: schema.nullable(schema.string()), tags: schema.nullable(schema.arrayOf(schema.string())), }), diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts index f0d2105f37fdc..4598e8471a838 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.test.ts @@ -197,9 +197,9 @@ describe('Cases webhook service', () => { title: 'title', description: 'desc', tags: ['hello', 'world'], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', + id: '10006', + severity: 'High', + status: 'Open', }, }; @@ -302,6 +302,9 @@ describe('Cases webhook service', () => { title: 'title', description: 'desc', tags: ['hello', 'world'], + id: '10006', + severity: 'High', + status: 'Open', }, }; @@ -579,9 +582,9 @@ describe('Cases webhook service', () => { title: 'title', description: 'desc', tags: ['hello', 'world'], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', + id: '10006', + severity: 'High', + status: 'Open', }, }; @@ -596,9 +599,9 @@ describe('Cases webhook service', () => { title: 'title', description: 'desc', tags: ['hello', 'world'], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', + id: '10006', + severity: 'High', + status: 'Open', }, }; @@ -653,9 +656,9 @@ describe('Cases webhook service', () => { title: 'title', description: 'desc', tags: ['hello', 'world'], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', + id: '10006', + severity: 'High', + status: 'Open', }, }; @@ -670,9 +673,9 @@ describe('Cases webhook service', () => { title: 'title', description: 'desc', tags: ['hello', 'world'], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', + id: '10006', + severity: 'High', + status: 'Open', }, }; @@ -733,9 +736,9 @@ describe('Cases webhook service', () => { title: 'title', description: 'desc', tags: ['hello', 'world'], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', + id: '10006', + severity: 'High', + status: 'Open', }, }; const res = await service.createIncident(incident); @@ -751,9 +754,9 @@ describe('Cases webhook service', () => { title: 'title', description: 'desc', tags: ['hello', 'world'], - issueType: '10006', - priority: 'High', - parent: 'RJ-107', + id: '10006', + severity: 'High', + status: 'Open', }, }; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts index 790bdffc84982..dd0b28d321a60 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/cases_webhook/service.ts @@ -118,7 +118,7 @@ export const createExternalService = ( incident, }: CreateIncidentParams): Promise => { try { - const { tags, title, description } = incident; + const { description, id, severity, status: incidentStatus, tags, title } = incident; const normalizedUrl = validateAndNormalizeUrl( `${createIncidentUrl}`, configurationUtilities, @@ -127,9 +127,12 @@ export const createExternalService = ( const json = renderMustacheStringNoEscape( createIncidentJson, stringifyObjValues({ - title, description: description ?? '', + id: id ?? '', + severity: severity ?? '', + status: incidentStatus ?? '', tags: tags ?? [], + title, }) ); @@ -197,13 +200,16 @@ export const createExternalService = ( 'Update case URL' ); - const { tags, title, description } = incident; + const { description, id, severity, status: incidentStatus, tags, title } = incident; const json = renderMustacheStringNoEscape(updateIncidentJson, { ...stringifyObjValues({ - title, description: description ?? '', + id: id ?? '', + severity: severity ?? '', + status: incidentStatus ?? '', tags: tags ?? [], + title, }), external: { system: { diff --git a/x-pack/plugins/stack_connectors/tsconfig.json b/x-pack/plugins/stack_connectors/tsconfig.json index 0009b86c8348c..4e47b703bf433 100644 --- a/x-pack/plugins/stack_connectors/tsconfig.json +++ b/x-pack/plugins/stack_connectors/tsconfig.json @@ -37,6 +37,7 @@ "@kbn/alerting-plugin", "@kbn/securitysolution-ecs", "@kbn/ui-theme", + "@kbn/cases-components", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index d406fb1aa5ffc..db7cac9e55cd3 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -4527,6 +4527,16 @@ } } }, + "global_labels": { + "properties": { + "1d": { + "type": "long", + "_meta": { + "description": "Total number of global labels used for creating aggregation keys for internal metrics computed from indices which received data in the last 24 hours" + } + } + } + }, "max_transaction_groups_per_service": { "properties": { "1d": { @@ -5927,6 +5937,20 @@ } } }, + "global_labels": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long", + "_meta": { + "description": "Execution time in milliseconds for the \"global_labels\" task" + } + } + } + } + } + }, "services": { "properties": { "took": { diff --git a/x-pack/plugins/transform/kibana.jsonc b/x-pack/plugins/transform/kibana.jsonc index 4baea3243370a..ef3577846238a 100644 --- a/x-pack/plugins/transform/kibana.jsonc +++ b/x-pack/plugins/transform/kibana.jsonc @@ -29,6 +29,7 @@ "contentManagement", ], "optionalPlugins": [ + "dataViewEditor", "security", "usageCollection", "spaces", diff --git a/x-pack/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx index 93d74ca706d43..05c59bb66baa8 100644 --- a/x-pack/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -37,6 +37,7 @@ import { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public'; import type { SettingsStart } from '@kbn/core-ui-settings-browser'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import type { GetMlSharedImportsReturnType } from '../shared_imports'; export interface AppDependencies { @@ -45,6 +46,7 @@ export interface AppDependencies { charts: ChartsPluginStart; chrome: ChromeStart; data: DataPublicPluginStart; + dataViewEditor?: DataViewEditorStart; dataViews: DataViewsPublicPluginStart; docLinks: DocLinksStart; fieldFormats: FieldFormatsStart; diff --git a/x-pack/plugins/transform/public/app/mount_management_section.ts b/x-pack/plugins/transform/public/app/mount_management_section.ts index f00596e1326b4..e6aad082acd6e 100644 --- a/x-pack/plugins/transform/public/app/mount_management_section.ts +++ b/x-pack/plugins/transform/public/app/mount_management_section.ts @@ -46,6 +46,7 @@ export async function mountManagementSection( const { data, dataViews, + dataViewEditor, share, spaces, triggersActionsUi, @@ -69,6 +70,7 @@ export async function mountManagementSection( application, chrome, data, + dataViewEditor, dataViews, docLinks, http, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx index 3f2bd7fb70c0c..75731baf92c06 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/search_selection/search_selection.tsx @@ -5,22 +5,49 @@ * 2.0. */ -import { EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; +import { EuiButton, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import React, { type FC } from 'react'; +import React, { type FC, Fragment, useCallback, useEffect, useRef } from 'react'; import { SavedObjectFinder } from '@kbn/saved-objects-finder-plugin/public'; import { useAppDependencies } from '../../../../app_dependencies'; interface SearchSelectionProps { onSearchSelected: (searchId: string, searchType: string) => void; + onCloseModal: () => void; } const fixedPageSize: number = 8; -export const SearchSelection: FC = ({ onSearchSelected }) => { - const { contentManagement, uiSettings } = useAppDependencies(); +export const SearchSelection: FC = ({ onSearchSelected, onCloseModal }) => { + const { contentManagement, uiSettings, dataViewEditor } = useAppDependencies(); + + const canEditDataView = Boolean(dataViewEditor?.userPermissions.editDataView()); + + const closeDataViewEditor = useRef<() => void | undefined>(); + + const createNewDataView = useCallback(() => { + onCloseModal(); + closeDataViewEditor.current = dataViewEditor?.openEditor({ + onSave: async (dataView) => { + if (dataView.id) { + onSearchSelected(dataView.id, 'index-pattern'); + } + }, + + allowAdHocDataView: true, + }); + }, [dataViewEditor, onCloseModal, onSearchSelected]); + + useEffect(function cleanUpFlyout() { + return () => { + // Close the editor when unmounting + if (closeDataViewEditor.current) { + closeDataViewEditor.current(); + } + }; + }, []); return ( <> @@ -72,7 +99,24 @@ export const SearchSelection: FC = ({ onSearchSelected }) ]} fixedPageSize={fixedPageSize} services={{ contentClient: contentManagement.client, uiSettings }} - /> + > + {canEditDataView ? ( + + + + ) : ( + + )} + ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 7e1f2ccd3bc09..b88d7fc88a7ce 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -330,7 +330,7 @@ export const TransformManagement: FC = () => { className="transformCreateTransformSearchDialog" data-test-subj="transformSelectSourceModal" > - + )} diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index 03b99ab85ad27..098b55765a889 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -24,12 +24,14 @@ import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-manag import type { ContentManagementPublicStart } from '@kbn/content-management-plugin/public'; import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public'; import type { PluginInitializerContext } from '@kbn/core/public'; +import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import { registerFeature } from './register_feature'; import { getTransformHealthRuleType } from './alerting'; export interface PluginsDependencies { charts: ChartsPluginStart; data: DataPublicPluginStart; + dataViewEditor?: DataViewEditorStart; unifiedSearch: UnifiedSearchPublicPluginStart; dataViews: DataViewsPublicPluginStart; management: ManagementSetup; diff --git a/x-pack/plugins/transform/tsconfig.json b/x-pack/plugins/transform/tsconfig.json index 7b41f101c15c1..d58a875eec9c5 100644 --- a/x-pack/plugins/transform/tsconfig.json +++ b/x-pack/plugins/transform/tsconfig.json @@ -68,7 +68,8 @@ "@kbn/ebt-tools", "@kbn/content-management-plugin", "@kbn/react-kibana-mount", - "@kbn/core-plugins-server" + "@kbn/core-plugins-server", + "@kbn/data-view-editor-plugin" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index ed5d0042c8030..61e5306384a53 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -10776,8 +10776,6 @@ "xpack.cases.getCurrentUser.unknownUser": "Inconnu", "xpack.cases.header.badge.betaDesc": "Cette fonctionnalité est actuellement en version bêta. Si vous rencontrez des bugs ou si vous souhaitez apporter des commentaires, ouvrez un ticket de problème ou visitez notre forum de discussion.", "xpack.cases.header.badge.betaLabel": "Bêta", - "xpack.cases.header.badge.experimentalDesc": "Cette fonctionnalité est en version d'évaluation technique et pourra être modifiée ou retirée complètement dans une future version. Elastic s'efforcera au maximum de corriger tout problème, mais les fonctionnalités en version d'évaluation technique ne sont pas soumises aux accords de niveau de service d'assistance des fonctionnalités officielles en disponibilité générale.", - "xpack.cases.header.badge.experimentalLabel": "Version d'évaluation technique", "xpack.cases.header.editableTitle.cancel": "Annuler", "xpack.cases.header.editableTitle.save": "Enregistrer", "xpack.cases.markdownEditor.plugins.lens.addVisualizationModalTitle": "Ajouter une visualisation", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 07f770568b038..e5dcbb8b17af5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10791,8 +10791,6 @@ "xpack.cases.getCurrentUser.unknownUser": "不明", "xpack.cases.header.badge.betaDesc": "現在、この機能はベータです。不具合を見つけた場合やご意見がある場合、サポートに問い合わせるか、またはディスカッションフォーラムにご報告ください。", "xpack.cases.header.badge.betaLabel": "ベータ", - "xpack.cases.header.badge.experimentalDesc": "この機能はテクニカルプレビュー中であり、将来のリリースでは変更されたり完全に削除されたりする場合があります。Elasticは最善の努力を講じてすべての問題の修正に努めますが、テクニカルプレビュー中の機能には正式なGA機能のサポートSLAが適用されません。", - "xpack.cases.header.badge.experimentalLabel": "テクニカルプレビュー", "xpack.cases.header.editableTitle.cancel": "キャンセル", "xpack.cases.header.editableTitle.save": "保存", "xpack.cases.markdownEditor.plugins.lens.addVisualizationModalTitle": "ビジュアライゼーションを追加", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 4369612f97b4a..fc12edb97a8e5 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10791,8 +10791,6 @@ "xpack.cases.getCurrentUser.unknownUser": "未知", "xpack.cases.header.badge.betaDesc": "此功能当前为公测版。如果遇到任何错误或有任何反馈,请报告问题或访问我们的论坛。", "xpack.cases.header.badge.betaLabel": "公测版", - "xpack.cases.header.badge.experimentalDesc": "此功能处于技术预览状态,在未来版本中可能会更改或完全移除。Elastic 将尽最大努力来修复任何问题,但处于技术预览状态的功能不受正式 GA 功能支持 SLA 的约束。", - "xpack.cases.header.badge.experimentalLabel": "技术预览", "xpack.cases.header.editableTitle.cancel": "取消", "xpack.cases.header.editableTitle.save": "保存", "xpack.cases.markdownEditor.plugins.lens.addVisualizationModalTitle": "添加可视化", diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts index 6a17340094e80..dfab65332a9e6 100644 --- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts +++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/documents_count_fired.ts @@ -163,6 +163,8 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.hits.hits[0]._source).property('event.kind', 'signal'); expect(resp.hits.hits[0]._source).property('event.action', 'open'); + expect(resp.hits.hits[0]._source).not.have.property('kibana.alert.group'); + expect(resp.hits.hits[0]._source) .property('kibana.alert.rule.parameters') .eql({ diff --git a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts index da18b429c45c0..1129ad40b5203 100644 --- a/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts +++ b/x-pack/test/alerting_api_integration/observability/custom_threshold_rule/group_by_fired.ts @@ -111,7 +111,7 @@ export default function ({ getService }: FtrProviderContext) { }, index: DATA_VIEW_ID, }, - groupBy: ['host.name'], + groupBy: ['host.name', 'container.id'], }, actions: [ { @@ -125,6 +125,7 @@ export default function ({ getService }: FtrProviderContext) { reason: '{{context.reason}}', value: '{{context.value}}', host: '{{context.host}}', + group: '{{context.group}}', }, ], }, @@ -180,7 +181,10 @@ export default function ({ getService }: FtrProviderContext) { 'custom_threshold.fired' ); expect(resp.hits.hits[0]._source).property('tags').contain('observability'); - expect(resp.hits.hits[0]._source).property('kibana.alert.instance.id', 'host-0'); + expect(resp.hits.hits[0]._source).property( + 'kibana.alert.instance.id', + 'host-0,container-0' + ); expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open'); expect(resp.hits.hits[0]._source).property('event.kind', 'signal'); expect(resp.hits.hits[0]._source).property('event.action', 'open'); @@ -193,6 +197,19 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.hits.hits[0]._source).property('container.name', 'container-name'); expect(resp.hits.hits[0]._source).not.property('container.cpu'); + expect(resp.hits.hits[0]._source) + .property('kibana.alert.group') + .eql([ + { + field: 'host.name', + value: 'host-0', + }, + { + field: 'container.id', + value: 'container-0', + }, + ]); + expect(resp.hits.hits[0]._source) .property('kibana.alert.rule.parameters') .eql({ @@ -209,7 +226,7 @@ export default function ({ getService }: FtrProviderContext) { alertOnNoData: true, alertOnGroupDisappear: true, searchConfiguration: { index: 'data-view-id', query: { query: '', language: 'kuery' } }, - groupBy: ['host.name'], + groupBy: ['host.name', 'container.id'], }); }); @@ -221,6 +238,7 @@ export default function ({ getService }: FtrProviderContext) { reason: string; value: string; host: string; + group: string; }>({ esClient, indexName: ALERT_ACTION_INDEX, @@ -231,12 +249,15 @@ export default function ({ getService }: FtrProviderContext) { `https://localhost:5601/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)` ); expect(resp.hits.hits[0]._source?.reason).eql( - 'Custom equation is 0.8 in the last 1 min for host-0. Alert when >= 0.2.' + 'Custom equation is 0.8 in the last 1 min for host-0,container-0. Alert when >= 0.2.' ); expect(resp.hits.hits[0]._source?.value).eql('0.8'); expect(resp.hits.hits[0]._source?.host).eql( '{"name":"host-0","mac":["00-00-5E-00-53-23","00-00-5E-00-53-24"]}' ); + expect(resp.hits.hits[0]._source?.group).eql( + '{"field":"host.name","value":"host-0"},{"field":"container.id","value":"container-0"}' + ); }); }); }); diff --git a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts index b0f21f8dc386f..7b1b6d1b76c6c 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts @@ -6,10 +6,14 @@ */ import { CASE_CONFIGURE_URL } from '@kbn/cases-plugin/common/constants'; -import { ConfigurationRequest } from '@kbn/cases-plugin/common/types/api'; +import { + ConfigurationPatchRequest, + ConfigurationRequest, +} from '@kbn/cases-plugin/common/types/api'; import { CaseConnector, Configuration, + Configurations, ConnectorTypes, } from '@kbn/cases-plugin/common/types/domain'; import type SuperTest from 'supertest'; @@ -38,6 +42,7 @@ export const getConfigurationRequest = ({ } as CaseConnector, closure_type: 'close-by-user', owner: 'securitySolutionFixture', + customFields: [], ...overrides, }; }; @@ -49,6 +54,7 @@ export const getConfigurationOutput = (update = false, overwrite = {}): Partial< mappings: [], created_by: { email: null, full_name: null, username: 'elastic' }, updated_by: update ? { email: null, full_name: null, username: 'elastic' } : null, + customFields: [], ...overwrite, }; }; @@ -72,3 +78,45 @@ export const createConfiguration = async ( return configuration; }; + +export const getConfiguration = async ({ + supertest, + query = { owner: 'securitySolutionFixture' }, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + query?: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: configuration } = await supertest + .get(`${getSpaceUrlPrefix(auth.space)}${CASE_CONFIGURE_URL}`) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .query(query) + .expect(expectedHttpCode); + + return configuration; +}; + +export const updateConfiguration = async ( + supertest: SuperTest.SuperTest, + id: string, + req: ConfigurationPatchRequest, + expectedHttpCode: number = 200, + auth: { user: User; space: string | null } | null = { user: superUser, space: null }, + headers: Record = {} +): Promise => { + const apiCall = supertest.patch(`${getSpaceUrlPrefix(auth?.space)}${CASE_CONFIGURE_URL}/${id}`); + + setupAuth({ apiCall, headers, auth }); + + const { body: configuration } = await apiCall + .set('kbn-xsrf', 'true') + .set(headers) + .send(req) + .expect(expectedHttpCode); + + return configuration; +}; diff --git a/x-pack/test/cases_api_integration/common/lib/api/index.ts b/x-pack/test/cases_api_integration/common/lib/api/index.ts index 6a539457849e6..361a00eadb518 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/index.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/index.ts @@ -18,7 +18,6 @@ import { CASES_URL, CASE_COMMENT_SAVED_OBJECT, CASE_CONFIGURE_SAVED_OBJECT, - CASE_CONFIGURE_URL, CASE_REPORTERS_URL, CASE_SAVED_OBJECT, CASE_STATUS_URL, @@ -30,13 +29,10 @@ import { import { CaseMetricsFeature } from '@kbn/cases-plugin/common'; import type { SingleCaseMetricsResponse, CasesMetricsResponse } from '@kbn/cases-plugin/common'; import { SignalHit } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/types'; -import { ActionResult } from '@kbn/actions-plugin/server/types'; import { CasePersistedAttributes } from '@kbn/cases-plugin/server/common/types/case'; import type { SavedObjectsRawDocSource } from '@kbn/core/server'; import type { ConfigurationPersistedAttributes } from '@kbn/cases-plugin/server/common/types/configure'; import { - Configurations, - Configuration, ConnectorMappingsAttributes, Case, Cases, @@ -49,7 +45,6 @@ import { CasesFindResponse, CasesPatchRequest, CasesStatusResponse, - ConfigurationPatchRequest, GetRelatedCasesByAlertResponse, } from '@kbn/cases-plugin/common/types/api'; import { User } from '../authentication/types'; @@ -441,52 +436,6 @@ export const updateCase = async ({ return cases; }; -export const getConfiguration = async ({ - supertest, - query = { owner: 'securitySolutionFixture' }, - expectedHttpCode = 200, - auth = { user: superUser, space: null }, -}: { - supertest: SuperTest.SuperTest; - query?: Record; - expectedHttpCode?: number; - auth?: { user: User; space: string | null }; -}): Promise => { - const { body: configuration } = await supertest - .get(`${getSpaceUrlPrefix(auth.space)}${CASE_CONFIGURE_URL}`) - .auth(auth.user.username, auth.user.password) - .set('kbn-xsrf', 'true') - .query(query) - .expect(expectedHttpCode); - - return configuration; -}; - -export type CreateConnectorResponse = Omit & { - connector_type_id: string; -}; - -export const updateConfiguration = async ( - supertest: SuperTest.SuperTest, - id: string, - req: ConfigurationPatchRequest, - expectedHttpCode: number = 200, - auth: { user: User; space: string | null } | null = { user: superUser, space: null }, - headers: Record = {} -): Promise => { - const apiCall = supertest.patch(`${getSpaceUrlPrefix(auth?.space)}${CASE_CONFIGURE_URL}/${id}`); - - setupAuth({ apiCall, headers, auth }); - - const { body: configuration } = await apiCall - .set('kbn-xsrf', 'true') - .set(headers) - .send(req) - .expect(expectedHttpCode); - - return configuration; -}; - export const getAllCasesStatuses = async ({ supertest, expectedHttpCode = 200, diff --git a/x-pack/test/cases_api_integration/common/lib/mock.ts b/x-pack/test/cases_api_integration/common/lib/mock.ts index b18ea72e02809..969df10a53d1d 100644 --- a/x-pack/test/cases_api_integration/common/lib/mock.ts +++ b/x-pack/test/cases_api_integration/common/lib/mock.ts @@ -174,6 +174,7 @@ export const postCaseResp = ( status: CaseStatuses.open, updated_by: null, category: null, + customFields: [], }); interface CommentRequestWithID { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/import_export.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/import_export.ts index 3f5d757124e4e..060b6463c0451 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/import_export.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/import_export.ts @@ -79,7 +79,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(objects).to.have.length(4); - const expectedCaseRequest = { ...caseRequest, category: null }; // added default value + const expectedCaseRequest = { ...caseRequest, category: null, customFields: [] }; // added default value expectExportToHaveCaseSavedObject(objects, expectedCaseRequest); expectExportToHaveUserActions(objects, expectedCaseRequest); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts index 0aa6d673ec55c..576fc11cdf51b 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/patch_cases.ts @@ -15,6 +15,7 @@ import { CaseSeverity, CaseStatuses, ConnectorTypes, + CustomFieldTypes, } from '@kbn/cases-plugin/common/types/domain'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { @@ -37,6 +38,8 @@ import { calculateDuration, getCaseUserActions, removeServerGeneratedPropertiesFromUserAction, + createConfiguration, + getConfigurationRequest, } from '../../../../common/lib/api'; import { createSignalsIndex, @@ -74,6 +77,21 @@ export default ({ getService }: FtrProviderContext): void => { describe('happy path', () => { it('should patch a case', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); const postedCase = await createCase(supertest, postCaseReq); const patchedCases = await updateCase({ supertest, @@ -91,12 +109,19 @@ export default ({ getService }: FtrProviderContext): void => { const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); expect(data).to.eql({ ...postCaseResp(), + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], title: 'new title', updated_by: defaultUser, }); }); - it('should closes the case correctly', async () => { + it('should close the case correctly', async () => { const postedCase = await createCase(supertest, postCaseReq); const patchedCases = await updateCase({ supertest, @@ -288,6 +313,136 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + it('should patch a case with customFields', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'test_custom_field_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + + const postedCase = await createCase(supertest, { + ...postCaseReq, + customFields: [ + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }); + const patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + ], + }, + }); + + expect(patchedCases[0].customFields).to.eql([ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]); + }); + + it('should fill out missing optional custom fields', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'test_custom_field_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + + const postedCase = await createCase(supertest, { + ...postCaseReq, + customFields: [ + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }); + + const patchedCases = await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }, + ], + }, + }); + + expect(patchedCases[0].customFields).to.eql([ + { key: 'test_custom_field_2', type: 'toggle', value: true }, + { key: 'test_custom_field_1', type: 'text', value: null }, + ]); + }); + describe('duration', () => { it('updates the duration correctly when the case closes', async () => { const postedCase = await createCase(supertest, postCaseReq); @@ -817,6 +972,158 @@ export default ({ getService }: FtrProviderContext): void => { }); }); }); + + describe('customFields', async () => { + it('400s when trying to patch with duplicated custom field keys', async () => { + const postedCase = await createCase(supertest, postCaseReq); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('400s when trying to patch with a custom field key that does not exist', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + const postedCase = await createCase(supertest, postCaseReq); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'key_does_not_exist', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('400s when trying to patch a case with a missing required custom field', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: true, + }, + ], + }, + }) + ); + + const postedCase = await createCase(supertest, { + ...postCaseReq, + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TEXT, + value: ['hello'], + }, + ], + }); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [], + }, + ], + }, + expectedHttpCode: 400, + }); + }); + + it('400s when trying to patch a case with a custom field with the wrong type', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + const postedCase = await createCase(supertest, postCaseReq); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: postedCase.id, + version: postedCase.version, + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TOGGLE, + value: false, + }, + ], + }, + ], + }, + expectedHttpCode: 400, + }); + }); + }); }); describe('alerts', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/post_case.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/post_case.ts index c32103ea51fef..6d152008e9074 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/post_case.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/post_case.ts @@ -8,15 +8,21 @@ import expect from '@kbn/expect'; import { CASES_URL } from '@kbn/cases-plugin/common/constants'; -import { CaseStatuses, CaseSeverity } from '@kbn/cases-plugin/common/types/domain'; +import { + CaseStatuses, + CaseSeverity, + CustomFieldTypes, +} from '@kbn/cases-plugin/common/types/domain'; import { ConnectorJiraTypeFields, ConnectorTypes } from '@kbn/cases-plugin/common/types/domain'; import { getPostCaseRequest, postCaseResp, defaultUser } from '../../../../common/lib/mock'; import { - deleteCasesByESQuery, + deleteAllCaseItems, createCase, removeServerGeneratedPropertiesFromCase, getCaseUserActions, removeServerGeneratedPropertiesFromUserAction, + createConfiguration, + getConfigurationRequest, } from '../../../../common/lib/api'; import { secOnly, @@ -37,7 +43,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('post_case', () => { afterEach(async () => { - await deleteCasesByESQuery(es); + await deleteAllCaseItems(es); }); describe('happy path', () => { @@ -155,6 +161,7 @@ export default ({ getService }: FtrProviderContext): void => { severity: CaseSeverity.LOW, assignees: [], category: null, + customFields: [], }, }); }); @@ -165,6 +172,103 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(postCaseResp()); }); + + it('should post a case with customFields', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'valid_key_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'valid_key_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + + const res = await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'valid_key_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'valid_key_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }) + ); + + expect(res.customFields).to.eql([ + { + key: 'valid_key_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'valid_key_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ]); + }); + + it('should fill out missing custom fields', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'valid_key_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'valid_key_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + + const res = await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'valid_key_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }) + ); + + expect(res.customFields).to.eql([ + { key: 'valid_key_2', type: 'toggle', value: true }, + { key: 'valid_key_1', type: 'text', value: null }, + ]); + }); }); describe('unhappy path', () => { @@ -324,6 +428,128 @@ export default ({ getService }: FtrProviderContext): void => { ); }); }); + + describe('customFields', async () => { + it('400s when trying to patch with duplicated custom field keys', async () => { + await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'duplicated_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }), + 400 + ); + }); + + it('400s when trying to create case with customField key that does not exist', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'invalid_key', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + ], + }), + 400 + ); + }); + + it('400s when trying to create case with a required custom field', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'toggle_custom_field', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: true, + }, + ], + }, + }) + ); + await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }), + 400 + ); + }); + + it('400s when trying to create case with a custom field with the wrong type', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field', + label: 'text', + type: CustomFieldTypes.TEXT, + required: true, + }, + ], + }, + }) + ); + await createCase( + supertest, + getPostCaseRequest({ + customFields: [ + { + key: 'test_custom_field', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + ], + }), + 400 + ); + }); + }); }); describe('rbac', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/resolve_case.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/resolve_case.ts index 042b9856fc45c..7679a3fbda9a7 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/resolve_case.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/resolve_case.ts @@ -18,7 +18,7 @@ import { getPostCaseRequest, } from '../../../../common/lib/mock'; import { - deleteCasesByESQuery, + deleteAllCaseItems, createCase, resolveCase, createComment, @@ -46,7 +46,7 @@ export default ({ getService }: FtrProviderContext): void => { describe('resolve_case', () => { afterEach(async () => { - await deleteCasesByESQuery(es); + await deleteAllCaseItems(es); }); it('should resolve a case with no comments', async () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts index 94e414fa23f45..87c65ba8a47c9 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/get_configure.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -47,12 +48,32 @@ export default ({ getService }: FtrProviderContext): void => { it('should return a configuration', async () => { await createConfiguration(supertest); - const configuration = await getConfiguration({ supertest }); + const configuration = await getConfiguration({ supertest }); const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql(getConfigurationOutput()); }); + it('should return a configuration with customFields', async () => { + const customFields = { + customFields: [ + { key: 'hello', label: 'text', type: CustomFieldTypes.TEXT, required: false }, + { key: 'goodbye', label: 'toggle', type: CustomFieldTypes.TOGGLE, required: true }, + ], + }; + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: customFields, + }) + ); + const configuration = await getConfiguration({ supertest }); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration[0]); + expect(data).to.eql(getConfigurationOutput(false, customFields)); + }); + it('should get a single configuration', async () => { await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); await createConfiguration(supertest); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts index d94ff433550e7..407529738b488 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts @@ -6,7 +6,7 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain'; +import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -54,6 +54,31 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql({ ...getConfigurationOutput(true), closure_type: 'close-by-pushing' }); }); + it('should patch a configuration with customFields', async () => { + const customFields = [ + { + key: 'text_field', + label: '#1', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'toggle_field', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ]; + const configuration = await createConfiguration(supertest); + const newConfiguration = await updateConfiguration(supertest, configuration.id, { + version: configuration.version, + customFields, + }); + + const data = removeServerGeneratedPropertiesFromSavedObject(newConfiguration); + expect(data).to.eql({ ...getConfigurationOutput(true), customFields }); + }); + it('should update mapping when changing connector', async () => { const configuration = await createConfiguration(supertest); await updateConfiguration(supertest, configuration.id, { @@ -87,80 +112,149 @@ export default ({ getService }: FtrProviderContext): void => { ]); }); - it('should not patch a configuration with unsupported connector type', async () => { - const configuration = await createConfiguration(supertest); - await updateConfiguration( - supertest, - configuration.id, - // @ts-expect-error - getConfigurationRequest({ type: '.unsupported' }), - 400 - ); - }); + describe('validation', () => { + it('should not patch a configuration with unsupported connector type', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + getConfigurationRequest({ type: '.unsupported' }), + 400 + ); + }); - it('should not patch a configuration with unsupported connector fields', async () => { - const configuration = await createConfiguration(supertest); - await updateConfiguration( - supertest, - configuration.id, - // @ts-expect-error - getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), - 400 - ); - }); + it('should not patch a configuration with unsupported connector fields', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), + 400 + ); + }); + + it('should handle patch request when there is no configuration', async () => { + const error = await updateConfiguration( + supertest, + 'not-exist', + { closure_type: 'close-by-pushing', version: 'no-version' }, + 404 + ); - it('should handle patch request when there is no configuration', async () => { - const error = await updateConfiguration( - supertest, - 'not-exist', - { closure_type: 'close-by-pushing', version: 'no-version' }, - 404 - ); - - expect(error).to.eql({ - error: 'Not Found', - message: 'Saved object [cases-configure/not-exist] not found', - statusCode: 404, + expect(error).to.eql({ + error: 'Not Found', + message: 'Saved object [cases-configure/not-exist] not found', + statusCode: 404, + }); }); - }); - it('should handle patch request when versions are different', async () => { - const configuration = await createConfiguration(supertest); - const error = await updateConfiguration( - supertest, - configuration.id, - { closure_type: 'close-by-pushing', version: 'no-version' }, - 409 - ); - - expect(error).to.eql({ - error: 'Conflict', - message: - 'This configuration has been updated. Please refresh before saving additional updates.', - statusCode: 409, + it('should handle patch request when versions are different', async () => { + const configuration = await createConfiguration(supertest); + const error = await updateConfiguration( + supertest, + configuration.id, + { closure_type: 'close-by-pushing', version: 'no-version' }, + 409 + ); + + expect(error).to.eql({ + error: 'Conflict', + message: + 'This configuration has been updated. Please refresh before saving additional updates.', + statusCode: 409, + }); }); - }); - it('should not allow to change the owner of the configuration', async () => { - const configuration = await createConfiguration(supertest); - await updateConfiguration( - supertest, - configuration.id, - // @ts-expect-error - { owner: 'observabilityFixture', version: configuration.version }, - 400 - ); - }); + it('should not allow to change the owner of the configuration', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + { owner: 'observabilityFixture', version: configuration.version }, + 400 + ); + }); - it('should not allow excess attributes', async () => { - const configuration = await createConfiguration(supertest); - await updateConfiguration( - supertest, - configuration.id, - // @ts-expect-error - { notExist: 'not-exist', version: configuration.version }, - 400 - ); + it('should not allow excess attributes', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + // @ts-expect-error + { notExist: 'not-exist', version: configuration.version }, + 400 + ); + }); + + it('should not allow patching the type of a custom field', async () => { + const configuration = await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'wrong_type_key', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + customFields: [ + { + key: 'wrong_type_key', + label: '#1', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }, + 400 + ); + }); + + it('should not patch a configuration with duplicated custom field keys', async () => { + const configuration = await createConfiguration(supertest); + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + customFields: [ + { + key: 'triplicated_key', + label: '#1', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'triplicated_key', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + { + key: 'triplicated_key', + label: '#2', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + ], + }, + 400 + ); + }); }); describe('rbac', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts index 0b152d5cd9d95..02721521a2e4f 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/post_configure.ts @@ -6,7 +6,8 @@ */ import expect from '@kbn/expect'; -import { ConnectorTypes } from '@kbn/cases-plugin/common/types/domain'; +import { ConnectorTypes, CustomFieldTypes } from '@kbn/cases-plugin/common/types/domain'; +import { MAX_CUSTOM_FIELD_LABEL_LENGTH } from '@kbn/cases-plugin/common/constants'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { ObjectRemover as ActionsRemover } from '../../../../../alerting_api_integration/common/lib'; @@ -52,6 +53,32 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql(getConfigurationOutput()); }); + it('should create a configuration with no customFields', async () => { + const { customFields, ...configurationRequest } = getConfigurationRequest(); + const configuration = await createConfiguration(supertest, configurationRequest); + + expect(configuration.customFields).to.eql([]); + }); + + it('should create a configuration with customFields', async () => { + const customFields = { + customFields: [ + { key: 'hello', label: 'text', type: CustomFieldTypes.TEXT, required: false }, + { key: 'goodbye', label: 'toggle', type: CustomFieldTypes.TOGGLE, required: true }, + ], + }; + + const configuration = await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: customFields, + }) + ); + + const data = removeServerGeneratedPropertiesFromSavedObject(configuration); + expect(data).to.eql(getConfigurationOutput(false, customFields)); + }); + it('should keep only the latest configuration', async () => { await createConfiguration(supertest, getConfigurationRequest({ id: 'connector-2' })); await createConfiguration(supertest); @@ -194,126 +221,176 @@ export default ({ getService }: FtrProviderContext): void => { ]); }); - it('should not create a configuration when missing connector.id', async () => { - await createConfiguration( - supertest, - { - // @ts-expect-error - connector: { - name: 'Connector', - type: ConnectorTypes.none, - fields: null, + describe('validation', () => { + it('should not create a configuration when missing connector.id', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + name: 'Connector', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when missing connector.name', async () => { - await createConfiguration( - supertest, - { - // @ts-expect-error - connector: { - id: 'test-id', - type: ConnectorTypes.none, - fields: null, + it('should not create a configuration when missing connector.name', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + id: 'test-id', + type: ConnectorTypes.none, + fields: null, + }, + closure_type: 'close-by-user', }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when missing connector.type', async () => { - await createConfiguration( - supertest, - { + it('should not create a configuration when missing connector.type', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + id: 'test-id', + name: 'Connector', + fields: null, + }, + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration when missing connector.fields', async () => { + await createConfiguration( + supertest, + { + // @ts-expect-error + connector: { + id: 'test-id', + type: ConnectorTypes.none, + name: 'Connector', + }, + closure_type: 'close-by-user', + }, + 400 + ); + }); + + it('should not create a configuration when when missing closure_type', async () => { + await createConfiguration( + supertest, // @ts-expect-error - connector: { - id: 'test-id', - name: 'Connector', - fields: null, + { + connector: { + id: 'test-id', + type: ConnectorTypes.none, + name: 'Connector', + fields: null, + }, }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when missing connector.fields', async () => { - await createConfiguration( - supertest, - { + it('should not create a configuration when missing connector', async () => { + await createConfiguration( + supertest, // @ts-expect-error - connector: { - id: 'test-id', - type: ConnectorTypes.none, - name: 'Connector', + { + closure_type: 'close-by-user', }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when when missing closure_type', async () => { - await createConfiguration( - supertest, - // @ts-expect-error - { - connector: { - id: 'test-id', - type: ConnectorTypes.none, - name: 'Connector', - fields: null, + it('should not create a configuration when fields are not null', async () => { + await createConfiguration( + supertest, + { + connector: { + id: 'test-id', + type: ConnectorTypes.none, + name: 'Connector', + // @ts-expect-error + fields: {}, + }, + closure_type: 'close-by-user', }, - }, - 400 - ); - }); + 400 + ); + }); - it('should not create a configuration when missing connector', async () => { - await createConfiguration( - supertest, - // @ts-expect-error - { - closure_type: 'close-by-user', - }, - 400 - ); - }); + it('should not create a configuration with unsupported connector type', async () => { + await createConfiguration( + supertest, + // @ts-expect-error + getConfigurationRequest({ type: '.unsupported' }), + 400 + ); + }); - it('should not create a configuration when fields are not null', async () => { - await createConfiguration( - supertest, - { - connector: { - id: 'test-id', - type: ConnectorTypes.none, - name: 'Connector', - // @ts-expect-error - fields: {}, - }, - closure_type: 'close-by-user', - }, - 400 - ); - }); + it('should not create a configuration with unsupported connector fields', async () => { + await createConfiguration( + supertest, + // @ts-expect-error + getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), + 400 + ); + }); - it('should not create a configuration with unsupported connector type', async () => { - // @ts-expect-error - await createConfiguration(supertest, getConfigurationRequest({ type: '.unsupported' }), 400); - }); + it('should not create a configuration when customField label is too long', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'hello', + label: '#'.repeat(MAX_CUSTOM_FIELD_LABEL_LENGTH + 1), + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }), + 400 + ); + }); - it('should not create a configuration with unsupported connector fields', async () => { - await createConfiguration( - supertest, - // @ts-expect-error - getConfigurationRequest({ type: '.jira', fields: { unsupported: 'value' } }), - 400 - ); + it('should not create a configuration with duplicated keys', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'duplicated_key', + label: '#1', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'duplicated_key', + label: '#2', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }), + 400 + ); + }); }); describe('rbac', () => { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts index 4679a03b4d8af..f5a95fe33d917 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/user_actions/get_all_user_actions.ts @@ -15,6 +15,8 @@ import { AttachmentType, CreateCaseUserAction, ConnectorTypes, + CustomFieldTypes, + CaseCustomFields, } from '@kbn/cases-plugin/common/types/domain'; import { getCaseUserActionUrl } from '@kbn/cases-plugin/common/api'; import { FtrProviderContext } from '../../../../common/ftr_provider_context'; @@ -30,6 +32,8 @@ import { deleteComment, extractWarningValueFromWarningHeader, getCaseUserActions, + createConfiguration, + getConfigurationRequest, } from '../../../../common/lib/api'; import { globalRead, @@ -84,6 +88,10 @@ export default ({ getService }: FtrProviderContext): void => { expect(createCaseUserAction.payload.settings).to.eql(postCaseReq.settings); expect(createCaseUserAction.payload.owner).to.eql(postCaseReq.owner); expect(createCaseUserAction.payload.connector).to.eql(postCaseReq.connector); + expect(createCaseUserAction.payload.assignees).to.eql(postCaseReq.assignees); + expect(createCaseUserAction.payload.severity).to.eql(postCaseReq.severity); + expect(createCaseUserAction.payload.category).to.eql(null); + expect(createCaseUserAction.payload.customFields).to.eql([]); }); it('deletes all user actions when a case is deleted', async () => { @@ -252,12 +260,12 @@ export default ({ getService }: FtrProviderContext): void => { }); const userActions = await getCaseUserActions({ supertest, caseID: theCase.id }); - const titleUserAction = userActions[1]; + const descUserAction = userActions[1]; expect(userActions.length).to.eql(2); - expect(titleUserAction.type).to.eql('description'); - expect(titleUserAction.action).to.eql('update'); - expect(titleUserAction.payload).to.eql({ description: newDesc }); + expect(descUserAction.type).to.eql('description'); + expect(descUserAction.action).to.eql('update'); + expect(descUserAction.payload).to.eql({ description: newDesc }); }); it('creates a create comment user action', async () => { @@ -349,6 +357,111 @@ export default ({ getService }: FtrProviderContext): void => { }); }); + it('creates user actions for custom fields correctly', async () => { + await createConfiguration( + supertest, + getConfigurationRequest({ + overrides: { + customFields: [ + { + key: 'test_custom_field_1', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + { + key: 'test_custom_field_2', + label: 'toggle', + type: CustomFieldTypes.TOGGLE, + required: false, + }, + { + key: 'test_custom_field_3', + label: 'text', + type: CustomFieldTypes.TEXT, + required: false, + }, + ], + }, + }) + ); + + const customFields: CaseCustomFields = [ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value'], + }, + { + key: 'test_custom_field_2', + type: CustomFieldTypes.TOGGLE, + value: true, + }, + { + key: 'test_custom_field_3', + type: CustomFieldTypes.TEXT, + value: ['this is a text field value 3'], + }, + ]; + + const theCase = await createCase(supertest, { + ...postCaseReq, + customFields: [customFields[0], customFields[2]], + }); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: theCase.id, + version: theCase.version, + customFields: [ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['new value'], + }, + ], + }, + ], + }, + }); + + const userActions = await getCaseUserActions({ supertest, caseID: theCase.id }); + expect(userActions.length).to.eql(3); + + const createCaseUserAction = userActions[0] as unknown as CreateCaseUserAction; + const updateCustomFieldCaseUserAction = userActions[1]; + const updateCustomFieldCaseUserAction2 = userActions[2]; + + expect(createCaseUserAction.payload.customFields).to.eql([customFields[0], customFields[2]]); + + expect(updateCustomFieldCaseUserAction.type).to.eql('customFields'); + expect(updateCustomFieldCaseUserAction.action).to.eql('update'); + expect(updateCustomFieldCaseUserAction.payload).to.eql({ + customFields: [ + { + key: 'test_custom_field_1', + type: CustomFieldTypes.TEXT, + value: ['new value'], + }, + ], + }); + + expect(updateCustomFieldCaseUserAction2.type).to.eql('customFields'); + expect(updateCustomFieldCaseUserAction2.action).to.eql('update'); + expect(updateCustomFieldCaseUserAction2.payload).to.eql({ + customFields: [ + { + key: 'test_custom_field_3', + type: CustomFieldTypes.TEXT, + value: null, + }, + ], + }); + }); + describe('rbac', () => { const supertestWithoutAuth = getService('supertestWithoutAuth'); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/timestamps.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/timestamps.ts index 9b56ead0d91a6..ef27b7c7ff4bd 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/timestamps.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/timestamps.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import { orderBy } from 'lodash'; -import { RuleExecutionStatus } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; import { EqlRuleCreateProps, QueryRuleCreateProps, @@ -261,7 +261,7 @@ export default ({ getService }: FtrProviderContext) => { log, es, createdRule, - RuleExecutionStatus['partial failure'] + RuleExecutionStatusEnum['partial failure'] ); expect(signalsOpen.hits.hits.length).eql(0); }); @@ -340,7 +340,7 @@ export default ({ getService }: FtrProviderContext) => { log, es, createdRule, - RuleExecutionStatus['partial failure'] + RuleExecutionStatusEnum['partial failure'] ); expect(signalsOpen.hits.hits.length).eql(0); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts index 81af34c6c34ac..b2033fed23ed4 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/query.ts @@ -28,7 +28,7 @@ import { QueryRuleCreateProps, AlertSuppressionMissingFieldsStrategy, } from '@kbn/security-solution-plugin/common/api/detection_engine'; -import { RuleExecutionStatus } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; import { Ancestor } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/types'; import { ALERT_ANCESTORS, @@ -797,7 +797,7 @@ export default ({ getService }: FtrProviderContext) => { log, es, createdRule, - RuleExecutionStatus.succeeded, + RuleExecutionStatusEnum.succeeded, undefined, afterTimestamp ); @@ -873,7 +873,7 @@ export default ({ getService }: FtrProviderContext) => { log, es, createdRule, - RuleExecutionStatus.succeeded, + RuleExecutionStatusEnum.succeeded, undefined, afterTimestamp ); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts index 0638765283a6e..efed9c26375e2 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/rule_execution_logic/threat_match.ts @@ -34,7 +34,7 @@ import { ALERT_ORIGINAL_EVENT_MODULE, ALERT_ORIGINAL_TIME, } from '@kbn/security-solution-plugin/common/field_maps/field_names'; -import { RuleExecutionStatus } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; +import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; import { getMaxSignalsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils'; import { previewRule, @@ -173,7 +173,7 @@ export default ({ getService }: FtrProviderContext) => { log, es, createdRule, - RuleExecutionStatus.succeeded, + RuleExecutionStatusEnum.succeeded, 100 ); expect(alerts.hits.hits.length).equal(88); @@ -354,7 +354,7 @@ export default ({ getService }: FtrProviderContext) => { log, es, createdRule, - RuleExecutionStatus.succeeded, + RuleExecutionStatusEnum.succeeded, 100 ); expect(alerts.hits.hits.length).equal(88); @@ -557,7 +557,7 @@ export default ({ getService }: FtrProviderContext) => { log, es, createdRuleTerm, - RuleExecutionStatus.succeeded, + RuleExecutionStatusEnum.succeeded, 100 ); const alertsMatch = await getOpenSignals( @@ -565,7 +565,7 @@ export default ({ getService }: FtrProviderContext) => { log, es, createdRuleMatch, - RuleExecutionStatus.succeeded, + RuleExecutionStatusEnum.succeeded, 100 ); diff --git a/x-pack/test/detection_engine_api_integration/utils/get_open_signals.ts b/x-pack/test/detection_engine_api_integration/utils/get_open_signals.ts index 817389ba20547..ac44acef91f06 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_open_signals.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_open_signals.ts @@ -8,7 +8,10 @@ import type SuperTest from 'supertest'; import type { Client } from '@elastic/elasticsearch'; import type { ToolingLog } from '@kbn/tooling-log'; -import { RuleExecutionStatus } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; +import { + RuleExecutionStatus, + RuleExecutionStatusEnum, +} from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; import type { RuleResponse } from '@kbn/security-solution-plugin/common/api/detection_engine'; import { waitForRuleStatus } from './wait_for_rule_status'; @@ -20,7 +23,7 @@ export const getOpenSignals = async ( log: ToolingLog, es: Client, rule: RuleResponse, - status: RuleExecutionStatus = RuleExecutionStatus.succeeded, + status: RuleExecutionStatus = RuleExecutionStatusEnum.succeeded, size?: number, afterDate?: Date ) => { diff --git a/x-pack/test/detection_engine_api_integration/utils/wait_for_rule_status.ts b/x-pack/test/detection_engine_api_integration/utils/wait_for_rule_status.ts index 53393fa58db70..59607eeb47d45 100644 --- a/x-pack/test/detection_engine_api_integration/utils/wait_for_rule_status.ts +++ b/x-pack/test/detection_engine_api_integration/utils/wait_for_rule_status.ts @@ -8,7 +8,10 @@ import type { ToolingLog } from '@kbn/tooling-log'; import type SuperTest from 'supertest'; import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; -import { RuleExecutionStatus } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; +import { + RuleExecutionStatus, + RuleExecutionStatusEnum, +} from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring'; import { waitFor } from './wait_for'; import { routeWithNamespace } from './route_with_namespace'; @@ -70,10 +73,10 @@ export const waitForRuleStatus = async ( }; export const waitForRuleSuccess = (params: WaitForRuleStatusParams): Promise => - waitForRuleStatus(RuleExecutionStatus.succeeded, params); + waitForRuleStatus(RuleExecutionStatusEnum.succeeded, params); export const waitForRulePartialFailure = (params: WaitForRuleStatusParams): Promise => - waitForRuleStatus(RuleExecutionStatus['partial failure'], params); + waitForRuleStatus(RuleExecutionStatusEnum['partial failure'], params); export const waitForRuleFailure = (params: WaitForRuleStatusParams): Promise => - waitForRuleStatus(RuleExecutionStatus.failed, params); + waitForRuleStatus(RuleExecutionStatusEnum.failed, params); diff --git a/x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts b/x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts index 4ad82d75346bd..67f3728eab9f9 100644 --- a/x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts +++ b/x-pack/test/functional/apps/ml/short_tests/model_management/model_list.ts @@ -17,9 +17,7 @@ export default function ({ getService }: FtrProviderContext) { id: model.name, })); - // Failing: See https://github.com/elastic/kibana/issues/165083 - // Failing: See https://github.com/elastic/kibana/issues/165084 - describe.skip('trained models', function () { + describe('trained models', function () { // 'Created at' will be different on each run, // so we will just assert that the value is in the expected timestamp format. const builtInModelData = { diff --git a/x-pack/test/functional/services/cases/common.ts b/x-pack/test/functional/services/cases/common.ts index ef7964935ae41..7a969b310f56f 100644 --- a/x-pack/test/functional/services/cases/common.ts +++ b/x-pack/test/functional/services/cases/common.ts @@ -63,6 +63,7 @@ export function CasesCommonServiceProvider({ getService, getPageObject }: FtrPro const radioGroup = await testSubjects.find(testSubject); const label = await radioGroup.findByCssSelector(`label[for="${value}"]`); await label.click(); + await header.waitUntilLoadingHasFinished(); await this.assertRadioGroupValue(testSubject, value); }, diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts index ceebee7a2dde1..e1b4e45724283 100644 --- a/x-pack/test/functional/services/cases/list.ts +++ b/x-pack/test/functional/services/cases/list.ts @@ -93,7 +93,7 @@ export function CasesTableServiceProvider( async waitForCasesToBeListed() { await retry.waitFor('cases to appear on the all cases table', async () => { - this.refreshTable(); + await this.refreshTable(); return await testSubjects.exists('case-details-link'); }); await header.waitUntilLoadingHasFinished(); @@ -101,7 +101,7 @@ export function CasesTableServiceProvider( async waitForCasesToBeDeleted() { await retry.waitFor('the cases table to be empty', async () => { - this.refreshTable(); + await this.refreshTable(); const rows = await find.allByCssSelector('[data-test-subj*="cases-table-row-"', 100); return rows.length === 0; }); diff --git a/x-pack/test/functional/services/ml/deploy_models_flyout.ts b/x-pack/test/functional/services/ml/deploy_models_flyout.ts index ed52ce97c21f5..22e9c87414f36 100644 --- a/x-pack/test/functional/services/ml/deploy_models_flyout.ts +++ b/x-pack/test/functional/services/ml/deploy_models_flyout.ts @@ -109,6 +109,9 @@ export function DeployDFAModelFlyoutProvider( await editor.click(); const input = await find.activeElement(); await input.clearValueWithKeyboard(); + // Ensure the editor is cleared before adding input + const editorContentAfterClearing = await input.getAttribute('value'); + expect(editorContentAfterClearing).to.eql(''); for (const chr of value) { await retry.tryForTime(5000, async () => { @@ -138,6 +141,7 @@ export function DeployDFAModelFlyoutProvider( 'mlTrainedModelsInferencePipelineFieldMapEditButton' ); await editFieldMapButton.click(); + await this.setTrainedModelsInferenceFlyoutCustomEditorValues( 'mlTrainedModelsInferencePipelineFieldMapEdit', JSON.stringify(values.editedFieldMap) diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts index b812bbaeb019b..098297551347d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/configure.ts @@ -13,6 +13,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const testSubjects = getService('testSubjects'); const cases = getService('cases'); const toasts = getService('toasts'); + const header = getPageObject('header'); describe('Configure', function () { before(async () => { @@ -24,6 +25,9 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); describe('Closure options', function () { + this.beforeEach(async () => { + await header.waitUntilLoadingHasFinished(); + }); it('defaults the closure option correctly', async () => { await cases.common.assertRadioGroupValue('closure-options-radio-group', 'close-by-user'); }); @@ -33,6 +37,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const toast = await toasts.getToastElement(1); expect(await toast.getVisibleText()).to.be('Saved external connection settings'); await toasts.dismissAllToasts(); + await cases.common.assertRadioGroupValue('closure-options-radio-group', 'close-by-pushing'); }); }); diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts index d61b24d67c223..ff5b776743841 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/connectors.cy.ts @@ -10,7 +10,7 @@ import { getServiceNowConnector, getServiceNowITSMHealthResponse } from '../../. import { SERVICE_NOW_MAPPING } from '../../../screens/configure_cases'; import { goToEditExternalConnection } from '../../../tasks/all_cases'; -import { cleanKibana, deleteCases, deleteConnectors } from '../../../tasks/common'; +import { cleanKibana, deleteAllCasesItems, deleteConnectors } from '../../../tasks/common'; import { addServiceNowConnector, openAddNewConnectorOption, @@ -35,6 +35,7 @@ describe('Cases connectors', { tags: ['@ess', '@serverless'] }, () => { error: null, updated_at: null, updated_by: null, + customFields: [], mappings: [ { source: 'title', target: 'short_description', action_type: 'overwrite' }, { source: 'description', target: 'description', action_type: 'overwrite' }, @@ -53,7 +54,7 @@ describe('Cases connectors', { tags: ['@ess', '@serverless'] }, () => { beforeEach(() => { login(); - deleteCases(); + deleteAllCasesItems(); cy.intercept('GET', `${snConnector.URL}/api/x_elas2_inc_int/elastic_api/health*`, { statusCode: 200, body: getServiceNowITSMHealthResponse(), diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/privileges.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/privileges.cy.ts index 7db79bd446777..73836e1cd913a 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/privileges.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/explore/cases/privileges.cy.ts @@ -9,7 +9,7 @@ import type { TestCaseWithoutTimeline } from '../../../objects/case'; import { ALL_CASES_CREATE_NEW_CASE_BTN, ALL_CASES_NAME } from '../../../screens/all_cases'; import { goToCreateNewCase } from '../../../tasks/all_cases'; -import { cleanKibana, deleteCases } from '../../../tasks/common'; +import { cleanKibana, deleteAllCasesItems } from '../../../tasks/common'; import { backToCases, @@ -61,7 +61,7 @@ describe('Cases privileges', { tags: ['@ess', '@serverless', '@brokenInServerles beforeEach(() => { login(); - deleteCases(); + deleteAllCasesItems(); }); for (const user of [secAllUser, secReadCasesAllUser, secAllCasesNoDeleteUser]) { diff --git a/x-pack/test/security_solution_cypress/cypress/objects/case.ts b/x-pack/test/security_solution_cypress/cypress/objects/case.ts index 2abdce5fb068c..f30f24fb6ff06 100644 --- a/x-pack/test/security_solution_cypress/cypress/objects/case.ts +++ b/x-pack/test/security_solution_cypress/cypress/objects/case.ts @@ -74,6 +74,7 @@ export const getCaseResponse = (): Case => ({ status: CaseStatuses.open, severity: CaseSeverity.HIGH, assignees: [], + customFields: [], settings: { syncAlerts: false, }, diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/common.ts b/x-pack/test/security_solution_cypress/cypress/tasks/common.ts index 7e45afcc42c49..3c2467c25f6ad 100644 --- a/x-pack/test/security_solution_cypress/cypress/tasks/common.ts +++ b/x-pack/test/security_solution_cypress/cypress/tasks/common.ts @@ -94,7 +94,7 @@ export const resetRulesTableState = () => { export const cleanKibana = () => { resetRulesTableState(); deleteAlertsAndRules(); - deleteCases(); + deleteAllCasesItems(); deleteTimelines(); }; @@ -169,8 +169,8 @@ export const deleteAlertsIndex = () => { }); }; -export const deleteCases = () => { - const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`; +export const deleteAllCasesItems = () => { + const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_alerting_cases_\*`; rootRequest({ method: 'POST', url: `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed&refresh`, @@ -179,8 +179,34 @@ export const deleteCases = () => { bool: { filter: [ { - match: { - type: 'cases', + bool: { + should: [ + { + term: { + type: 'cases', + }, + }, + { + term: { + type: 'cases-configure', + }, + }, + { + term: { + type: 'cases-comments', + }, + }, + { + term: { + type: 'cases-user-action', + }, + }, + { + term: { + type: 'cases-connector-mappings', + }, + }, + ], }, }, ], @@ -191,7 +217,7 @@ export const deleteCases = () => { }; export const deleteConnectors = () => { - const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_\*`; + const kibanaIndexUrl = `${Cypress.env('ELASTICSEARCH_URL')}/.kibana_alerting_cases_\*`; rootRequest({ method: 'POST', url: `${kibanaIndexUrl}/_delete_by_query?conflicts=proceed&refresh`, diff --git a/x-pack/test_serverless/api_integration/services/svl_cases/api.ts b/x-pack/test_serverless/api_integration/services/svl_cases/api.ts index 4163fc70291db..a504b71240dd5 100644 --- a/x-pack/test_serverless/api_integration/services/svl_cases/api.ts +++ b/x-pack/test_serverless/api_integration/services/svl_cases/api.ts @@ -133,6 +133,7 @@ export function SvlCasesApiServiceProvider({ getService }: FtrProviderContext) { status: CaseStatuses.open, updated_by: null, category: null, + customFields: [], }; }, diff --git a/yarn.lock b/yarn.lock index 41c8328afe98d..1c36304e4eff5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4023,6 +4023,10 @@ version "0.0.0" uid "" +"@kbn/core-test-helpers-model-versions@link:packages/core/test-helpers/core-test-helpers-model-versions": + version "0.0.0" + uid "" + "@kbn/core-test-helpers-so-type-serializer@link:packages/core/test-helpers/core-test-helpers-so-type-serializer": version "0.0.0" uid "" @@ -11385,7 +11389,7 @@ async@^1.4.2: resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= -async@^3.1.0, async@^3.2.0, async@^3.2.3: +async@^3.2.0, async@^3.2.3, async@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== @@ -20804,33 +20808,33 @@ launchdarkly-eventsource@1.4.4: resolved "https://registry.yarnpkg.com/launchdarkly-eventsource/-/launchdarkly-eventsource-1.4.4.tgz#fa595af8602e487c61520787170376c6a1104459" integrity sha512-GL+r2Y3WccJlhFyL2buNKel+9VaMnYpbE/FfCkOST5jSNSFodahlxtGyrE8o7R+Qhobyq0Ree4a7iafJDQi9VQ== -launchdarkly-js-client-sdk@^2.22.1: - version "2.22.1" - resolved "https://registry.yarnpkg.com/launchdarkly-js-client-sdk/-/launchdarkly-js-client-sdk-2.22.1.tgz#e6064c79bc575eea0aa4364be41754d54d89ae6a" - integrity sha512-EAdw7B8w4m/WZGmHHLj9gbYBP6lCqJs5TQDCM9kWJOnvHBz7DJIxOdqazNMDn5AzBxfvaMG7cpLms+Cur5LD5g== +launchdarkly-js-client-sdk@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/launchdarkly-js-client-sdk/-/launchdarkly-js-client-sdk-3.1.4.tgz#e613cb53412533c07ccf140ae570fc994c59758d" + integrity sha512-yq0FeklpVuHMSRz7jfUAfyM7I/659RvGztqJ0Y9G5eN/ZrG1o2W61ZU0Nrv/gqZCtLXjarh/u1otxSFFBjTpHw== dependencies: - escape-string-regexp "^1.0.5" - launchdarkly-js-sdk-common "3.6.0" + escape-string-regexp "^4.0.0" + launchdarkly-js-sdk-common "5.0.3" -launchdarkly-js-sdk-common@3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/launchdarkly-js-sdk-common/-/launchdarkly-js-sdk-common-3.6.0.tgz#d146be5bbd86a019c4bedc52e66c37a1ffa7bb3d" - integrity sha512-wCdBoBiYXlP64jTrC0dOXY2B345LSJO/IvitbdW4kBKmJ1DkeufpqV0s5DBlwE0RLzDmaQx3mRTmcoNAIhIoaA== +launchdarkly-js-sdk-common@5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/launchdarkly-js-sdk-common/-/launchdarkly-js-sdk-common-5.0.3.tgz#345f899f5779be8b03d6599978c855eb838d8b7f" + integrity sha512-wKG8UsVbPVq8+7eavgAm5CVmulQWN6Ddod2ZoA3euZ1zPvJPwIQ2GrOYaCJr3cFrrMIX+nQyBJHBHYxUAPcM+Q== dependencies: base64-js "^1.3.0" fast-deep-equal "^2.0.1" - uuid "^3.3.2" + uuid "^8.0.0" -launchdarkly-node-server-sdk@^6.4.2: - version "6.4.2" - resolved "https://registry.yarnpkg.com/launchdarkly-node-server-sdk/-/launchdarkly-node-server-sdk-6.4.2.tgz#10a4fea21762315a095a9377cb23dc8d6e714469" - integrity sha512-cZQ/FDpzrXu7rOl2re9+79tX/jOrj+kb1ikbqpk/jEgLvXUHGE7Xr+fsEIbQa80H1PkGwiyWbmnAl31THJfKew== +launchdarkly-node-server-sdk@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/launchdarkly-node-server-sdk/-/launchdarkly-node-server-sdk-7.0.3.tgz#d7a8b996d992b0ca5d4972db5df1ae49332b094c" + integrity sha512-uSkBezAiQ9nwv8N6CmI7OmyJ9e3xpueJzYOso8+5vMf7VtBtPjz6RRsUkUsSzUDo7siclmW8USjCwqn9aX2EbQ== dependencies: - async "^3.1.0" + async "^3.2.4" launchdarkly-eventsource "1.4.4" lru-cache "^6.0.0" node-cache "^5.1.0" - semver "^7.3.0" + semver "^7.5.4" tunnel "0.0.6" uuid "^8.3.2" @@ -27077,7 +27081,7 @@ semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semve resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.0, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.0, semver@^7.5.2, semver@^7.5.3: +semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.0, semver@^7.5.2, semver@^7.5.3: version "7.5.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.3.tgz#161ce8c2c6b4b3bdca6caadc9fa3317a4c4fe88e" integrity sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ== @@ -29964,7 +29968,7 @@ uuid@^3.3.2, uuid@^3.3.3: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== -uuid@^8.3.0, uuid@^8.3.2: +uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==