diff --git a/packages/kbn-scout/README.md b/packages/kbn-scout/README.md index b5e64416d4ed2..7368b4d1bb48e 100644 --- a/packages/kbn-scout/README.md +++ b/packages/kbn-scout/README.md @@ -193,6 +193,12 @@ npx playwright test --config /ui_tests/playwright.config.ts We welcome contributions to improve and extend `kbn-scout`. This guide will help you get started, add new features, and align with existing project standards. +Make sure to run unit tests before opening the PR: + +```bash +node scripts/jest --config packages/kbn-scout/jest.config.js +``` + #### Setting Up the Environment Ensure you have the latest local copy of the Kibana repository. diff --git a/packages/kbn-scout/src/common/services/clients.ts b/packages/kbn-scout/src/common/services/clients.ts index 3a0dcf8bfe320..58a5d222e18e5 100644 --- a/packages/kbn-scout/src/common/services/clients.ts +++ b/packages/kbn-scout/src/common/services/clients.ts @@ -9,7 +9,7 @@ import { KbnClient, createEsClientForTesting } from '@kbn/test'; import type { ToolingLog } from '@kbn/tooling-log'; -import { ScoutServerConfig } from '../../types'; +import { ScoutTestConfig } from '../../types'; import { serviceLoadedMsg } from '../../playwright/utils'; interface ClientOptions { @@ -29,7 +29,7 @@ function createClientUrlWithAuth({ serviceName, url, username, password, log }: return clientUrl.toString(); } -export function createEsClient(config: ScoutServerConfig, log: ToolingLog) { +export function createEsClient(config: ScoutTestConfig, log: ToolingLog) { const { username, password } = config.auth; const elasticsearchUrl = createClientUrlWithAuth({ serviceName: 'Es', @@ -45,7 +45,7 @@ export function createEsClient(config: ScoutServerConfig, log: ToolingLog) { }); } -export function createKbnClient(config: ScoutServerConfig, log: ToolingLog) { +export function createKbnClient(config: ScoutTestConfig, log: ToolingLog) { const kibanaUrl = createClientUrlWithAuth({ serviceName: 'Kbn', url: config.hosts.kibana, diff --git a/packages/kbn-scout/src/common/services/config.ts b/packages/kbn-scout/src/common/services/config.ts index fe8e932194d91..dcbcdb2a17ab9 100644 --- a/packages/kbn-scout/src/common/services/config.ts +++ b/packages/kbn-scout/src/common/services/config.ts @@ -10,7 +10,7 @@ import path from 'path'; import fs from 'fs'; import { ToolingLog } from '@kbn/tooling-log'; -import { ScoutServerConfig } from '../../types'; +import { ScoutTestConfig } from '../../types'; import { serviceLoadedMsg } from '../../playwright/utils'; export function createScoutConfig(configDir: string, configName: string, log: ToolingLog) { @@ -21,7 +21,7 @@ export function createScoutConfig(configDir: string, configName: string, log: To const configPath = path.join(configDir, `${configName}.json`); log.info(`Reading test servers confiuration from file: ${configPath}`); - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as ScoutServerConfig; + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as ScoutTestConfig; log.debug(serviceLoadedMsg('config')); diff --git a/packages/kbn-scout/src/common/services/kibana_url.ts b/packages/kbn-scout/src/common/services/kibana_url.ts index cbfab5dc90796..65a21c80592e8 100644 --- a/packages/kbn-scout/src/common/services/kibana_url.ts +++ b/packages/kbn-scout/src/common/services/kibana_url.ts @@ -8,7 +8,7 @@ */ import type { ToolingLog } from '@kbn/tooling-log'; -import { ScoutServerConfig } from '../../types'; +import { ScoutTestConfig } from '../../types'; import { serviceLoadedMsg } from '../../playwright/utils'; export interface PathOptions { @@ -64,7 +64,7 @@ export class KibanaUrl { } } -export function createKbnUrl(scoutConfig: ScoutServerConfig, log: ToolingLog) { +export function createKbnUrl(scoutConfig: ScoutTestConfig, log: ToolingLog) { const kbnUrl = new KibanaUrl(new URL(scoutConfig.hosts.kibana)); log.debug(serviceLoadedMsg('kbnUrl')); diff --git a/packages/kbn-scout/src/common/services/saml_auth.ts b/packages/kbn-scout/src/common/services/saml_auth.ts index e3dbd47fc8c90..8d3daf8e3ccd6 100644 --- a/packages/kbn-scout/src/common/services/saml_auth.ts +++ b/packages/kbn-scout/src/common/services/saml_auth.ts @@ -17,17 +17,17 @@ import { import { REPO_ROOT } from '@kbn/repo-info'; import { HostOptions, SamlSessionManager } from '@kbn/test'; import { ToolingLog } from '@kbn/tooling-log'; -import { ScoutServerConfig } from '../../types'; +import { ScoutTestConfig } from '../../types'; import { Protocol } from '../../playwright/types'; import { serviceLoadedMsg } from '../../playwright/utils'; -const getResourceDirPath = (config: ScoutServerConfig) => { +const getResourceDirPath = (config: ScoutTestConfig) => { return config.serverless ? path.resolve(SERVERLESS_ROLES_ROOT_PATH, config.projectType!) : path.resolve(REPO_ROOT, STATEFUL_ROLES_ROOT_PATH); }; -const createKibanaHostOptions = (config: ScoutServerConfig): HostOptions => { +const createKibanaHostOptions = (config: ScoutTestConfig): HostOptions => { const kibanaUrl = new URL(config.hosts.kibana); kibanaUrl.username = config.auth.username; kibanaUrl.password = config.auth.password; @@ -42,7 +42,7 @@ const createKibanaHostOptions = (config: ScoutServerConfig): HostOptions => { }; export const createSamlSessionManager = ( - config: ScoutServerConfig, + config: ScoutTestConfig, log: ToolingLog ): SamlSessionManager => { const resourceDirPath = getResourceDirPath(config); diff --git a/packages/kbn-scout/src/config/config.test.ts b/packages/kbn-scout/src/config/config.test.ts new file mode 100644 index 0000000000000..f4401e8af2da2 --- /dev/null +++ b/packages/kbn-scout/src/config/config.test.ts @@ -0,0 +1,128 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { Config } from './config'; + +describe('Config.getScoutTestConfig', () => { + it(`should return a properly structured 'ScoutTestConfig' object for 'stateful'`, async () => { + const config = new Config({ + servers: { + elasticsearch: { + protocol: 'http', + hostname: 'localhost', + port: 9220, + username: 'kibana_system', + password: 'changeme', + }, + kibana: { + protocol: 'http', + hostname: 'localhost', + port: 5620, + username: 'elastic', + password: 'changeme', + }, + }, + dockerServers: {}, + esTestCluster: { + from: 'snapshot', + files: [], + serverArgs: [], + ssl: false, + }, + kbnTestServer: { + buildArgs: [], + env: {}, + sourceArgs: [], + serverArgs: [], + }, + }); + + const scoutConfig = config.getScoutTestConfig(); + + const expectedConfig = { + serverless: false, + projectType: undefined, + isCloud: false, + license: 'trial', + cloudUsersFilePath: expect.stringContaining('.ftr/role_users.json'), + hosts: { + kibana: 'http://localhost:5620', + elasticsearch: 'http://localhost:9220', + }, + auth: { + username: 'elastic', + password: 'changeme', + }, + metadata: { + generatedOn: expect.any(String), + config: expect.any(Object), + }, + }; + + expect(scoutConfig).toEqual(expectedConfig); + }); + + it(`should return a properly structured 'ScoutTestConfig' object for 'serverless=es'`, async () => { + const config = new Config({ + serverless: true, + servers: { + elasticsearch: { + protocol: 'https', + hostname: 'localhost', + port: 9220, + username: 'elastic_serverless', + password: 'changeme', + }, + kibana: { + protocol: 'http', + hostname: 'localhost', + port: 5620, + username: 'elastic_serverless', + password: 'changeme', + }, + }, + dockerServers: {}, + esTestCluster: { + from: 'serverless', + files: [], + serverArgs: [], + ssl: true, + }, + kbnTestServer: { + buildArgs: [], + env: {}, + sourceArgs: [], + serverArgs: ['--serverless=es'], + }, + }); + + const scoutConfig = config.getScoutTestConfig(); + const expectedConfig = { + serverless: true, + projectType: 'es', + isCloud: false, + license: 'trial', + cloudUsersFilePath: expect.stringContaining('.ftr/role_users.json'), + hosts: { + kibana: 'http://localhost:5620', + elasticsearch: 'https://localhost:9220', + }, + auth: { + username: 'elastic_serverless', + password: 'changeme', + }, + metadata: { + generatedOn: expect.any(String), + config: expect.any(Object), + }, + }; + + expect(scoutConfig).toEqual(expectedConfig); + }); +}); diff --git a/packages/kbn-scout/src/config/config.ts b/packages/kbn-scout/src/config/config.ts index a316aac61d69e..d790545d258e2 100644 --- a/packages/kbn-scout/src/config/config.ts +++ b/packages/kbn-scout/src/config/config.ts @@ -13,15 +13,15 @@ import Path from 'path'; import { cloneDeepWith, get, has, toPath } from 'lodash'; import { REPO_ROOT } from '@kbn/repo-info'; import { schema } from './schema'; -import { ScoutServerConfig } from '../types'; -import { formatCurrentDate, getProjectType } from './utils'; +import { ScoutServerConfig, ScoutTestConfig } from '../types'; +import { formatCurrentDate, getProjectType } from './utils/utils'; const $values = Symbol('values'); export class Config { - private [$values]: Record; + private [$values]: ScoutServerConfig; - constructor(data: Record) { + constructor(data: ScoutServerConfig) { const { error, value } = schema.validate(data, { abortEarly: false, }); @@ -104,13 +104,14 @@ export class Config { }); } - public getTestServersConfig(): ScoutServerConfig { + public getScoutTestConfig(): ScoutTestConfig { return { serverless: this.get('serverless'), projectType: this.get('serverless') ? getProjectType(this.get('kbnTestServer.serverArgs')) : undefined, isCloud: false, + license: this.get('esTestCluster.license'), cloudUsersFilePath: Path.resolve(REPO_ROOT, '.ftr', 'role_users.json'), hosts: { kibana: Url.format({ diff --git a/packages/kbn-scout/src/config/index.ts b/packages/kbn-scout/src/config/index.ts index 969edbe8e4483..fc9e334ad6596 100644 --- a/packages/kbn-scout/src/config/index.ts +++ b/packages/kbn-scout/src/config/index.ts @@ -7,7 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { loadConfig } from './loader/config_load'; -export { getConfigFilePath } from './get_config_file'; -export { loadServersConfig } from './utils'; +export { readConfigFile } from './loader'; +export { getConfigFilePath, loadServersConfig } from './utils'; export type { Config } from './config'; diff --git a/packages/kbn-scout/src/config/loader/index.ts b/packages/kbn-scout/src/config/loader/index.ts new file mode 100644 index 0000000000000..f84f5e89a4022 --- /dev/null +++ b/packages/kbn-scout/src/config/loader/index.ts @@ -0,0 +1,10 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { readConfigFile } from './read_config_file'; diff --git a/packages/kbn-scout/src/config/loader/read_config_file.test.ts b/packages/kbn-scout/src/config/loader/read_config_file.test.ts new file mode 100644 index 0000000000000..03f0009b3b395 --- /dev/null +++ b/packages/kbn-scout/src/config/loader/read_config_file.test.ts @@ -0,0 +1,83 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import path from 'path'; +import { Config } from '../config'; +import { readConfigFile } from './read_config_file'; + +jest.mock('path', () => ({ + resolve: jest.fn(), +})); + +jest.mock('../config', () => ({ + Config: jest.fn(), +})); + +describe('readConfigFile', () => { + const configPath = '/mock/config/path'; + const resolvedPath = '/resolved/config/path'; + const mockPathResolve = path.resolve as jest.Mock; + const mockConfigConstructor = Config as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + }); + + it(`should load and return a valid 'Config' instance when the config file exports 'servers'`, async () => { + const mockConfigModule = { servers: { host: 'localhost', port: 5601 } }; + + mockPathResolve.mockReturnValueOnce(resolvedPath); + + jest.isolateModules(async () => { + jest.mock(resolvedPath, () => mockConfigModule, { virtual: true }); + mockConfigConstructor.mockImplementation((servers) => ({ servers })); + + const result = await readConfigFile(configPath); + + expect(path.resolve).toHaveBeenCalledWith(configPath); + expect(result).toEqual({ servers: mockConfigModule.servers }); + }); + }); + + it(`should throw an error if the config file does not export 'servers'`, async () => { + const mockConfigModule = { otherProperty: 'value' }; + + mockPathResolve.mockReturnValueOnce(resolvedPath); + + jest.isolateModules(async () => { + jest.mock(resolvedPath, () => mockConfigModule, { virtual: true }); + + await expect(readConfigFile(configPath)).rejects.toThrow( + `No 'servers' found in the config file at path: ${resolvedPath}` + ); + expect(path.resolve).toHaveBeenCalledWith(configPath); + }); + }); + + it('should throw an error if the config file cannot be loaded', async () => { + mockPathResolve.mockReturnValueOnce(resolvedPath); + + jest.isolateModules(async () => { + const message = 'Module not found'; + jest.mock( + resolvedPath, + () => { + throw new Error(message); + }, + { virtual: true } + ); + + await expect(readConfigFile(configPath)).rejects.toThrow( + `Failed to load config from ${configPath}: ${message}` + ); + expect(path.resolve).toHaveBeenCalledWith(configPath); + }); + }); +}); diff --git a/packages/kbn-scout/src/config/loader/config_load.ts b/packages/kbn-scout/src/config/loader/read_config_file.ts similarity index 86% rename from packages/kbn-scout/src/config/loader/config_load.ts rename to packages/kbn-scout/src/config/loader/read_config_file.ts index c7e6b197d6a28..a4f153ff392ac 100644 --- a/packages/kbn-scout/src/config/loader/config_load.ts +++ b/packages/kbn-scout/src/config/loader/read_config_file.ts @@ -9,6 +9,7 @@ import path from 'path'; import { Config } from '../config'; +import { ScoutServerConfig } from '../../types'; /** * Dynamically loads server configuration file in the "kbn-scout" framework. It reads @@ -17,13 +18,13 @@ import { Config } from '../config'; * @param configPath Path to the configuration file to be loaded. * @returns Config instance that is used to start local servers */ -export const loadConfig = async (configPath: string): Promise => { +export const readConfigFile = async (configPath: string): Promise => { try { const absolutePath = path.resolve(configPath); const configModule = await import(absolutePath); if (configModule.servers) { - return new Config(configModule.servers); + return new Config(configModule.servers as ScoutServerConfig); } else { throw new Error(`No 'servers' found in the config file at path: ${absolutePath}`); } diff --git a/packages/kbn-scout/src/config/schema/schema.ts b/packages/kbn-scout/src/config/schema/schema.ts index 86add154cc661..77f3c352e5589 100644 --- a/packages/kbn-scout/src/config/schema/schema.ts +++ b/packages/kbn-scout/src/config/schema/schema.ts @@ -75,7 +75,7 @@ export const schema = Joi.object() esTestCluster: Joi.object() .keys({ - license: Joi.valid('basic', 'trial', 'gold').default('basic'), + license: Joi.valid('basic', 'trial', 'gold').default('trial'), from: Joi.string().default('snapshot'), serverArgs: Joi.array().items(Joi.string()).default([]), esJavaOpts: Joi.string(), diff --git a/packages/kbn-scout/src/config/serverless/es.serverless.config.ts b/packages/kbn-scout/src/config/serverless/es.serverless.config.ts index 0ae2c7e6f0b3f..6ad77bff4606f 100644 --- a/packages/kbn-scout/src/config/serverless/es.serverless.config.ts +++ b/packages/kbn-scout/src/config/serverless/es.serverless.config.ts @@ -7,10 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ScoutLoaderConfig } from '../../types'; +import { ScoutServerConfig } from '../../types'; import { defaultConfig } from './serverless.base.config'; -export const servers: ScoutLoaderConfig = { +export const servers: ScoutServerConfig = { ...defaultConfig, esTestCluster: { ...defaultConfig.esTestCluster, diff --git a/packages/kbn-scout/src/config/serverless/oblt.serverless.config.ts b/packages/kbn-scout/src/config/serverless/oblt.serverless.config.ts index 08eb4d9d7cf55..f0739af12d9a4 100644 --- a/packages/kbn-scout/src/config/serverless/oblt.serverless.config.ts +++ b/packages/kbn-scout/src/config/serverless/oblt.serverless.config.ts @@ -8,9 +8,9 @@ */ import { defaultConfig } from './serverless.base.config'; -import { ScoutLoaderConfig } from '../../types'; +import { ScoutServerConfig } from '../../types'; -export const servers: ScoutLoaderConfig = { +export const servers: ScoutServerConfig = { ...defaultConfig, esTestCluster: { ...defaultConfig.esTestCluster, diff --git a/packages/kbn-scout/src/config/serverless/security.serverless.config.ts b/packages/kbn-scout/src/config/serverless/security.serverless.config.ts index 289790a9ffeb1..e702ae960dca5 100644 --- a/packages/kbn-scout/src/config/serverless/security.serverless.config.ts +++ b/packages/kbn-scout/src/config/serverless/security.serverless.config.ts @@ -7,10 +7,10 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ScoutLoaderConfig } from '../../types'; +import { ScoutServerConfig } from '../../types'; import { defaultConfig } from './serverless.base.config'; -export const servers: ScoutLoaderConfig = { +export const servers: ScoutServerConfig = { ...defaultConfig, esTestCluster: { ...defaultConfig.esTestCluster, diff --git a/packages/kbn-scout/src/config/serverless/serverless.base.config.ts b/packages/kbn-scout/src/config/serverless/serverless.base.config.ts index a20a0c3bbe7a7..0df42d354a1e1 100644 --- a/packages/kbn-scout/src/config/serverless/serverless.base.config.ts +++ b/packages/kbn-scout/src/config/serverless/serverless.base.config.ts @@ -17,7 +17,7 @@ import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils'; import { dockerImage } from '@kbn/test-suites-xpack/fleet_api_integration/config.base'; import { REPO_ROOT } from '@kbn/repo-info'; -import { ScoutLoaderConfig } from '../../types'; +import { ScoutServerConfig } from '../../types'; import { SAML_IDP_PLUGIN_PATH, SERVERLESS_IDP_METADATA_PATH, JWKS_PATH } from '../constants'; const packageRegistryConfig = join(__dirname, './package_registry_config.yml'); @@ -49,7 +49,7 @@ const servers = { }, }; -export const defaultConfig: ScoutLoaderConfig = { +export const defaultConfig: ScoutServerConfig = { serverless: true, servers, dockerServers: defineDockerServersConfig({ diff --git a/packages/kbn-scout/src/config/stateful/base.config.ts b/packages/kbn-scout/src/config/stateful/base.config.ts index a2d6f1e0fa6eb..2bac0024ad4e3 100644 --- a/packages/kbn-scout/src/config/stateful/base.config.ts +++ b/packages/kbn-scout/src/config/stateful/base.config.ts @@ -25,7 +25,7 @@ import { MOCK_IDP_REALM_NAME } from '@kbn/mock-idp-utils'; import { dockerImage } from '@kbn/test-suites-xpack/fleet_api_integration/config.base'; import { REPO_ROOT } from '@kbn/repo-info'; import { STATEFUL_ROLES_ROOT_PATH } from '@kbn/es'; -import type { ScoutLoaderConfig } from '../../types'; +import type { ScoutServerConfig } from '../../types'; import { SAML_IDP_PLUGIN_PATH, STATEFUL_IDP_METADATA_PATH } from '../constants'; const packageRegistryConfig = join(__dirname, './package_registry_config.yml'); @@ -61,7 +61,7 @@ const servers = { const kbnUrl = `${servers.kibana.protocol}://${servers.kibana.hostname}:${servers.kibana.port}`; -export const defaultConfig: ScoutLoaderConfig = { +export const defaultConfig: ScoutServerConfig = { servers, dockerServers: defineDockerServersConfig({ registry: { diff --git a/packages/kbn-scout/src/config/stateful/stateful.config.ts b/packages/kbn-scout/src/config/stateful/stateful.config.ts index e67419c21fb37..2825f6e9f86df 100644 --- a/packages/kbn-scout/src/config/stateful/stateful.config.ts +++ b/packages/kbn-scout/src/config/stateful/stateful.config.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { ScoutLoaderConfig } from '../../types'; +import { ScoutServerConfig } from '../../types'; import { defaultConfig } from './base.config'; -export const servers: ScoutLoaderConfig = defaultConfig; +export const servers: ScoutServerConfig = defaultConfig; diff --git a/packages/kbn-scout/src/config/utils.ts b/packages/kbn-scout/src/config/utils.ts deleted file mode 100644 index 38c65f1573b04..0000000000000 --- a/packages/kbn-scout/src/config/utils.ts +++ /dev/null @@ -1,83 +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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public - * License v3.0 only", or the "Server Side Public License, v 1". - */ - -import * as Fs from 'fs'; -import getopts from 'getopts'; -import path from 'path'; -import { ToolingLog } from '@kbn/tooling-log'; -import { ServerlessProjectType } from '@kbn/es'; -import { SCOUT_SERVERS_ROOT } from '@kbn/scout-info'; -import { CliSupportedServerModes, ScoutServerConfig } from '../types'; -import { getConfigFilePath } from './get_config_file'; -import { loadConfig } from './loader/config_load'; -import type { Config } from './config'; - -export const formatCurrentDate = () => { - const now = new Date(); - - const format = (num: number, length: number) => String(num).padStart(length, '0'); - - return ( - `${format(now.getDate(), 2)}/${format(now.getMonth() + 1, 2)}/${now.getFullYear()} ` + - `${format(now.getHours(), 2)}:${format(now.getMinutes(), 2)}:${format(now.getSeconds(), 2)}.` + - `${format(now.getMilliseconds(), 3)}` - ); -}; - -/** - * Saves Scout server configuration to the disk. - * @param testServersConfig configuration to be saved - * @param log Logger instance to report errors or debug information. - */ -const saveTestServersConfigOnDisk = (testServersConfig: ScoutServerConfig, log: ToolingLog) => { - const configFilePath = path.join(SCOUT_SERVERS_ROOT, `local.json`); - - try { - const jsonData = JSON.stringify(testServersConfig, null, 2); - - if (!Fs.existsSync(SCOUT_SERVERS_ROOT)) { - log.debug(`scout: creating configuration directory: ${SCOUT_SERVERS_ROOT}`); - Fs.mkdirSync(SCOUT_SERVERS_ROOT, { recursive: true }); - } - - Fs.writeFileSync(configFilePath, jsonData, 'utf-8'); - log.info(`scout: Test server configuration saved at ${configFilePath}`); - } catch (error) { - log.error(`scout: Failed to save test server configuration - ${error.message}`); - throw new Error(`Failed to save test server configuration at ${configFilePath}`); - } -}; - -/** - * Loads server configuration based on the mode, creates "kbn-test" compatible Config - * instance, that can be used to start local servers and saves its "Scout"-format copy - * to the disk. - * @param mode server local run mode - * @param log Logger instance to report errors or debug information. - * @returns "kbn-test" compatible Config instance - */ -export async function loadServersConfig( - mode: CliSupportedServerModes, - log: ToolingLog -): Promise { - // get path to one of the predefined config files - const configPath = getConfigFilePath(mode); - // load config that is compatible with kbn-test input format - const config = await loadConfig(configPath); - // construct config for Playwright Test - const scoutServerConfig = config.getTestServersConfig(); - // save test config to the file - saveTestServersConfigOnDisk(scoutServerConfig, log); - return config; -} - -export const getProjectType = (kbnServerArgs: string[]) => { - const options = getopts(kbnServerArgs); - return options.serverless as ServerlessProjectType; -}; diff --git a/packages/kbn-scout/src/config/utils/get_config_file.test.ts b/packages/kbn-scout/src/config/utils/get_config_file.test.ts new file mode 100644 index 0000000000000..09b300e9ae404 --- /dev/null +++ b/packages/kbn-scout/src/config/utils/get_config_file.test.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import path from 'path'; +import { getConfigFilePath } from './get_config_file'; +import { REPO_ROOT } from '@kbn/repo-info'; + +// Not mocking to validate the actual path to the config file +const CONFIG_ROOT = path.join(REPO_ROOT, 'packages', 'kbn-scout', 'src', 'config'); + +describe('getConfigFilePath', () => { + it('should return the correct path for stateful config', () => { + const config = 'stateful'; + const expectedPath = path.join(CONFIG_ROOT, 'stateful', 'stateful.config.ts'); + + const result = getConfigFilePath(config); + + expect(result).toBe(expectedPath); + }); + + it('should return the correct path for serverless config with a valid type', () => { + const config = 'serverless=oblt'; + const expectedPath = path.join(CONFIG_ROOT, 'serverless', 'oblt.serverless.config.ts'); + + const result = getConfigFilePath(config); + + expect(result).toBe(expectedPath); + }); +}); diff --git a/packages/kbn-scout/src/config/get_config_file.ts b/packages/kbn-scout/src/config/utils/get_config_file.ts similarity index 67% rename from packages/kbn-scout/src/config/get_config_file.ts rename to packages/kbn-scout/src/config/utils/get_config_file.ts index 5976db1265797..95fa49af0d669 100644 --- a/packages/kbn-scout/src/config/get_config_file.ts +++ b/packages/kbn-scout/src/config/utils/get_config_file.ts @@ -8,19 +8,22 @@ */ import path from 'path'; -import { CliSupportedServerModes } from '../types'; +import { CliSupportedServerModes } from '../../types'; export const getConfigFilePath = (config: CliSupportedServerModes): string => { + const baseDir = path.join(__dirname, '..'); // config base directory + if (config === 'stateful') { - return path.join(__dirname, 'stateful', 'stateful.config.ts'); + return path.join(baseDir, 'stateful', 'stateful.config.ts'); } const [mode, type] = config.split('='); + if (mode !== 'serverless' || !type) { throw new Error( - `Invalid config format: ${config}. Expected "stateful" or "serverless=".` + `Invalid config format: "${config}". Expected "stateful" or "serverless=".` ); } - return path.join(__dirname, 'serverless', `${type}.serverless.config.ts`); + return path.join(baseDir, 'serverless', `${type}.serverless.config.ts`); }; diff --git a/packages/kbn-scout/src/config/utils/index.ts b/packages/kbn-scout/src/config/utils/index.ts new file mode 100644 index 0000000000000..ebe3b0bc27c44 --- /dev/null +++ b/packages/kbn-scout/src/config/utils/index.ts @@ -0,0 +1,12 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { getConfigFilePath } from './get_config_file'; +export { loadServersConfig } from './load_servers_config'; +export { formatCurrentDate, getProjectType } from './utils'; diff --git a/packages/kbn-scout/src/config/utils/load_servers_config.test.ts b/packages/kbn-scout/src/config/utils/load_servers_config.test.ts new file mode 100644 index 0000000000000..8a33663cb4989 --- /dev/null +++ b/packages/kbn-scout/src/config/utils/load_servers_config.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ToolingLog } from '@kbn/tooling-log'; +import { getConfigFilePath } from './get_config_file'; +import { readConfigFile } from '../loader'; +import { loadServersConfig } from '..'; +import { saveScoutTestConfigOnDisk } from './save_scout_test_config'; +import { CliSupportedServerModes, ScoutTestConfig } from '../../types'; + +jest.mock('./get_config_file', () => ({ + getConfigFilePath: jest.fn(), +})); + +jest.mock('../loader', () => ({ + readConfigFile: jest.fn(), +})); + +jest.mock('./save_scout_test_config', () => ({ + saveScoutTestConfigOnDisk: jest.fn(), +})); + +const mockScoutTestConfig: ScoutTestConfig = { + hosts: { + kibana: 'http://localhost:5601', + elasticsearch: 'http://localhost:9220', + }, + auth: { + username: 'elastic', + password: 'changeme', + }, + serverless: true, + projectType: 'oblt', + isCloud: true, + license: 'trial', + cloudUsersFilePath: '/path/to/users', +}; + +describe('loadServersConfig', () => { + let mockLog: ToolingLog; + + const mockMode = `serverless=${mockScoutTestConfig.projectType}` as CliSupportedServerModes; + const mockConfigPath = '/mock/config/path.ts'; + + const mockClusterConfig = { + getScoutTestConfig: jest.fn().mockReturnValue(mockScoutTestConfig), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockLog = { + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + } as unknown as ToolingLog; + }); + + it('should load, save, and return cluster configuration', async () => { + (getConfigFilePath as jest.Mock).mockReturnValue(mockConfigPath); + (readConfigFile as jest.Mock).mockResolvedValue(mockClusterConfig); + + const result = await loadServersConfig(mockMode, mockLog); + + expect(getConfigFilePath).toHaveBeenCalledWith(mockMode); + expect(readConfigFile).toHaveBeenCalledWith(mockConfigPath); + expect(mockClusterConfig.getScoutTestConfig).toHaveBeenCalled(); + expect(saveScoutTestConfigOnDisk).toHaveBeenCalledWith(mockScoutTestConfig, mockLog); + expect(result).toBe(mockClusterConfig); + + // no errors should be logged + expect(mockLog.info).not.toHaveBeenCalledWith(expect.stringContaining('error')); + }); + + it('should throw an error if readConfigFile fails', async () => { + const errorMessage = 'Failed to read config file'; + (getConfigFilePath as jest.Mock).mockReturnValue(mockConfigPath); + (readConfigFile as jest.Mock).mockRejectedValue(new Error(errorMessage)); + + await expect(loadServersConfig(mockMode, mockLog)).rejects.toThrow(errorMessage); + + expect(getConfigFilePath).toHaveBeenCalledWith(mockMode); + expect(readConfigFile).toHaveBeenCalledWith(mockConfigPath); + expect(saveScoutTestConfigOnDisk).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-scout/src/config/utils/load_servers_config.ts b/packages/kbn-scout/src/config/utils/load_servers_config.ts new file mode 100644 index 0000000000000..007b2fd32a460 --- /dev/null +++ b/packages/kbn-scout/src/config/utils/load_servers_config.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { ToolingLog } from '@kbn/tooling-log'; +import { CliSupportedServerModes } from '../../types'; +import { getConfigFilePath } from './get_config_file'; +import { readConfigFile } from '../loader'; +import type { Config } from '../config'; +import { saveScoutTestConfigOnDisk } from './save_scout_test_config'; + +/** + * Loads server configuration based on the mode, creates "kbn-test" compatible Config + * instance, that can be used to start local servers and saves its "Scout"-format copy + * to the disk. + * @param mode server local run mode + * @param log Logger instance to report errors or debug information. + * @returns "kbn-test" compatible Config instance + */ +export async function loadServersConfig( + mode: CliSupportedServerModes, + log: ToolingLog +): Promise { + // get path to one of the predefined config files + const configPath = getConfigFilePath(mode); + // load config that is compatible with kbn-test input format + const clusterConfig = await readConfigFile(configPath); + // construct config for Playwright Test + const scoutServerConfig = clusterConfig.getScoutTestConfig(); + // save test config to the file + saveScoutTestConfigOnDisk(scoutServerConfig, log); + return clusterConfig; +} diff --git a/packages/kbn-scout/src/config/utils/save_scout_test_config.test.ts b/packages/kbn-scout/src/config/utils/save_scout_test_config.test.ts new file mode 100644 index 0000000000000..770145c8fdf35 --- /dev/null +++ b/packages/kbn-scout/src/config/utils/save_scout_test_config.test.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import path from 'path'; +import Fs from 'fs'; +import { ToolingLog } from '@kbn/tooling-log'; +import { saveScoutTestConfigOnDisk } from './save_scout_test_config'; + +const MOCKED_SCOUT_SERVERS_ROOT = '/mock/repo/root/scout/servers'; + +jest.mock('fs'); + +jest.mock('@kbn/repo-info', () => ({ + REPO_ROOT: '/mock/repo/root', +})); + +jest.mock('@kbn/scout-info', () => ({ + SCOUT_SERVERS_ROOT: '/mock/repo/root/scout/servers', +})); + +const testServersConfig = { + hosts: { + kibana: 'http://localhost:5601', + elasticsearch: 'http://localhost:9220', + }, + auth: { + username: 'elastic', + password: 'changeme', + }, + serverless: true, + isCloud: true, + license: 'trial', + cloudUsersFilePath: '/path/to/users', +}; + +jest.mock('path', () => ({ + ...jest.requireActual('path'), + join: jest.fn((...args) => args.join('/')), +})); + +describe('saveScoutTestConfigOnDisk', () => { + let mockLog: ToolingLog; + + beforeEach(() => { + jest.clearAllMocks(); + mockLog = { + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + } as unknown as ToolingLog; + }); + + it('should save configuration to disk successfully', () => { + const mockConfigFilePath = `${MOCKED_SCOUT_SERVERS_ROOT}/local.json`; + + // Mock path.join to return a fixed file path + (path.join as jest.Mock).mockReturnValueOnce(mockConfigFilePath); + + // Mock Fs.existsSync to return true + (Fs.existsSync as jest.Mock).mockReturnValueOnce(true); + + // Mock Fs.writeFileSync to do nothing + const writeFileSyncMock = jest.spyOn(Fs, 'writeFileSync'); + + saveScoutTestConfigOnDisk(testServersConfig, mockLog); + + expect(Fs.existsSync).toHaveBeenCalledWith(MOCKED_SCOUT_SERVERS_ROOT); + expect(writeFileSyncMock).toHaveBeenCalledWith( + mockConfigFilePath, + JSON.stringify(testServersConfig, null, 2), + 'utf-8' + ); + expect(mockLog.info).toHaveBeenCalledWith( + `scout: Test server configuration saved at ${mockConfigFilePath}` + ); + }); + + it('should throw an error if writing to file fails', () => { + const mockConfigFilePath = `${MOCKED_SCOUT_SERVERS_ROOT}/local.json`; + + (path.join as jest.Mock).mockReturnValueOnce(mockConfigFilePath); + (Fs.existsSync as jest.Mock).mockReturnValueOnce(true); + + // Mock writeFileSync to throw an error + (Fs.writeFileSync as jest.Mock).mockImplementationOnce(() => { + throw new Error('Disk is full'); + }); + + expect(() => saveScoutTestConfigOnDisk(testServersConfig, mockLog)).toThrow( + `Failed to save test server configuration at ${mockConfigFilePath}` + ); + expect(mockLog.error).toHaveBeenCalledWith( + `scout: Failed to save test server configuration - Disk is full` + ); + }); + + it('should create configuration directory if it does not exist', () => { + const mockConfigFilePath = `${MOCKED_SCOUT_SERVERS_ROOT}/local.json`; + + (path.join as jest.Mock).mockReturnValueOnce(mockConfigFilePath); + + // Mock existsSync to simulate non-existent directory + (Fs.existsSync as jest.Mock).mockReturnValueOnce(false); + + const mkdirSyncMock = jest.spyOn(Fs, 'mkdirSync'); + const writeFileSyncMock = jest.spyOn(Fs, 'writeFileSync'); + + saveScoutTestConfigOnDisk(testServersConfig, mockLog); + + expect(Fs.existsSync).toHaveBeenCalledWith(MOCKED_SCOUT_SERVERS_ROOT); + expect(mkdirSyncMock).toHaveBeenCalledWith(MOCKED_SCOUT_SERVERS_ROOT, { recursive: true }); + expect(writeFileSyncMock).toHaveBeenCalledWith( + mockConfigFilePath, + JSON.stringify(testServersConfig, null, 2), + 'utf-8' + ); + expect(mockLog.debug).toHaveBeenCalledWith( + `scout: creating configuration directory: ${MOCKED_SCOUT_SERVERS_ROOT}` + ); + expect(mockLog.info).toHaveBeenCalledWith( + `scout: Test server configuration saved at ${mockConfigFilePath}` + ); + }); +}); diff --git a/packages/kbn-scout/src/config/utils/save_scout_test_config.ts b/packages/kbn-scout/src/config/utils/save_scout_test_config.ts new file mode 100644 index 0000000000000..12d78d804bf9f --- /dev/null +++ b/packages/kbn-scout/src/config/utils/save_scout_test_config.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import * as Fs from 'fs'; +import path from 'path'; +import { ToolingLog } from '@kbn/tooling-log'; +import { SCOUT_SERVERS_ROOT } from '@kbn/scout-info'; +import { ScoutTestConfig } from '../../types'; + +/** + * Saves Scout server configuration to the disk. + * @param testServersConfig configuration to be saved + * @param log Logger instance to report errors or debug information. + */ +export const saveScoutTestConfigOnDisk = (testServersConfig: ScoutTestConfig, log: ToolingLog) => { + const configFilePath = path.join(SCOUT_SERVERS_ROOT, `local.json`); + + try { + const jsonData = JSON.stringify(testServersConfig, null, 2); + + if (!Fs.existsSync(SCOUT_SERVERS_ROOT)) { + log.debug(`scout: creating configuration directory: ${SCOUT_SERVERS_ROOT}`); + Fs.mkdirSync(SCOUT_SERVERS_ROOT, { recursive: true }); + } + + Fs.writeFileSync(configFilePath, jsonData, 'utf-8'); + log.info(`scout: Test server configuration saved at ${configFilePath}`); + } catch (error) { + log.error(`scout: Failed to save test server configuration - ${error.message}`); + throw new Error(`Failed to save test server configuration at ${configFilePath}`); + } +}; diff --git a/packages/kbn-scout/src/config/utils/utils.ts b/packages/kbn-scout/src/config/utils/utils.ts new file mode 100644 index 0000000000000..4fab350e8b4b1 --- /dev/null +++ b/packages/kbn-scout/src/config/utils/utils.ts @@ -0,0 +1,28 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import getopts from 'getopts'; +import { ServerlessProjectType } from '@kbn/es'; + +export const formatCurrentDate = () => { + const now = new Date(); + + const format = (num: number, length: number) => String(num).padStart(length, '0'); + + return ( + `${format(now.getDate(), 2)}/${format(now.getMonth() + 1, 2)}/${now.getFullYear()} ` + + `${format(now.getHours(), 2)}:${format(now.getMinutes(), 2)}:${format(now.getSeconds(), 2)}.` + + `${format(now.getMilliseconds(), 3)}` + ); +}; + +export const getProjectType = (kbnServerArgs: string[]) => { + const options = getopts(kbnServerArgs); + return options.serverless as ServerlessProjectType; +}; diff --git a/packages/kbn-scout/src/playwright/config/create_config.test.ts b/packages/kbn-scout/src/playwright/config/create_config.test.ts new file mode 100644 index 0000000000000..730bdd5ef55e4 --- /dev/null +++ b/packages/kbn-scout/src/playwright/config/create_config.test.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { SCOUT_SERVERS_ROOT } from '@kbn/scout-info'; +import { createPlaywrightConfig } from './create_config'; +import { VALID_CONFIG_MARKER } from '../types'; + +describe('createPlaywrightConfig', () => { + it('should return a valid default Playwright configuration', () => { + const testDir = './my_tests'; + const config = createPlaywrightConfig({ testDir }); + + expect(config.testDir).toBe(testDir); + expect(config.workers).toBe(1); + expect(config.fullyParallel).toBe(false); + expect(config.use).toEqual({ + serversConfigDir: SCOUT_SERVERS_ROOT, + [VALID_CONFIG_MARKER]: true, + screenshot: 'only-on-failure', + trace: 'on-first-retry', + }); + expect(config.globalSetup).toBeUndefined(); + expect(config.globalTeardown).toBeUndefined(); + expect(config.reporter).toEqual([ + ['html', { open: 'never', outputFolder: './output/reports' }], + ['json', { outputFile: './output/reports/test-results.json' }], + ['@kbn/scout-reporting/src/reporting/playwright.ts', { name: 'scout-playwright' }], + ]); + expect(config.timeout).toBe(60000); + expect(config.expect?.timeout).toBe(10000); + expect(config.outputDir).toBe('./output/test-artifacts'); + expect(config.projects![0].name).toEqual('chromium'); + }); + + it(`should override 'workers' count in Playwright configuration`, () => { + const testDir = './my_tests'; + const workers = 2; + + const config = createPlaywrightConfig({ testDir, workers }); + expect(config.workers).toBe(workers); + }); +}); diff --git a/packages/kbn-scout/src/playwright/config/create_config.ts b/packages/kbn-scout/src/playwright/config/create_config.ts new file mode 100644 index 0000000000000..cb1e371cb43e7 --- /dev/null +++ b/packages/kbn-scout/src/playwright/config/create_config.ts @@ -0,0 +1,76 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { defineConfig, PlaywrightTestConfig, devices } from '@playwright/test'; +import { scoutPlaywrightReporter } from '@kbn/scout-reporting'; +import { SCOUT_SERVERS_ROOT } from '@kbn/scout-info'; +import { ScoutPlaywrightOptions, ScoutTestOptions, VALID_CONFIG_MARKER } from '../types'; + +export function createPlaywrightConfig(options: ScoutPlaywrightOptions): PlaywrightTestConfig { + return defineConfig({ + testDir: options.testDir, + /* Run tests in files in parallel */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: options.workers ?? 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['html', { outputFolder: './output/reports', open: 'never' }], // HTML report configuration + ['json', { outputFile: './output/reports/test-results.json' }], // JSON report + scoutPlaywrightReporter({ name: 'scout-playwright' }), // Scout report + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + serversConfigDir: SCOUT_SERVERS_ROOT, + [VALID_CONFIG_MARKER]: true, + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + screenshot: 'only-on-failure', + // video: 'retain-on-failure', + // storageState: './output/reports/state.json', // Store session state (like cookies) + }, + + // Timeout for each test, includes test, hooks and fixtures + timeout: 60000, + + // Timeout for each assertion + expect: { + timeout: 10000, + }, + + outputDir: './output/test-artifacts', // For other test artifacts (screenshots, videos, traces) + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, + }); +} diff --git a/packages/kbn-scout/src/playwright/config/index.ts b/packages/kbn-scout/src/playwright/config/index.ts index cb1e371cb43e7..c3949cca0af7a 100644 --- a/packages/kbn-scout/src/playwright/config/index.ts +++ b/packages/kbn-scout/src/playwright/config/index.ts @@ -7,70 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { defineConfig, PlaywrightTestConfig, devices } from '@playwright/test'; -import { scoutPlaywrightReporter } from '@kbn/scout-reporting'; -import { SCOUT_SERVERS_ROOT } from '@kbn/scout-info'; -import { ScoutPlaywrightOptions, ScoutTestOptions, VALID_CONFIG_MARKER } from '../types'; - -export function createPlaywrightConfig(options: ScoutPlaywrightOptions): PlaywrightTestConfig { - return defineConfig({ - testDir: options.testDir, - /* Run tests in files in parallel */ - fullyParallel: false, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: options.workers ?? 1, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [ - ['html', { outputFolder: './output/reports', open: 'never' }], // HTML report configuration - ['json', { outputFile: './output/reports/test-results.json' }], // JSON report - scoutPlaywrightReporter({ name: 'scout-playwright' }), // Scout report - ], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - serversConfigDir: SCOUT_SERVERS_ROOT, - [VALID_CONFIG_MARKER]: true, - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - screenshot: 'only-on-failure', - // video: 'retain-on-failure', - // storageState: './output/reports/state.json', // Store session state (like cookies) - }, - - // Timeout for each test, includes test, hooks and fixtures - timeout: 60000, - - // Timeout for each assertion - expect: { - timeout: 10000, - }, - - outputDir: './output/test-artifacts', // For other test artifacts (screenshots, videos, traces) - - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, - ], - - /* Run your local dev server before starting the tests */ - // webServer: { - // command: 'npm run start', - // url: 'http://127.0.0.1:3000', - // reuseExistingServer: !process.env.CI, - // }, - }); -} +export { createPlaywrightConfig } from './create_config'; diff --git a/packages/kbn-scout/src/playwright/fixtures/types/worker_scope.ts b/packages/kbn-scout/src/playwright/fixtures/types/worker_scope.ts index c42f7143d3191..29283cbea4517 100644 --- a/packages/kbn-scout/src/playwright/fixtures/types/worker_scope.ts +++ b/packages/kbn-scout/src/playwright/fixtures/types/worker_scope.ts @@ -14,7 +14,7 @@ import { LoadActionPerfOptions } from '@kbn/es-archiver'; import { IndexStats } from '@kbn/es-archiver/src/lib/stats'; import type { UiSettingValues } from '@kbn/test/src/kbn_client/kbn_client_ui_settings'; -import { ScoutServerConfig } from '../../../types'; +import { ScoutTestConfig } from '../../../types'; import { KibanaUrl } from '../../../common/services/kibana_url'; export interface EsArchiverFixture { @@ -58,7 +58,7 @@ export interface UiSettingsFixture { */ export interface ScoutWorkerFixtures { log: ToolingLog; - config: ScoutServerConfig; + config: ScoutTestConfig; kbnUrl: KibanaUrl; esClient: Client; kbnClient: KbnClient; diff --git a/packages/kbn-scout/src/playwright/runner/config_loader.ts b/packages/kbn-scout/src/playwright/runner/config_loader.ts new file mode 100644 index 0000000000000..c7758f1415634 --- /dev/null +++ b/packages/kbn-scout/src/playwright/runner/config_loader.ts @@ -0,0 +1,17 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +/** + * Loads the config module dynamically + * @param configPath config absolute path + * @returns + */ +export async function loadConfigModule(configPath: string) { + return import(configPath); +} diff --git a/packages/kbn-scout/src/playwright/runner/config_validator.test.ts b/packages/kbn-scout/src/playwright/runner/config_validator.test.ts new file mode 100644 index 0000000000000..eb6d97ee8e576 --- /dev/null +++ b/packages/kbn-scout/src/playwright/runner/config_validator.test.ts @@ -0,0 +1,99 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { validatePlaywrightConfig } from './config_validator'; +import * as configLoader from './config_loader'; +import Fs from 'fs'; +import { VALID_CONFIG_MARKER } from '../types'; + +jest.mock('fs'); + +const existsSyncMock = jest.spyOn(Fs, 'existsSync'); +const loadConfigModuleMock = jest.spyOn(configLoader, 'loadConfigModule'); + +describe('validatePlaywrightConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should pass validation for a valid config file', async () => { + const configPath = 'valid/path/config.ts'; + existsSyncMock.mockReturnValue(true); + loadConfigModuleMock.mockResolvedValue({ + default: { + use: { [VALID_CONFIG_MARKER]: true }, + testDir: './tests', + }, + }); + + await expect(validatePlaywrightConfig(configPath)).resolves.not.toThrow(); + }); + + it('should throw an error if the config file does not have the valid marker', async () => { + const configPath = 'valid/path/config.ts'; + existsSyncMock.mockReturnValue(true); + loadConfigModuleMock.mockResolvedValue({ + default: { + use: {}, + testDir: './tests', + }, + }); + + await expect(validatePlaywrightConfig(configPath)).rejects.toThrow( + `The config file at "${configPath}" must be created with "createPlaywrightConfig" from '@kbn/scout' package:` + ); + }); + + it(`should throw an error if the config file does not have a 'testDir'`, async () => { + const configPath = 'valid/path/config.ts'; + existsSyncMock.mockReturnValue(true); + loadConfigModuleMock.mockResolvedValue({ + default: { + use: { [VALID_CONFIG_MARKER]: true }, + }, + }); + + await expect(validatePlaywrightConfig(configPath)).rejects.toThrow( + `The config file at "${configPath}" must export a valid Playwright configuration with "testDir" property.` + ); + }); + + it('should throw an error if the config file does not have a default export', async () => { + const configPath = 'valid/path/config.ts'; + existsSyncMock.mockReturnValue(true); + loadConfigModuleMock.mockResolvedValue({ + test: { + use: {}, + testDir: './tests', + }, + }); + + await expect(validatePlaywrightConfig(configPath)).rejects.toThrow( + `The config file at "${configPath}" must export default function` + ); + }); + + it('should throw an error if the path does not exist', async () => { + const configPath = 'invalid/path/to/config.ts'; + existsSyncMock.mockReturnValue(false); + + await expect(validatePlaywrightConfig(configPath)).rejects.toThrow( + `Path to a valid TypeScript config file is required: --config ` + ); + }); + + it('should throw an error if the file does not have a .ts extension', async () => { + const configPath = 'config.js'; + existsSyncMock.mockReturnValue(true); + + await expect(validatePlaywrightConfig(configPath)).rejects.toThrow( + `Path to a valid TypeScript config file is required: --config ` + ); + }); +}); diff --git a/packages/kbn-scout/src/playwright/runner/config_validator.ts b/packages/kbn-scout/src/playwright/runner/config_validator.ts index a066a6dfba30c..41c44cfc6eabd 100644 --- a/packages/kbn-scout/src/playwright/runner/config_validator.ts +++ b/packages/kbn-scout/src/playwright/runner/config_validator.ts @@ -7,34 +7,35 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import * as Fs from 'fs'; -import { REPO_ROOT } from '@kbn/repo-info'; +import Fs from 'fs'; import { PlaywrightTestConfig } from 'playwright/test'; -import path from 'path'; import { createFlagError } from '@kbn/dev-cli-errors'; import { ScoutTestOptions, VALID_CONFIG_MARKER } from '../types'; +import { loadConfigModule } from './config_loader'; export async function validatePlaywrightConfig(configPath: string) { - const fullPath = path.resolve(REPO_ROOT, configPath); - // Check if the path exists and has a .ts extension - if (!configPath || !Fs.existsSync(fullPath) || !configPath.endsWith('.ts')) { + if (!configPath || !Fs.existsSync(configPath) || !configPath.endsWith('.ts')) { throw createFlagError( `Path to a valid TypeScript config file is required: --config ` ); } - // Dynamically import the file to check for a default export - const configModule = await import(fullPath); + const configModule = await loadConfigModule(configPath); + // Check for a default export const config = configModule.default as PlaywrightTestConfig; - // Check if the config's 'use' property has the valid marker + if (config === undefined) { + throw createFlagError(`The config file at "${configPath}" must export default function`); + } + if (!config?.use?.[VALID_CONFIG_MARKER]) { + // Check if the config's 'use' property has the valid marker throw createFlagError( `The config file at "${configPath}" must be created with "createPlaywrightConfig" from '@kbn/scout' package:\n -export default createPlaywrightConfig({ - testDir: './tests', -});` + export default createPlaywrightConfig({ + testDir: './tests', + });` ); } diff --git a/packages/kbn-scout/src/playwright/runner/flags.test.ts b/packages/kbn-scout/src/playwright/runner/flags.test.ts new file mode 100644 index 0000000000000..94683146e5278 --- /dev/null +++ b/packages/kbn-scout/src/playwright/runner/flags.test.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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { parseTestFlags } from './flags'; +import { FlagsReader } from '@kbn/dev-cli-runner'; +import * as configValidator from './config_validator'; + +const validatePlaywrightConfigMock = jest.spyOn(configValidator, 'validatePlaywrightConfig'); + +describe('parseTestFlags', () => { + it(`should throw an error without 'config' flag`, async () => { + const flags = new FlagsReader({ + stateful: true, + logToFile: false, + headed: false, + }); + + await expect(parseTestFlags(flags)).rejects.toThrow( + 'Path to playwright config is required: --config ' + ); + }); + + it(`should throw an error with '--stateful' flag as string value`, async () => { + const flags = new FlagsReader({ + stateful: 'true', + logToFile: false, + headed: false, + }); + + await expect(parseTestFlags(flags)).rejects.toThrow('expected --stateful to be a boolean'); + }); + + it(`should throw an error with '--serverless' flag as boolean`, async () => { + const flags = new FlagsReader({ + serverless: true, + logToFile: false, + headed: false, + }); + + await expect(parseTestFlags(flags)).rejects.toThrow('expected --serverless to be a string'); + }); + + it(`should throw an error with incorrect '--serverless' flag`, async () => { + const flags = new FlagsReader({ + serverless: 'a', + logToFile: false, + headed: false, + }); + + await expect(parseTestFlags(flags)).rejects.toThrow( + 'invalid --serverless, expected one of "es", "oblt", "security"' + ); + }); + + it(`should parse with correct config and serverless flags`, async () => { + const flags = new FlagsReader({ + config: '/path/to/config', + stateful: false, + serverless: 'oblt', + logToFile: false, + headed: false, + }); + validatePlaywrightConfigMock.mockResolvedValueOnce(); + const result = await parseTestFlags(flags); + + expect(result).toEqual({ + mode: 'serverless=oblt', + configPath: '/path/to/config', + headed: false, + esFrom: undefined, + installDir: undefined, + logsDir: undefined, + }); + }); + + it(`should parse with correct config and stateful flags`, async () => { + const flags = new FlagsReader({ + config: '/path/to/config', + stateful: true, + logToFile: false, + headed: true, + esFrom: 'snapshot', + }); + validatePlaywrightConfigMock.mockResolvedValueOnce(); + const result = await parseTestFlags(flags); + + expect(result).toEqual({ + mode: 'stateful', + configPath: '/path/to/config', + headed: true, + esFrom: 'snapshot', + installDir: undefined, + logsDir: undefined, + }); + }); +}); diff --git a/packages/kbn-scout/src/playwright/runner/flags.ts b/packages/kbn-scout/src/playwright/runner/flags.ts index 7d39d821705c1..e5bded7d6f1c2 100644 --- a/packages/kbn-scout/src/playwright/runner/flags.ts +++ b/packages/kbn-scout/src/playwright/runner/flags.ts @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { REPO_ROOT } from '@kbn/repo-info'; +import path from 'path'; import { FlagOptions, FlagsReader } from '@kbn/dev-cli-runner'; import { createFlagError } from '@kbn/dev-cli-errors'; import { SERVER_FLAG_OPTIONS, parseServerFlags } from '../../servers'; @@ -42,7 +44,8 @@ export async function parseTestFlags(flags: FlagsReader) { throw createFlagError(`Path to playwright config is required: --config `); } - await validatePlaywrightConfig(configPath); + const configFullPath = path.resolve(REPO_ROOT, configPath); + await validatePlaywrightConfig(configFullPath); return { ...options, diff --git a/packages/kbn-scout/src/playwright/utils/index.ts b/packages/kbn-scout/src/playwright/utils/index.ts index 8956c6d7cc18f..347fe7f22d05b 100644 --- a/packages/kbn-scout/src/playwright/utils/index.ts +++ b/packages/kbn-scout/src/playwright/utils/index.ts @@ -7,23 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import moment from 'moment'; -import { Config } from '../../config'; -import { tagsByMode } from '../tags'; - -export const serviceLoadedMsg = (name: string) => `scout service loaded: ${name}`; - -export const isValidUTCDate = (date: string): boolean => { - return !isNaN(Date.parse(date)) && new Date(date).toISOString() === date; -}; - -export function formatTime(date: string, fmt: string = 'MMM D, YYYY @ HH:mm:ss.SSS') { - return moment.utc(date, fmt).format(); -} - -export const getPlaywrightGrepTag = (config: Config): string => { - const serversConfig = config.getTestServersConfig(); - return serversConfig.serverless - ? tagsByMode.serverless[serversConfig.projectType!] - : tagsByMode.stateful; -}; +export { serviceLoadedMsg, isValidUTCDate, formatTime, getPlaywrightGrepTag } from './runner_utils'; diff --git a/packages/kbn-scout/src/playwright/utils/runner_utils.test.ts b/packages/kbn-scout/src/playwright/utils/runner_utils.test.ts new file mode 100644 index 0000000000000..007555ea4e7ad --- /dev/null +++ b/packages/kbn-scout/src/playwright/utils/runner_utils.test.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { isValidUTCDate, formatTime, getPlaywrightGrepTag } from './runner_utils'; +import moment from 'moment'; +jest.mock('moment', () => { + const actualMoment = jest.requireActual('moment'); + return { + ...actualMoment, + utc: jest.fn((date, fmt) => actualMoment(date, fmt)), + }; +}); + +describe('isValidUTCDate', () => { + it('should return true for valid UTC date strings', () => { + expect(isValidUTCDate('2019-04-27T23:56:51.374Z')).toBe(true); + }); + + it('should return false for invalid date strings', () => { + expect(isValidUTCDate('invalid-date')).toBe(false); + }); + + it('should return false for valid non-UTC date strings', () => { + expect(isValidUTCDate('2015-09-19T06:31:44')).toBe(false); + expect(isValidUTCDate('Sep 19, 2015 @ 06:31:44.000')).toBe(false); + }); +}); + +describe('formatTime', () => { + it('should format the time using the default format', () => { + const mockDate = '2024-12-16T12:00:00.000Z'; + const mockFormat = 'MMM D, YYYY @ HH:mm:ss.SSS'; + (moment.utc as jest.Mock).mockReturnValue({ format: () => 'Dec 16, 2024 @ 12:00:00.000' }); + + const result = formatTime(mockDate); + + expect(moment.utc).toHaveBeenCalledWith(mockDate, mockFormat); + expect(result).toBe('Dec 16, 2024 @ 12:00:00.000'); + }); + + it('should format the time using a custom format', () => { + const mockDate = '2024-12-16T12:00:00.000Z'; + const customFormat = 'YYYY-MM-DD'; + (moment.utc as jest.Mock).mockReturnValue({ format: () => '2024-12-16' }); + + const result = formatTime(mockDate, customFormat); + + expect(moment.utc).toHaveBeenCalledWith(mockDate, customFormat); + expect(result).toBe('2024-12-16'); + }); +}); + +describe('getPlaywrightGrepTag', () => { + const mockConfig = { + getScoutTestConfig: jest.fn(), + }; + + it('should return the correct tag for serverless mode', () => { + mockConfig.getScoutTestConfig.mockReturnValue({ + serverless: true, + projectType: 'oblt', + }); + + const result = getPlaywrightGrepTag(mockConfig as any); + + expect(mockConfig.getScoutTestConfig).toHaveBeenCalled(); + expect(result).toBe('@svlOblt'); + }); + + it('should return the correct tag for stateful mode', () => { + mockConfig.getScoutTestConfig.mockReturnValue({ + serverless: false, + }); + + const result = getPlaywrightGrepTag(mockConfig as any); + + expect(mockConfig.getScoutTestConfig).toHaveBeenCalled(); + expect(result).toBe('@ess'); + }); + + it('should throw an error if projectType is missing in serverless mode', () => { + mockConfig.getScoutTestConfig.mockReturnValue({ + serverless: true, + projectType: undefined, + }); + + expect(() => getPlaywrightGrepTag(mockConfig as any)).toThrow( + `'projectType' is required to determine tags for 'serverless' mode.` + ); + }); + + it('should throw an error if unknown projectType is set in serverless mode', () => { + mockConfig.getScoutTestConfig.mockReturnValue({ + serverless: true, + projectType: 'a', + }); + + expect(() => getPlaywrightGrepTag(mockConfig as any)).toThrow( + `No tags found for projectType: 'a'.` + ); + }); +}); diff --git a/packages/kbn-scout/src/playwright/utils/runner_utils.ts b/packages/kbn-scout/src/playwright/utils/runner_utils.ts new file mode 100644 index 0000000000000..fda016f9389c8 --- /dev/null +++ b/packages/kbn-scout/src/playwright/utils/runner_utils.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import moment from 'moment'; +import { Config } from '../../config'; +import { tagsByMode } from '../tags'; + +export const serviceLoadedMsg = (name: string) => `scout service loaded: ${name}`; + +export const isValidUTCDate = (date: string): boolean => { + return !isNaN(Date.parse(date)) && new Date(date).toISOString() === date; +}; + +export function formatTime(date: string, fmt: string = 'MMM D, YYYY @ HH:mm:ss.SSS') { + return moment.utc(date, fmt).format(); +} + +export const getPlaywrightGrepTag = (config: Config): string => { + const serversConfig = config.getScoutTestConfig(); + + if (serversConfig.serverless) { + const { projectType } = serversConfig; + + if (!projectType) { + throw new Error(`'projectType' is required to determine tags for 'serverless' mode.`); + } + + const tag = tagsByMode.serverless[projectType]; + if (!tag) { + throw new Error(`No tags found for projectType: '${projectType}'.`); + } + + return tag; + } + + return tagsByMode.stateful; +}; diff --git a/packages/kbn-scout/src/servers/flags.test.ts b/packages/kbn-scout/src/servers/flags.test.ts new file mode 100644 index 0000000000000..1bf6f18c0236b --- /dev/null +++ b/packages/kbn-scout/src/servers/flags.test.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { parseServerFlags } from './flags'; +import { FlagsReader } from '@kbn/dev-cli-runner'; + +describe('parseServerFlags', () => { + it(`should throw an error with '--stateful' flag as string value`, () => { + const flags = new FlagsReader({ + stateful: 'true', + logToFile: false, + }); + + expect(() => parseServerFlags(flags)).toThrow('expected --stateful to be a boolean'); + }); + + it(`should throw an error with '--serverless' flag as boolean`, () => { + const flags = new FlagsReader({ + serverless: true, + logToFile: false, + }); + + expect(() => parseServerFlags(flags)).toThrow('expected --serverless to be a string'); + }); + + it(`should throw an error with incorrect '--serverless' flag`, () => { + const flags = new FlagsReader({ + serverless: 'a', + logToFile: false, + }); + + expect(() => parseServerFlags(flags)).toThrow( + 'invalid --serverless, expected one of "es", "oblt", "security"' + ); + }); + + it(`should parse with correct config and serverless flags`, () => { + const flags = new FlagsReader({ + stateful: false, + serverless: 'oblt', + logToFile: false, + }); + const result = parseServerFlags(flags); + + expect(result).toEqual({ + mode: 'serverless=oblt', + esFrom: undefined, + installDir: undefined, + logsDir: undefined, + }); + }); + + it(`should parse with correct config and stateful flags`, () => { + const flags = new FlagsReader({ + stateful: true, + logToFile: false, + esFrom: 'snapshot', + }); + const result = parseServerFlags(flags); + + expect(result).toEqual({ + mode: 'stateful', + esFrom: 'snapshot', + installDir: undefined, + logsDir: undefined, + }); + }); +}); diff --git a/packages/kbn-scout/src/types/index.ts b/packages/kbn-scout/src/types/index.ts index 024a00ec29e45..5f79c9ba17b88 100644 --- a/packages/kbn-scout/src/types/index.ts +++ b/packages/kbn-scout/src/types/index.ts @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export * from './config'; +export * from './server_config'; export * from './cli'; -export * from './servers'; +export * from './test_config'; export * from './services'; diff --git a/packages/kbn-scout/src/types/config.d.ts b/packages/kbn-scout/src/types/server_config.d.ts similarity index 96% rename from packages/kbn-scout/src/types/config.d.ts rename to packages/kbn-scout/src/types/server_config.d.ts index 08c4bc5f3f9b0..ea35377d88ca4 100644 --- a/packages/kbn-scout/src/types/config.d.ts +++ b/packages/kbn-scout/src/types/server_config.d.ts @@ -9,7 +9,7 @@ import { UrlParts } from '@kbn/test'; -export interface ScoutLoaderConfig { +export interface ScoutServerConfig { serverless?: boolean; servers: { kibana: UrlParts; diff --git a/packages/kbn-scout/src/types/servers.d.ts b/packages/kbn-scout/src/types/test_config.d.ts similarity index 93% rename from packages/kbn-scout/src/types/servers.d.ts rename to packages/kbn-scout/src/types/test_config.d.ts index 587e1d213b9ba..ffe7d56f83ed5 100644 --- a/packages/kbn-scout/src/types/servers.d.ts +++ b/packages/kbn-scout/src/types/test_config.d.ts @@ -9,10 +9,11 @@ import { ServerlessProjectType } from '@kbn/es'; -export interface ScoutServerConfig { +export interface ScoutTestConfig { serverless: boolean; projectType?: ServerlessProjectType; isCloud: boolean; + license: string; cloudUsersFilePath: string; hosts: { kibana: string; diff --git a/src/plugins/controls/public/controls/timeslider_control/components/time_slider_popover_content.tsx b/src/plugins/controls/public/controls/timeslider_control/components/time_slider_popover_content.tsx index fc4d050d71d59..5bf94109d3b9f 100644 --- a/src/plugins/controls/public/controls/timeslider_control/components/time_slider_popover_content.tsx +++ b/src/plugins/controls/public/controls/timeslider_control/components/time_slider_popover_content.tsx @@ -8,6 +8,8 @@ */ import React from 'react'; +import { useMemo, useEffect, useState } from 'react'; +import { debounce } from 'lodash'; import { EuiButtonIcon, EuiRangeTick, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { TimeSliderStrings } from './time_slider_strings'; @@ -27,29 +29,63 @@ interface Props { compressed: boolean; } -export function TimeSliderPopoverContent(props: Props) { - const rangeInput = props.isAnchored ? ( +export function TimeSliderPopoverContent({ + isAnchored, + setIsAnchored, + value, + onChange, + stepSize, + ticks, + timeRangeMin, + timeRangeMax, + compressed, +}: Props) { + const [displayedValue, setDisplayedValue] = useState(value); + + const debouncedOnChange = useMemo( + () => + debounce((updateTimeslice: Timeslice | undefined) => { + onChange(updateTimeslice); + }, 750), + [onChange] + ); + + /** + * The following `useEffect` ensures that the changes to the value that come from the embeddable (for example, + * from the `clear` button on the dashboard) are reflected in the displayed value + */ + useEffect(() => { + setDisplayedValue(value); + }, [value]); + + const rangeInput = isAnchored ? ( { + setDisplayedValue(newValue as Timeslice); + debouncedOnChange(newValue); + }} + stepSize={stepSize} + ticks={ticks} + timeRangeMin={timeRangeMin} + timeRangeMax={timeRangeMax} + compressed={compressed} /> ) : ( { + setDisplayedValue(newValue as Timeslice); + debouncedOnChange(newValue); + }} + stepSize={stepSize} + ticks={ticks} + timeRangeMin={timeRangeMin} + timeRangeMax={timeRangeMax} + compressed={compressed} /> ); - const anchorStartToggleButtonLabel = props.isAnchored + const anchorStartToggleButtonLabel = isAnchored ? TimeSliderStrings.control.getUnpinStart() : TimeSliderStrings.control.getPinStart(); @@ -59,17 +95,24 @@ export function TimeSliderPopoverContent(props: Props) { gutterSize="none" data-test-subj="timeSlider-popoverContents" responsive={false} + onMouseUp={() => { + // when the pin is dropped (on mouse up), cancel any pending debounced changes and force the change + // in value to happen instantly (which, in turn, will re-calculate the from/to for the slider due to + // the `useEffect` above. + debouncedOnChange.cancel(); + onChange(displayedValue); + }} > { - const nextIsAnchored = !props.isAnchored; + const nextIsAnchored = !isAnchored; if (nextIsAnchored) { - props.onChange([props.timeRangeMin, props.value[1]]); + onChange([timeRangeMin, value[1]]); } - props.setIsAnchored(nextIsAnchored); + setIsAnchored(nextIsAnchored); }} aria-label={anchorStartToggleButtonLabel} data-test-subj="timeSlider__anchorStartToggleButton" diff --git a/src/plugins/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx b/src/plugins/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx index 59ad0a2a5076c..7e81fa075334e 100644 --- a/src/plugins/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx +++ b/src/plugins/controls/public/controls/timeslider_control/get_timeslider_control_factory.tsx @@ -270,7 +270,6 @@ export const getTimesliderControlFactory = (): ControlFactory< Component: (controlPanelClassNames) => { const [isAnchored, isPopoverOpen, timeRangeMeta, timeslice] = useBatchedPublishingSubjects(isAnchored$, isPopoverOpen$, timeRangeMeta$, timeslice$); - useEffect(() => { return () => { cleanupTimeRangeSubscription(); @@ -284,6 +283,9 @@ export const getTimesliderControlFactory = (): ControlFactory< const to = useMemo(() => { return timeslice ? timeslice[TO_INDEX] : timeRangeMeta.timeRangeMax; }, [timeslice, timeRangeMeta.timeRangeMax]); + const value: Timeslice = useMemo(() => { + return [from, to]; + }, [from, to]); return ( { diff --git a/src/plugins/kibana_overview/public/components/_index.scss b/src/plugins/kibana_overview/public/components/_index.scss deleted file mode 100644 index b8857d171728f..0000000000000 --- a/src/plugins/kibana_overview/public/components/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'overview'; diff --git a/src/plugins/kibana_overview/public/components/app.tsx b/src/plugins/kibana_overview/public/components/app.tsx index 581356f83d358..50233396f3c48 100644 --- a/src/plugins/kibana_overview/public/components/app.tsx +++ b/src/plugins/kibana_overview/public/components/app.tsx @@ -13,11 +13,11 @@ import { I18nProvider } from '@kbn/i18n-react'; import type { NavigationPublicPluginStart } from '@kbn/navigation-plugin/public'; import type { FetchResult } from '@kbn/newsfeed-plugin/public'; import { Route, Routes } from '@kbn/shared-ux-router'; +import { withSuspense } from '@kbn/shared-ux-utility'; import React, { useEffect, useState } from 'react'; import { HashRouter as Router } from 'react-router-dom'; import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; -import { Overview } from './overview'; interface KibanaOverviewAppDeps { basename: string; @@ -48,6 +48,14 @@ export const KibanaOverviewApp = ({ } }, [newsfeed$]); + const Overview = withSuspense( + React.lazy(() => + import('./overview').then(({ Overview: OverviewComponent }) => { + return { default: OverviewComponent }; + }) + ) + ); + return ( diff --git a/src/plugins/kibana_overview/public/components/_overview.scss b/src/plugins/kibana_overview/public/components/overview/overview.scss similarity index 100% rename from src/plugins/kibana_overview/public/components/_overview.scss rename to src/plugins/kibana_overview/public/components/overview/overview.scss diff --git a/src/plugins/kibana_overview/public/components/overview/overview.tsx b/src/plugins/kibana_overview/public/components/overview/overview.tsx index 4d21adeecd377..c4a36e966142b 100644 --- a/src/plugins/kibana_overview/public/components/overview/overview.tsx +++ b/src/plugins/kibana_overview/public/components/overview/overview.tsx @@ -7,6 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import './overview.scss'; + import { snakeCase } from 'lodash'; import React, { FC, useState, useEffect } from 'react'; import useObservable from 'react-use/lib/useObservable'; diff --git a/src/plugins/kibana_overview/public/index.scss b/src/plugins/kibana_overview/public/index.scss deleted file mode 100644 index 841415620d691..0000000000000 --- a/src/plugins/kibana_overview/public/index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'components/index'; diff --git a/src/plugins/kibana_overview/public/index.ts b/src/plugins/kibana_overview/public/index.ts index 5091cd83ae67c..6fb3f0cc4a6e3 100644 --- a/src/plugins/kibana_overview/public/index.ts +++ b/src/plugins/kibana_overview/public/index.ts @@ -7,8 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import './index.scss'; - import { KibanaOverviewPlugin } from './plugin'; // This exports static code and TypeScript types, diff --git a/x-pack/platform/packages/shared/kbn-cloud-security-posture/common/schema/graph/v1.ts b/x-pack/platform/packages/shared/kbn-cloud-security-posture/common/schema/graph/v1.ts index 5b1a48cf940b7..114ff1aec9568 100644 --- a/x-pack/platform/packages/shared/kbn-cloud-security-posture/common/schema/graph/v1.ts +++ b/x-pack/platform/packages/shared/kbn-cloud-security-posture/common/schema/graph/v1.ts @@ -99,4 +99,5 @@ export const edgeDataSchema = schema.object({ source: schema.string(), target: schema.string(), color: colorSchema, + type: schema.maybe(schema.oneOf([schema.literal('solid'), schema.literal('dashed')])), }); diff --git a/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx b/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx index 4b17004865502..6b7569dcddd8d 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/delete_action_modal.tsx @@ -14,6 +14,7 @@ import { EuiFlexGroup, EuiFlexItem, EUI_MODAL_CONFIRM_BUTTON, + EuiSpacer, } from '@elastic/eui'; import type { DeleteAction } from './use_delete_action'; @@ -79,16 +80,22 @@ export const DeleteActionModal: FC = ({ {userCanDeleteIndex && dataViewExists && ( - + <> + + + )} diff --git a/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx b/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx index 9fe4da68aa6f8..08360eb4f2cca 100644 --- a/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx +++ b/x-pack/platform/plugins/shared/ml/public/application/data_frame_analytics/pages/components/analytics_selector/analytics_id_selector.tsx @@ -281,7 +281,18 @@ export function AnalyticsIdSelector({ {renderTabs()} - + + + + {i18n.translate('xpack.ml.analyticsSelector.closeFlyoutButton', { + defaultMessage: 'Close', + })} + + - - - {i18n.translate('xpack.ml.analyticsSelector.closeFlyoutButton', { - defaultMessage: 'Close', - })} - - diff --git a/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts b/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts index 9fe03cd411b81..34f750ac776a2 100755 --- a/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts +++ b/x-pack/plugins/logstash/server/models/cluster/cluster.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type * as estypes from '@elastic/elasticsearch/lib/api/types'; import { Cluster } from './cluster'; describe('cluster', () => { diff --git a/x-pack/plugins/logstash/server/models/cluster/cluster.ts b/x-pack/plugins/logstash/server/models/cluster/cluster.ts index 6c1712eb4797e..c3a3729254623 100755 --- a/x-pack/plugins/logstash/server/models/cluster/cluster.ts +++ b/x-pack/plugins/logstash/server/models/cluster/cluster.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type * as estypes from '@elastic/elasticsearch/lib/api/types'; /** * This model deals with a cluster object from ES and converts it to Kibana downstream diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index 5920e6a05eb29..3d5b5169d9c14 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import * as estypes from '@elastic/elasticsearch/lib/api/types'; import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { JOB_STATUS } from '@kbn/reporting-common'; import { ReportDocument } from '@kbn/reporting-common/types'; @@ -212,7 +212,7 @@ describe('ReportingStore', () => { const [[updateCall]] = mockEsClient.update.mock.calls; - const response = (updateCall as estypes.UpdateRequest).body?.doc as Report; + const response = (updateCall as estypes.UpdateRequest)?.doc as Report; expect(response.migration_version).toBe(`7.14.0`); expect(response.status).toBe(`processing`); expect(updateCall.if_seq_no).toBe(42); @@ -242,7 +242,7 @@ describe('ReportingStore', () => { await store.setReportFailed(report, { errors: 'yes' } as any); const [[updateCall]] = mockEsClient.update.mock.calls; - const response = (updateCall as estypes.UpdateRequest).body?.doc as Report; + const response = (updateCall as estypes.UpdateRequest)?.doc as Report; expect(response.migration_version).toBe(`7.14.0`); expect(response.status).toBe(`failed`); expect(updateCall.if_seq_no).toBe(43); @@ -272,7 +272,7 @@ describe('ReportingStore', () => { await store.setReportError(report, { errors: 'yes' } as any); const [[updateCall]] = mockEsClient.update.mock.calls; - const response = (updateCall as estypes.UpdateRequest).body?.doc as Report; + const response = (updateCall as estypes.UpdateRequest)?.doc as Report; expect(response.migration_version).toBe(`7.14.0`); expect(updateCall.if_seq_no).toBe(43); expect(updateCall.if_primary_term).toBe(10002); @@ -301,7 +301,7 @@ describe('ReportingStore', () => { await store.setReportCompleted(report, { certainly_completed: 'yes' } as any); const [[updateCall]] = mockEsClient.update.mock.calls; - const response = (updateCall as estypes.UpdateRequest).body?.doc as Report; + const response = (updateCall as estypes.UpdateRequest)?.doc as Report; expect(response.migration_version).toBe(`7.14.0`); expect(response.status).toBe(`completed`); expect(updateCall.if_seq_no).toBe(44); @@ -336,7 +336,7 @@ describe('ReportingStore', () => { } as any); const [[updateCall]] = mockEsClient.update.mock.calls; - const response = (updateCall as estypes.UpdateRequest).body?.doc as Report; + const response = (updateCall as estypes.UpdateRequest)?.doc as Report; expect(response.migration_version).toBe(`7.14.0`); expect(response.status).toBe(`completed_with_warnings`); diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 05b34f2c04533..85da045996279 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -86,7 +86,7 @@ const esDocForUpdate = ( if_seq_no: report._seq_no, if_primary_term: report._primary_term, refresh: 'wait_for' as estypes.Refresh, - body: { doc }, + doc, }; }; diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index 4ca908310c78c..80cbb1e71a5d3 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -12,7 +12,7 @@ import { Writable } from 'stream'; import { finished } from 'stream/promises'; import { setTimeout } from 'timers/promises'; -import { UpdateResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { UpdateResponse } from '@elastic/elasticsearch/lib/api/types'; import type { Logger } from '@kbn/core/server'; import { CancellationToken, diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/deafult_edge.stories.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/deafult_edge.stories.tsx index dd1c956a55e55..b4f35af2054f4 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/deafult_edge.stories.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/deafult_edge.stories.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { ThemeProvider } from '@emotion/react'; import { ReactFlow, @@ -17,14 +17,18 @@ import { useEdgesState, type BuiltInNode, type NodeProps, + type Node as xyNode, + type Edge as xyEdge, } from '@xyflow/react'; +import { isEmpty, isEqual, pick, size, xorWith } from 'lodash'; import { Story } from '@storybook/react'; -import { SvgDefsMarker } from './styles'; import { DefaultEdge } from '.'; +import { LabelNode } from '../node'; +import type { EdgeViewModel } from '../types'; +import { SvgDefsMarker } from './markers'; import '@xyflow/react/dist/style.css'; -import { LabelNode } from '../node'; -import type { NodeViewModel } from '../types'; +import { HandleStyleOverride } from '../node/styles'; export default { title: 'Components/Graph Components/Default Edge', @@ -34,22 +38,32 @@ export default { options: ['primary', 'danger', 'warning'], control: { type: 'radio' }, }, + type: { + options: ['solid', 'dashed'], + control: { type: 'radio' }, + }, }, }; const nodeTypes = { - default: ((props: NodeProps) => { - const handleStyle = { - width: 0, - height: 0, - 'min-width': 0, - 'min-height': 0, - border: 'none', - }; + // eslint-disable-next-line react/display-name + default: React.memo((props: NodeProps) => { return (
- - + + {props.data.label}
); @@ -61,66 +75,87 @@ const edgeTypes = { default: DefaultEdge, }; -const Template: Story = (args: NodeViewModel) => { - const initialNodes = [ - { - id: 'source', - type: 'default', - data: { label: 'source' }, - position: { x: 0, y: 0 }, - draggable: true, - }, - { - id: 'target', - type: 'default', - data: { label: 'target' }, - position: { x: 320, y: 100 }, - draggable: true, - }, - { - id: args.id, - type: 'label', - data: args, - position: { x: 160, y: 50 }, - draggable: true, - }, - ]; +const Template: Story = (args: EdgeViewModel) => { + const nodes = useMemo( + () => [ + { + id: 'source', + type: 'default', + data: { + label: 'source', + }, + position: { x: 0, y: 0 }, + }, + { + id: 'target', + type: 'default', + data: { + label: 'target', + }, + position: { x: 420, y: 0 }, + }, + { + id: args.id, + type: 'label', + data: pick(args, ['id', 'label', 'interactive', 'source', 'target', 'color', 'type']), + position: { x: 230, y: 6 }, + }, + ], + [args] + ); - const initialEdges = [ - { - id: `source-${args.id}`, - source: 'source', - target: args.id, - data: { + const edges = useMemo( + () => [ + { id: `source-${args.id}`, source: 'source', - sourceShape: 'rectangle', target: args.id, - targetShape: 'label', - color: args.color, - interactive: true, + data: { + id: `source-${args.id}`, + source: 'source', + sourceShape: 'custom', + target: args.id, + targetShape: 'label', + color: args.color, + type: args.type, + }, + type: 'default', }, - type: 'default', - }, - { - id: `${args.id}-target`, - source: args.id, - target: 'target', - data: { + { id: `${args.id}-target`, source: args.id, - sourceShape: 'label', target: 'target', - targetShape: 'rectangle', - color: args.color, - interactive: true, + data: { + id: `${args.id}-target`, + source: args.id, + sourceShape: 'label', + target: 'target', + targetShape: 'custom', + color: args.color, + type: args.type, + }, + type: 'default', }, - type: 'default', - }, - ]; + ], + [args] + ); - const [nodes, _setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, _setEdges, onEdgesChange] = useEdgesState(initialEdges); + const [nodesState, setNodes, onNodesChange] = useNodesState(nodes); + const [edgesState, setEdges, onEdgesChange] = useEdgesState>(edges); + const currNodesRef = useRef(nodes); + const currEdgesRef = useRef(edges); + + useEffect(() => { + if ( + !isArrayOfObjectsEqual(nodes, currNodesRef.current) || + !isArrayOfObjectsEqual(edges, currEdgesRef.current) + ) { + setNodes(nodes); + setEdges(edges); + currNodesRef.current = nodes; + currEdgesRef.current = edges; + } + }, [setNodes, setEdges, nodes, edges]); return ( @@ -128,12 +163,13 @@ const Template: Story = (args: NodeViewModel) => { @@ -148,6 +184,9 @@ Edge.args = { id: 'siem-windows', label: 'User login to OKTA', color: 'primary', - icon: 'okta', interactive: true, + type: 'solid', }; + +const isArrayOfObjectsEqual = (x: object[], y: object[]) => + size(x) === size(y) && isEmpty(xorWith(x, y, isEqual)); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx index 6826c47b270ce..370c7b3909973 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx @@ -6,12 +6,13 @@ */ import React from 'react'; -import { BaseEdge, getBezierPath } from '@xyflow/react'; +import { BaseEdge, getSmoothStepPath } from '@xyflow/react'; import { useEuiTheme } from '@elastic/eui'; -import type { Color } from '@kbn/cloud-security-posture-common/types/graph/latest'; -import type { EdgeProps } from '../types'; -import { getMarker } from './styles'; +import type { EdgeProps, EdgeViewModel } from '../types'; import { getShapeHandlePosition } from './utils'; +import { getMarkerStart, getMarkerEnd } from './markers'; + +type EdgeColor = EdgeViewModel['color']; export function DefaultEdge({ id, @@ -25,9 +26,9 @@ export function DefaultEdge({ data, }: EdgeProps) { const { euiTheme } = useEuiTheme(); - const color: Color = data?.color ?? 'primary'; + const color: EdgeColor = data?.color ?? 'primary'; - const [edgePath] = getBezierPath({ + const [edgePath] = getSmoothStepPath({ // sourceX and targetX are adjusted to account for the shape handle position sourceX: sourceX - getShapeHandlePosition(data?.sourceShape), sourceY, @@ -35,12 +36,8 @@ export function DefaultEdge({ targetX: targetX + getShapeHandlePosition(data?.targetShape), targetY, targetPosition, - curvature: - 0.1 * - (data?.sourceShape === 'group' || - (data?.sourceShape === 'label' && data?.targetShape === 'group') - ? -1 // We flip direction when the edge is between parent node to child nodes (groups always contain children in our graph) - : 1), + borderRadius: 15, + offset: 0, }); return ( @@ -50,12 +47,19 @@ export function DefaultEdge({ style={{ stroke: euiTheme.colors[color], }} - css={{ - strokeDasharray: '2,2', - }} + css={ + (!data?.type || data?.type === 'dashed') && { + strokeDasharray: '2,2', + } + } + markerStart={ + data?.sourceShape !== 'label' && data?.sourceShape !== 'group' + ? getMarkerStart(color) + : undefined + } markerEnd={ data?.targetShape !== 'label' && data?.targetShape !== 'group' - ? getMarker(color) + ? getMarkerEnd(color) : undefined } /> diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/markers.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/markers.tsx new file mode 100644 index 0000000000000..06dcaf29c63d6 --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/markers.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 { useEuiTheme } from '@elastic/eui'; + +const getArrowPoints = (width: number, height: number): string => { + return `${-width},${-height} 0,0 ${-width},${height} ${-width},${-height}`; +}; + +const ArrowMarker = ({ + id, + color, + width = 5, + height = 4, +}: { + id: string; + color: string; + width?: number; + height?: number; +}) => { + const points = getArrowPoints(width, height); + + return ( + + + + ); +}; + +const DotMarker = ({ id, color }: { id: string; color: string }) => { + return ( + + + + ); +}; + +const MarkerStartType = { + primary: 'url(#dotPrimary)', + danger: 'url(#dotDanger)', + warning: 'url(#dotWarning)', +}; + +const MarkerEndType = { + primary: 'url(#arrowPrimary)', + danger: 'url(#arrowDanger)', + warning: 'url(#arrowWarning)', +}; + +export const getMarkerStart = (color: string) => { + const colorKey = color as keyof typeof MarkerStartType; + return MarkerStartType[colorKey] ?? MarkerStartType.primary; +}; + +export const getMarkerEnd = (color: string) => { + const colorKey = color as keyof typeof MarkerEndType; + return MarkerEndType[colorKey] ?? MarkerEndType.primary; +}; + +export const SvgDefsMarker = () => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + + + + + + + ); +}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx index 5a3e2f8b72b21..66b7eca015caf 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import React from 'react'; import styled from '@emotion/styled'; import { rgba } from 'polished'; import { @@ -88,52 +87,3 @@ export const EdgeLabelOnHover = styled(EdgeLabel) { - return ( - - - - ); -}; - -export const MarkerType = { - primary: 'url(#primary)', - danger: 'url(#danger)', - warning: 'url(#warning)', -}; - -export const getMarker = (color: string) => { - const colorKey = color as keyof typeof MarkerType; - return MarkerType[colorKey] ?? MarkerType.primary; -}; - -export const SvgDefsMarker = () => { - const { euiTheme } = useEuiTheme(); - - return ( - - - - - - - - ); -}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx index a97a1c74698ca..f08e40111b7f8 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx @@ -18,7 +18,7 @@ import { import type { Edge, FitViewOptions, Node, ReactFlowInstance } from '@xyflow/react'; import { useGeneratedHtmlId } from '@elastic/eui'; import type { CommonProps } from '@elastic/eui'; -import { SvgDefsMarker } from '../edge/styles'; +import { SvgDefsMarker } from '../edge/markers'; import { HexagonNode, PentagonNode, @@ -243,7 +243,9 @@ const processGraph = ( data: { ...edgeData, sourceShape: nodesById[edgeData.source].shape, + sourceColor: nodesById[edgeData.source].color, targetShape: nodesById[edgeData.target].shape, + targetColor: nodesById[edgeData.target].color, }, }; }); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx index 570c1332a8834..65d0b5a2b89b8 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx @@ -40,20 +40,8 @@ export const GraphPopover: React.FC = ({ {...rest} panelProps={{ css: css` - .euiPopover__arrow[data-popover-arrow='left']:before { - border-inline-start-color: ${euiTheme.colors?.body}; - } - - .euiPopover__arrow[data-popover-arrow='right']:before { - border-inline-end-color: ${euiTheme.colors?.body}; - } - - .euiPopover__arrow[data-popover-arrow='bottom']:before { - border-block-end-color: ${euiTheme.colors?.body}; - } - - .euiPopover__arrow[data-popover-arrow='top']:before { - border-block-start-color: ${euiTheme.colors?.body}; + .euiPopover__arrow { + --euiPopoverBackgroundColor: ${euiTheme.colors?.body}; } background-color: ${euiTheme.colors?.body}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/button.stories.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/button.stories.tsx index 7dc46ac6eb82c..bea6f851bab17 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/button.stories.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/button.stories.tsx @@ -10,7 +10,8 @@ import React from 'react'; import { ThemeProvider } from '@emotion/react'; import { Story } from '@storybook/react'; -import { NodeButton, type NodeButtonProps, NodeShapeContainer } from './styles'; +import { type NodeButtonProps, NodeShapeContainer } from './styles'; +import { NodeExpandButton } from './node_expand_button'; export default { title: 'Components/Graph Components', @@ -24,7 +25,7 @@ const Template: Story = (args) => ( Hover me - + ); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx index ac6f51284a98d..b46c44c69d1b8 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui'; +import { useEuiTheme } from '@elastic/eui'; import { Handle, Position } from '@xyflow/react'; import type { EntityNodeViewModel, NodeProps } from '../types'; import { @@ -16,6 +16,7 @@ import { NodeIcon, NodeButton, HandleStyleOverride, + useNodeFillColor, } from './styles'; import { DiamondHoverShape, DiamondShape } from './shapes/diamond_shape'; import { NodeExpandButton } from './node_expand_button'; @@ -51,7 +52,7 @@ export const DiamondNode: React.FC = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -60,6 +61,7 @@ export const DiamondNode: React.FC = memo((props: NodeProps) => { <> nodeClick?.(e, props)} /> expandButtonClick?.(e, props, unToggleCallback)} x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize}px`} y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2 - 4}px`} diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/edge_group_node.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/edge_group_node.tsx index 05a61977cdcb1..10ac415398717 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/edge_group_node.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/edge_group_node.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { Handle, NodeResizeControl, Position } from '@xyflow/react'; +import { Handle, Position } from '@xyflow/react'; import { HandleStyleOverride } from './styles'; import type { NodeProps } from '../types'; @@ -15,25 +15,20 @@ export const EdgeGroupNode: React.FC = memo((props: NodeProps) => { // Handles order horizontally is: in > inside > out > outside return ( <> - - - - + + = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -59,6 +60,7 @@ export const EllipseNode: React.FC = memo((props: NodeProps) => { <> nodeClick?.(e, props)} /> expandButtonClick?.(e, props, unToggleCallback)} x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 2}px`} y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2}px`} diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx index f5ee7d92605cc..ebde4e2334e21 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui'; +import { useEuiTheme } from '@elastic/eui'; import { Handle, Position } from '@xyflow/react'; import { NodeShapeContainer, @@ -15,6 +15,7 @@ import { NodeIcon, NodeButton, HandleStyleOverride, + useNodeFillColor, } from './styles'; import type { EntityNodeViewModel, NodeProps } from '../types'; import { HexagonHoverShape, HexagonShape } from './shapes/hexagon_shape'; @@ -51,7 +52,7 @@ export const HexagonNode: React.FC = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -60,6 +61,7 @@ export const HexagonNode: React.FC = memo((props: NodeProps) => { <> nodeClick?.(e, props)} /> expandButtonClick?.(e, props, unToggleCallback)} x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 2 + 2}px`} y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2 - 2}px`} diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/node_expand_button.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/node_expand_button.tsx index 07581c1e3d3dd..80d354cd77d6b 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/node_expand_button.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/node_expand_button.tsx @@ -7,14 +7,16 @@ import React, { useCallback, useState } from 'react'; import { StyledNodeExpandButton, RoundEuiButtonIcon, ExpandButtonSize } from './styles'; +import type { EntityNodeViewModel, LabelNodeViewModel } from '..'; export interface NodeExpandButtonProps { x?: string; y?: string; + color?: EntityNodeViewModel['color'] | LabelNodeViewModel['color']; onClick?: (e: React.MouseEvent, unToggleCallback: () => void) => void; } -export const NodeExpandButton = ({ x, y, onClick }: NodeExpandButtonProps) => { +export const NodeExpandButton = ({ x, y, color, onClick }: NodeExpandButtonProps) => { // State to track whether the icon is "plus" or "minus" const [isToggled, setIsToggled] = useState(false); @@ -30,7 +32,7 @@ export const NodeExpandButton = ({ x, y, onClick }: NodeExpandButtonProps) => { return ( = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -65,6 +66,7 @@ export const PentagonNode: React.FC = memo((props: NodeProps) => { <> nodeClick?.(e, props)} /> expandButtonClick?.(e, props, unToggleCallback)} x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 2}px`} y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2}px`} diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx index 8b55a0898586c..f923641a25a50 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui'; +import { useEuiTheme } from '@elastic/eui'; import { Handle, Position } from '@xyflow/react'; import { NodeShapeContainer, @@ -15,6 +15,7 @@ import { NodeIcon, NodeButton, HandleStyleOverride, + useNodeFillColor, } from './styles'; import type { EntityNodeViewModel, NodeProps } from '../types'; import { RectangleHoverShape, RectangleShape } from './shapes/rectangle_shape'; @@ -51,7 +52,7 @@ export const RectangleNode: React.FC = memo((props: NodeProps) => { xmlns="http://www.w3.org/2000/svg" > {icon && } @@ -60,6 +61,7 @@ export const RectangleNode: React.FC = memo((props: NodeProps) => { <> nodeClick?.(e, props)} /> expandButtonClick?.(e, props, unToggleCallback)} x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 4}px`} y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize / 2) / 2}px`} diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx index 2982c4145370e..c4305c6fded6b 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx @@ -9,7 +9,7 @@ import React from 'react'; import styled from '@emotion/styled'; import { type EuiIconProps, - type _EuiBackgroundColor, + type EuiTextProps, EuiButtonIcon, EuiIcon, EuiText, @@ -19,12 +19,14 @@ import { import { rgba } from 'polished'; import { getSpanIcon } from './get_span_icon'; import type { NodeExpandButtonProps } from './node_expand_button'; +import type { EntityNodeViewModel, LabelNodeViewModel } from '..'; export const LABEL_PADDING_X = 15; export const LABEL_BORDER_WIDTH = 1; export const NODE_WIDTH = 90; export const NODE_HEIGHT = 90; export const NODE_LABEL_WIDTH = 160; +type NodeColor = EntityNodeViewModel['color'] | LabelNodeViewModel['color']; export const LabelNodeContainer = styled.div` text-wrap: nowrap; @@ -32,8 +34,12 @@ export const LabelNodeContainer = styled.div` height: 24px; `; -export const LabelShape = styled(EuiText)` - background: ${(props) => useEuiBackgroundColor(props.color as _EuiBackgroundColor)}; +interface LabelShapeProps extends EuiTextProps { + color: LabelNodeViewModel['color']; +} + +export const LabelShape = styled(EuiText)` + background: ${(props) => useNodeFillColor(props.color)}; border: ${(props) => { const { euiTheme } = useEuiTheme(); return `solid ${ @@ -209,6 +215,11 @@ export const HandleStyleOverride: React.CSSProperties = { border: 'none', }; +export const useNodeFillColor = (color: NodeColor | undefined) => { + const fillColor = (color === 'danger' ? 'primary' : color) ?? 'primary'; + return useEuiBackgroundColor(fillColor); +}; + export const GroupStyleOverride = (size?: { width: number; height: number; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/types.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/types.ts index b09f6a29f6c62..32e34a212af59 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/types.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/types.ts @@ -12,6 +12,7 @@ import type { LabelNodeDataModel, EdgeDataModel, NodeShape, + Color as NodeColor, } from '@kbn/cloud-security-posture-common/types/graph/latest'; import type { Node, NodeProps as xyNodeProps, Edge, EdgeProps as xyEdgeProps } from '@xyflow/react'; @@ -62,7 +63,9 @@ export type EdgeProps = xyEdgeProps< Edge< EdgeViewModel & { sourceShape: NodeShape; + sourceColor: NodeColor; targetShape: NodeShape; + targetColor: NodeColor; } > >; diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts index d506bb856e766..5fb39c9be4993 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts @@ -40,6 +40,7 @@ interface GraphEdge { interface LabelEdges { source: string; target: string; + edgeType: EdgeDataModel['type']; } interface GraphContextServices { @@ -259,7 +260,17 @@ const createNodes = (records: GraphEdge[], context: Omit { // If actor is a user return ellipse if (users.includes(actorId)) { @@ -337,7 +352,7 @@ const determineEntityNodeShape = ( return { shape: 'diamond', icon: 'globe' }; } - return { shape: 'hexagon', icon: 'questionInCircle' }; + return { shape: 'hexagon' }; }; const sortNodes = (nodesMap: Record) => { @@ -368,7 +383,8 @@ const createEdgesAndGroups = (context: ParseContext) => { nodesMap, labelEdges[edgeLabelId].source, edgeLabelId, - labelEdges[edgeLabelId].target + labelEdges[edgeLabelId].target, + labelEdges[edgeLabelId].edgeType ); } else { const groupNode: GroupNodeDataModel = { @@ -377,10 +393,18 @@ const createEdgesAndGroups = (context: ParseContext) => { }; nodesMap[groupNode.id] = groupNode; let groupEdgesColor: Color = 'primary'; + let groupEdgesType: EdgeDataModel['type'] = 'dashed'; edgeLabelsIds.forEach((edgeLabelId) => { (nodesMap[edgeLabelId] as Writable).parentId = groupNode.id; - connectEntitiesAndLabelNode(edgesMap, nodesMap, groupNode.id, edgeLabelId, groupNode.id); + connectEntitiesAndLabelNode( + edgesMap, + nodesMap, + groupNode.id, + edgeLabelId, + groupNode.id, + labelEdges[edgeLabelId].edgeType + ); if ((nodesMap[edgeLabelId] as LabelNodeDataModel).color === 'danger') { groupEdgesColor = 'danger'; @@ -391,6 +415,10 @@ const createEdgesAndGroups = (context: ParseContext) => { // Use warning only if there's no danger color groupEdgesColor = 'warning'; } + + if (labelEdges[edgeLabelId].edgeType === 'solid') { + groupEdgesType = 'solid'; + } }); connectEntitiesAndLabelNode( @@ -399,6 +427,7 @@ const createEdgesAndGroups = (context: ParseContext) => { labelEdges[edgeLabelsIds[0]].source, groupNode.id, labelEdges[edgeLabelsIds[0]].target, + groupEdgesType, groupEdgesColor ); } @@ -411,11 +440,12 @@ const connectEntitiesAndLabelNode = ( sourceNodeId: string, labelNodeId: string, targetNodeId: string, + edgeType: EdgeDataModel['type'] = 'solid', colorOverride?: Color ) => { [ - connectNodes(nodesMap, sourceNodeId, labelNodeId, colorOverride), - connectNodes(nodesMap, labelNodeId, targetNodeId, colorOverride), + connectNodes(nodesMap, sourceNodeId, labelNodeId, edgeType, colorOverride), + connectNodes(nodesMap, labelNodeId, targetNodeId, edgeType, colorOverride), ].forEach((edge) => { edgesMap[edge.id] = edge; }); @@ -425,6 +455,7 @@ const connectNodes = ( nodesMap: Record, sourceNodeId: string, targetNodeId: string, + edgeType: EdgeDataModel['type'] = 'solid', colorOverride?: Color ): EdgeDataModel => { const sourceNode = nodesMap[sourceNodeId]; @@ -441,5 +472,6 @@ const connectNodes = ( source: sourceNodeId, target: targetNodeId, color: colorOverride ?? color, + type: edgeType, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation.md b/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation.md new file mode 100644 index 0000000000000..6f91d4958650c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation.md @@ -0,0 +1,559 @@ +# Installation of Prebuilt Rules + +This is a test plan for the workflows of installing prebuilt rules. + +Status: `in progress`. The current test plan matches [Rule Immutability/Customization Milestone 3 epic](https://github.com/elastic/kibana/issues/174168). + +## Table of Contents + +- [Useful information](#useful-information) + - [Tickets](#tickets) + - [Terminology](#terminology) + - [Assumptions](#assumptions) + - [Non-functional requirements](#non-functional-requirements) + - [Functional requirements](#functional-requirements) +- [Scenarios](#scenarios) + - [Rule installation notifications on the Rule Management page](#rule-installation-and-upgrade-notifications-on-the-rule-management-page) + - [**Scenario: User is NOT notified when no prebuilt rules are installed and there are no prebuilt rules assets**](#scenario-user-is-not-notified-when-no-prebuilt-rules-are-installed-and-there-are-no-prebuilt-rules-assets) + - [**Scenario: User is NOT notified when all prebuilt rules are installed and up to date**](#scenario-user-is-not-notified-when-all-prebuilt-rules-are-installed-and-up-to-date) + - [**Scenario: User is notified when no prebuilt rules are installed and there are rules available to install**](#scenario-user-is-notified-when-no-prebuilt-rules-are-installed-and-there-are-rules-available-to-install) + - [**Scenario: User is notified when some prebuilt rules can be installed**](#scenario-user-is-notified-when-some-prebuilt-rules-can-be-installed) + - [**Scenario: User is notified when both rules to install and upgrade are available**](#scenario-user-is-notified-when-both-rules-to-install-and-upgrade-are-available) + - [**Scenario: User is notified after a prebuilt rule gets deleted**](#scenario-user-is-notified-after-a-prebuilt-rule-gets-deleted) + - [Rule installation workflow: base cases](#rule-installation-workflow-base-cases) + - [**Scenario: User can install prebuilt rules one by one**](#scenario-user-can-install-prebuilt-rules-one-by-one) + - [**Scenario: User can install multiple prebuilt rules selected on the page**](#scenario-user-can-install-multiple-prebuilt-rules-selected-on-the-page) + - [**Scenario: User can install all available prebuilt rules at once**](#scenario-user-can-install-all-available-prebuilt-rules-at-once) + - [**Scenario: Empty screen is shown when all prebuilt rules are installed**](#scenario-empty-screen-is-shown-when-all-prebuilt-rules-are-installed) + - [**Scenario: User can preview rules available for installation**](#scenario-user-can-preview-rules-available-for-installation) + - [**Scenario: User can install a rule using the rule preview**](#scenario-user-can-install-a-rule-using-the-rule-preview) + - [**Scenario: User can see correct rule information in preview before installing**](#scenario-user-can-see-correct-rule-information-in-preview-before-installing) + - [**Scenario: Tabs and sections without content should be hidden in preview before installing**](#scenario-tabs-and-sections-without-content-should-be-hidden-in-preview-before-installing) + - [Rule installation workflow: filtering, sorting, pagination](#rule-installation-workflow-filtering-sorting-pagination) + - [Rule installation workflow: misc cases](#rule-installation-workflow-misc-cases) + - [**Scenario: User opening the Add Rules page sees a loading skeleton until the package installation is completed**](#scenario-user-opening-the-add-rules-page-sees-a-loading-skeleton-until-the-package-installation-is-completed) + - [**Scenario: User can navigate from the Add Rules page to the Rule Management page via breadcrumbs**](#scenario-user-can-navigate-from-the-add-rules-page-to-the-rule-management-page-via-breadcrumbs) + - [Rule installation and upgrade via the Prebuilt rules API](#rule-installation-and-upgrade-via-the-prebuilt-rules-api) + - [**Scenario: API can install all prebuilt rules**](#scenario-api-can-install-all-prebuilt-rules) + - [**Scenario: API can install prebuilt rules that are not yet installed**](#scenario-api-can-install-prebuilt-rules-that-are-not-yet-installed) + - [**Scenario: API does not install prebuilt rules if they are up to date**](#scenario-api-does-not-installupgrade-prebuilt-rules-if-they-are-up-to-date) + - [Error handling](#error-handling) + - [**Scenario: Error is handled when any operation on prebuilt rules fails**](#scenario-error-is-handled-when-any-operation-on-prebuilt-rules-fails) + - [Authorization / RBAC](#authorization--rbac) + - [**Scenario: User with read privileges on Security Solution cannot install prebuilt rules**](#scenario-user-with-read-privileges-on-security-solution-cannot-install-prebuilt-rules) + +## Useful information + +### Tickets + +- [Rule Immutability/Customization epic](https://github.com/elastic/security-team/issues/1974)(internal) + +**Milestone 3 - Prebuilt Rules Customization:** +- [Milestone 3 epic ticket](https://github.com/elastic/kibana/issues/174168) +- [Tests for prebuilt rule upgrade workflow #202078](https://github.com/elastic/kibana/issues/202078) + +**Milestone 2:** +- [Ensure full test coverage for existing workflows of installing and upgrading prebuilt rules](https://github.com/elastic/kibana/issues/148176) +- [Write test plan and add test coverage for the new workflows of installing and upgrading prebuilt rules](https://github.com/elastic/kibana/issues/148192) + +### Terminology + +- **EPR**: [Elastic Package Registry](https://github.com/elastic/package-registry), service that hosts our **Package**. + +- **Package**: `security_detection_engine` Fleet package that we use to distribute prebuilt detection rules in the form of `security-rule` assets (saved objects). + +- **Real package**: actual latest stable package distributed and pulled from EPR via Fleet. + +- **Mock rules**: `security-rule` assets that are indexed into the `.kibana_security_solution` index directly in the test setup, either by using the ES client _in integration tests_ or by an API request _in Cypress tests_. + +- **Air-gapped environment**: an environment where Kibana doesn't have access to the internet. In general, EPR is not available in such environments, except the cases when the user runs a custom EPR inside the environment. + +- **CTA**: "call to action", usually a button, a link, or a callout message with a button, etc, that invites the user to do some action. + - CTA to install prebuilt rules - at this moment, it's a link button with a counter (implemented) and a callout with a link button (not yet implemented) on the Rule Management page. + - CTA to upgrade prebuilt rules - at this moment, it's a tab with a counter (implemented) and a callout with a link button (not yet implemented) on the Rule Management page. + +### Assumptions + +- Below scenarios only apply to prebuilt detection rules. +- Users should be able to install prebuilt rules on the `Basic` license and higher. +- EPR is available for fetching the package unless explicitly indicated otherwise. +- Only the latest **stable** package is checked for installation/upgrade and pre-release packages are ignored. + +### Non-functional requirements + +- Notifications, rule installation workflows should work: + - regardless of the package type: with historical rule versions or without; + - regardless of the package registry availability: i.e., they should also work in air-gapped environments. +- Rule installation and upgrade workflows should work with packages containing up to 15000 historical rule versions. This is the max number of versions of all rules in the package. This limit is enforced by Fleet. +- Kibana should not crash with Out Of Memory exception during package installation. +- For test purposes, it should be possible to use detection rules package versions lower than the latest. + +### Functional requirements + +- User should be able to install prebuilt rules with and without previewing what exactly they would install (rule properties). +- If user chooses to preview a prebuilt rule to be installed/upgraded, we currently show this preview in a flyout. +- In the prebuilt rule preview a tab that doesn't have any sections should not be displayed and a section that doesn't have any properties also should not be displayed. + +Examples of rule properties we show in the prebuilt rule preview flyout: + +```Gherkin +Examples: +| rule_type | property | tab | section | +│ All rule types │ Author │ Overview │ About │ +│ All rule types │ Building block │ Overview │ About │ +│ All rule types │ Severity │ Overview │ About │ +│ All rule types │ Severity override │ Overview │ About │ +│ All rule types │ Risk score │ Overview │ About │ +│ All rule types │ Risk score override │ Overview │ About │ +│ All rule types │ Reference URLs │ Overview │ About │ +│ All rule types │ False positive examples │ Overview │ About │ +│ All rule types │ Custom highlighted fields │ Overview │ About │ +│ All rule types │ License │ Overview │ About │ +│ All rule types │ Rule name override │ Overview │ About │ +│ All rule types │ MITRE ATT&CK™ │ Overview │ About │ +│ All rule types │ Timestamp override │ Overview │ About │ +│ All rule types │ Tags │ Overview │ About │ +│ All rule types │ Type │ Overview │ Definition │ +│ All rule types │ Related integrations │ Overview │ Definition │ +│ All rule types │ Required fields │ Overview │ Definition │ +│ All rule types │ Timeline template │ Overview │ Definition │ +│ All rule types │ Runs every │ Overview │ Schedule │ +│ All rule types │ Additional look-back time │ Overview │ Schedule │ +│ All rule types │ Setup guide │ Overview │ Setup guide │ +│ All rule types │ Investigation guide │ Investigation guide │ Investigation guide │ +│ Custom Query │ Index patterns │ Overview │ Definition │ +│ Custom Query │ Data view ID │ Overview │ Definition │ +│ Custom Query │ Data view index pattern │ Overview │ Definition │ +│ Custom Query │ Custom query │ Overview │ Definition │ +│ Custom Query │ Filters │ Overview │ Definition │ +│ Custom Query │ Saved query name │ Overview │ Definition │ +│ Custom Query │ Saved query filters │ Overview │ Definition │ +│ Custom Query │ Saved query │ Overview │ Definition │ +│ Custom Query │ Suppress alerts by │ Overview │ Definition │ +│ Custom Query │ Suppress alerts for │ Overview │ Definition │ +│ Custom Query │ If a suppression field is missing │ Overview │ Definition │ +│ Machine Learning │ Anomaly score threshold │ Overview │ Definition │ +│ Machine Learning │ Machine Learning job │ Overview │ Definition │ +│ Threshold │ Threshold │ Overview │ Definition │ +│ Threshold │ Index patterns │ Overview │ Definition │ +│ Threshold │ Data view ID │ Overview │ Definition │ +│ Threshold │ Data view index pattern │ Overview │ Definition │ +│ Threshold │ Custom query │ Overview │ Definition │ +│ Threshold │ Filters │ Overview │ Definition │ +│ Event Correlation │ EQL query │ Overview │ Definition │ +│ Event Correlation │ Filters │ Overview │ Definition │ +│ Event Correlation │ Index patterns │ Overview │ Definition │ +│ Event Correlation │ Data view ID │ Overview │ Definition │ +│ Event Correlation │ Data view index pattern │ Overview │ Definition │ +│ Indicator Match │ Indicator index patterns │ Overview │ Definition │ +│ Indicator Match │ Indicator mapping │ Overview │ Definition │ +│ Indicator Match │ Indicator filters │ Overview │ Definition │ +│ Indicator Match │ Indicator index query │ Overview │ Definition │ +│ Indicator Match │ Index patterns │ Overview │ Definition │ +│ Indicator Match │ Data view ID │ Overview │ Definition │ +│ Indicator Match │ Data view index pattern │ Overview │ Definition │ +│ Indicator Match │ Custom query │ Overview │ Definition │ +│ Indicator Match │ Filters │ Overview │ Definition │ +│ New Terms │ Fields │ Overview │ Definition │ +│ New Terms │ History Window Size │ Overview │ Definition │ +│ New Terms │ Index patterns │ Overview │ Definition │ +│ New Terms │ Data view ID │ Overview │ Definition │ +│ New Terms │ Data view index pattern │ Overview │ Definition │ +│ New Terms │ Custom query │ Overview │ Definition │ +│ New Terms │ Filters │ Overview │ Definition │ +│ ESQL │ ESQL query │ Overview │ Definition │ +│ ESQL │ Suppress alerts by │ Overview │ Definition │ +│ ESQL │ Suppress alerts for │ Overview │ Definition │ +│ ESQL │ If a suppression field is missing │ Overview │ Definition │ +``` + +## Scenarios + +### Rule installation notifications on the Rule Management page + +#### **Scenario: User is NOT notified when no prebuilt rules are installed and there are no prebuilt rules assets** + +**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. + +```Gherkin +Given no prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +When user opens the Rule Management page +Then user should NOT see a CTA to install prebuilt rules +And user should NOT see a number of rules available to install +And user should NOT see a CTA to upgrade prebuilt rules +And user should NOT see a number of rules available to upgrade +And user should NOT see the Rule Updates table +``` + +#### **Scenario: User is NOT notified when all prebuilt rules are installed and up to date** + +**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. + +```Gherkin +Given the latest prebuilt rule assets exist in Kibana +And all the latest prebuilt rules from those rule assets are installed +When user opens the Rule Management page +Then user should NOT see a CTA to install prebuilt rules +And user should NOT see a number of rules available to install +And user should NOT see a CTA to upgrade prebuilt rules +And user should NOT see a number of rules available to upgrade +And user should NOT see the Rule Updates table +``` + +#### **Scenario: User is notified when no prebuilt rules are installed and there are rules available to install** + +**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. + +```Gherkin +Given X prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +And there are X prebuilt rules available to install +When user opens the Rule Management page +Then user should see a CTA to install prebuilt rules +And user should see a number of rules available to install (X) +And user should NOT see a CTA to upgrade prebuilt rules +And user should NOT see a number of rules available to upgrade +And user should NOT see the Rule Updates table +``` + +#### **Scenario: User is notified when some prebuilt rules can be installed** + +**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. + +```Gherkin +Given Y prebuilt rule assets exist in Kibana +And X (where X < Y) prebuilt rules are installed +And there are Y more prebuilt rules available to install +And for all X installed rules there are no new versions available +When user opens the Rule Management page +Then user should see a CTA to install prebuilt rules +And user should see the number of rules available to install (Y) +And user should NOT see a CTA to upgrade prebuilt rules +And user should NOT see a number of rules available to upgrade +And user should NOT see the Rule Updates table +``` + +#### **Scenario: User is notified when both rules to install and upgrade are available** + +**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. + +```Gherkin +Given Y prebuilt rule assets exist in Kibana +And X (where X < Y) prebuilt rules are installed +And Z (where Z < X) installed rules have matching prebuilt rule assets with higher version available +And for Z of the installed rules there are new versions available +When user opens the Rule Management page +Then user should see a CTA to install prebuilt rules +And user should see the number of rules available to install (Y) +And user should see a CTA to upgrade prebuilt rules +And user should see the number of rules available to upgrade (Z) +``` + +#### **Scenario: User is notified after a prebuilt rule gets deleted** + +**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. + +```Gherkin +Given X prebuilt rules are installed in Kibana +And there are no more prebuilt rules available to install +When user opens the Rule Management page +And user deletes Y prebuilt rules +Then user should see a CTA to install prebuilt rules +And user should see rules available to install +``` + +### Rule installation workflow: base cases + +#### **Scenario: User can install prebuilt rules one by one** + +**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /installation/\* endpoints in integration. + +```Gherkin +Given X prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +When user opens the Add Rules page +Then prebuilt rules available for installation should be displayed in the table +When user installs one individual rule without previewing it +Then success message should be displayed after installation +And the installed rule should be removed from the table +When user navigates back to the Rule Management page +Then user should see a CTA to install prebuilt rules +And user should see the number of rules available to install decreased by 1 +``` + +#### **Scenario: User can install multiple prebuilt rules selected on the page** + +**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /installation/\* endpoints in integration. + +```Gherkin +Given X prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +When user opens the Add Rules page +Then prebuilt rules available for installation should be displayed in the table +When user selects rules +Then user should see a CTA to install number of rules +When user clicks the CTA +Then success message should be displayed after installation +And all the installed rules should be removed from the table +When user navigates back to the Rule Management page +Then user should see a CTA to install prebuilt rules +And user should see the number of rules available to install decreased by number of installed rules + +Examples: + | Y | + | a few rules on the page, e.g. 2 | + | all rules on the page, e.g. 12 | +``` + +#### **Scenario: User can install all available prebuilt rules at once** + +**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /installation/\* endpoints in integration. + +```Gherkin +Given X prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +When user opens the Add Rules page +Then prebuilt rules available for installation should be displayed in the table +When user installs all rules +Then success message should be displayed after installation +And all the rules should be removed from the table +And user should see a message indicating that all available rules have been installed +And user should see a CTA that leads to the Rule Management page +When user clicks on the CTA +Then user should be navigated back to Rule Management page +And user should NOT see a CTA to install prebuilt rules +And user should NOT see a number of rules available to install +``` + +#### **Scenario: Empty screen is shown when all prebuilt rules are installed** + +**Automation**: 1 e2e test with mock rules + 1 integration test. + +```Gherkin +Given all the available prebuilt rules are installed in Kibana +When user opens the Add Rules page +Then user should see a message indicating that all available rules have been installed +And user should see a CTA that leads to the Rule Management page +``` + +#### **Scenario: User can preview rules available for installation** + +**Automation**: 1 e2e test + +```Gherkin +Given 2 prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +When user opens the Add Rules page +Then all rules available for installation should be displayed in the table +When user opens the rule preview for the 1st rule +Then the preview should open +When user closes the preview +Then it should disappear +``` + +#### **Scenario: User can install a rule using the rule preview** + +**Automation**: 1 e2e test + +```Gherkin +Given 2 prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +When user opens the Add Rules page +Then all rules available for installation should be displayed in the table +When user opens the rule preview for the rule +Then the preview should open +When user installs the rule using a CTA in the rule preview +Then the rule should be installed +And a success message should be displayed after installation +And the rule should be removed from the Add Rules table +When user navigates back to the Rule Management page +Then user should see a CTA to install prebuilt rules +And user should see the number of rules available to install as initial number minus 1 +``` + +#### **Scenario: User can see correct rule information in preview before installing** + +**Automation**: 1 e2e test + +```Gherkin +Given X prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +When user opens the Add Rules page +Then all X rules available for installation should be displayed in the table +When user opens a rule preview for any rule +Then the preview should appear +And all properties of a rule should be displayed in the correct tab and section of the preview (see examples of rule properties above) +``` + +#### **Scenario: Optional tabs and sections without content should be hidden in preview before installing** + +**Automation**: 1 e2e test + +```Gherkin +Given 1 prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +And this rule has neither Setup guide nor Investigation guide +When user opens the Add Rules page +Then all rules available for installation should be displayed in the table +When user opens the rule preview for this rule +Then the preview should open +And the Setup Guide section should NOT be displayed in the Overview tab +And the Investigation Guide tab should NOT be displayed +``` + +### Rule installation workflow: filtering, sorting, pagination + +TODO: add scenarios https://github.com/elastic/kibana/issues/166215 + +### Rule installation workflow: misc cases + +#### **Scenario: User opening the Add Rules page sees a loading skeleton until the package installation is completed** + +**Automation**: unit tests. + +```Gherkin +Given prebuilt rules package is not installed +When user opens the Add Rules page +Then user should see a loading skeleton until the package installation is completed +``` + +#### **Scenario: User can navigate from the Add Rules page to the Rule Management page via breadcrumbs** + +**Automation**: 1 e2e test. + +```Gherkin +Given user is on the Add Rules page +When user navigates to the Rule Management page via breadcrumbs +Then the Rule Management page should be displayed +``` + +### Rule installation via the Prebuilt rules API + +There's a legacy prebuilt rules API and a new one. Both should be tested against two types of the package: with and without historical rule versions. + +#### **Scenario: API can install all prebuilt rules** + +**Automation**: 8 integration tests with mock rules: 4 examples below \* 2 (we split checking API response and installed rules into two different tests). + +```Gherkin +Given the package is installed +And the package contains N rules +When user installs all rules via install +Then the endpoint should return success response (HTTP 200 code) with +And N rule objects should be created +And each rule object should have correct id and version + +Examples: + | package_type | api | install_response | + | with historical versions | legacy | installed: N, updated: 0 | + | w/o historical versions | legacy | installed: N, updated: 0 | + | with historical versions | new | total: N, succeeded: N | + | w/o historical versions | new | total: N, succeeded: N | +``` + +Notes: + +- Legacy API: + - install: `PUT /api/detection_engine/rules/prepackaged` +- New API: + - install: `POST /internal/detection_engine/prebuilt_rules/installation/_perform` + +#### **Scenario: API can install prebuilt rules that are not yet installed** + +**Automation**: 4 integration tests with mock rules. + +```Gherkin +Given the package is installed +And the package contains N rules +When user installs all rules via install +And deletes one of the installed rules +And gets prebuilt rules status via status +Then the endpoint should return successful response (HTTP 200 code) with +When user installs all rules via install again +Then the endpoint should return successful response (HTTP 200 code) with + +Examples: + | package_type | api | status_response | install_response | + | with historical versions | legacy | not_installed: 1 | installed: 1, updated: 0 | + | w/o historical versions | legacy | not_installed: 1 | installed: 1, updated: 0 | + | with historical versions | new | to_install: 1 | total: 1, succeeded: 1 | + | w/o historical versions | new | to_install: 1 | total: 1, succeeded: 1 | +``` + +Notes: + +- Legacy API: + - install: `PUT /api/detection_engine/rules/prepackaged` + - status: `GET /api/detection_engine/rules/prepackaged/_status` +- New API: + - install: `POST /internal/detection_engine/prebuilt_rules/installation/_perform` + - status: `GET /internal/detection_engine/prebuilt_rules/status` + + +#### **Scenario: API does not install prebuilt rules if they are up to date** + +**Automation**: 4 integration tests with mock rules. + +```Gherkin +Given the package is installed +And the package contains N rules +When user installs all rules via install +And user gets prebuilt rules status via status +Then the endpoint should return successful response (HTTP 200 code) with +When user calls install +Then the endpoint should return successful response (HTTP 200 code) with +When user calls upgrade +Then the endpoint should return successful response (HTTP 200 code) with + +Examples: + | package_type | api | status_response | install_response | upgrade_response | + | with historical versions | legacy | not_installed: 0, not_updated: 0 | installed: 0, updated: 0 | installed: 0, updated: 0 | + | w/o historical versions | legacy | not_installed: 0, not_updated: 0 | installed: 0, updated: 0 | installed: 0, updated: 0 | + | with historical versions | new | to_install: 0, to_upgrade: 0 | total: 0, succeeded: 0 | total: 0, succeeded: 0 | + | w/o historical versions | new | to_install: 0, to_upgrade: 0 | total: 0, succeeded: 0 | total: 0, succeeded: 0 | +``` + +Notes: + +- Legacy API: + - install: `PUT /api/detection_engine/rules/prepackaged` + - upgrade: `PUT /api/detection_engine/rules/prepackaged` + - status: `GET /api/detection_engine/rules/prepackaged/_status` +- New API: + - install: `POST /internal/detection_engine/prebuilt_rules/installation/_perform` + - upgrade: `POST /internal/detection_engine/prebuilt_rules/upgrade/_perform` + - status: `GET /internal/detection_engine/prebuilt_rules/status` + +### Error handling + +#### **Scenario: Error is handled when any installation operation on prebuilt rules fails** + +**Automation**: e2e test with mock rules + +```Gherkin +When user is prebuilt rules +And this operation fails +Then user should see an error message + +Examples: + | operation | + | installing all | + | installing selected | + | installing individual | +``` + +### Authorization / RBAC + +#### **Scenario: User with read privileges on Security Solution cannot install prebuilt rules** + +**Automation**: 1 e2e test with mock rules + 3 integration tests with mock rules for the status and installation endpoints. + +```Gherkin +Given user with "Security: read" privileges on Security Solution +And prebuilt rule assets exist in Kibana +And no prebuilt rules are installed +When user opens the Add Rules page +Then user should see prebuilt rules available to install +But user should not be able to install them +``` \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/package_installation_and_upgrade.md b/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/package_installation_and_upgrade.md new file mode 100644 index 0000000000000..57e16684facca --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/package_installation_and_upgrade.md @@ -0,0 +1,165 @@ +# Prebuilt Rules Package Installation and Upgrade + +This is a test plan for the workflows of installing and updating the Prebuilt Rules package. + +> For the test plans for installing and upgrading prebuilt rules, see [Installation of Prebuilt Rules](./installation.md) and [Upgrade of Prebuilt Rules](./upgrade.md). + +Status: `in progress`. The current test plan matches [Rule Immutability/Customization Milestone 3 epic](https://github.com/elastic/kibana/issues/174168). + +## Table of Contents + +- [Useful information](#useful-information) + - [Tickets](#tickets) + - [Terminology](#terminology) + - [Assumptions](#assumptions) + - [Non-functional requirements](#non-functional-requirements) + - [Functional requirements](#functional-requirements) +- [Scenarios](#scenarios) + - [Package installation](#package-installation) + - [**Scenario: Package is installed via Fleet**](#scenario-package-is-installed-via-fleet) + - [**Scenario: Package is installed via bundled Fleet package in Kibana**](#scenario-package-is-installed-via-bundled-fleet-package-in-kibana) + - [**Scenario: Large package can be installed on a small Kibana instance**](#scenario-large-package-can-be-installed-on-a-small-kibana-instance) + - [Scenarios for the real package](#scenarios-for-the-real-package) + - [**Scenario: User can install prebuilt rules from scratch, then install new rules and upgrade existing rules from the new package**](#scenario-user-can-install-prebuilt-rules-from-scratch-then-install-new-rules-and-upgrade-existing-rules-from-the-new-package) + - [Kibana upgrade](#kibana-upgrade) + - [**Scenario: User can use prebuilt rules after upgrading Kibana from version A to B**](#scenario-user-can-use-prebuilt-rules-after-upgrading-kibana-from-version-a-to-b) + +## Useful information + +### Tickets + +- [Rule Immutability/Customization epic](https://github.com/elastic/security-team/issues/1974)(internal) + +**Milestone 3 - Prebuilt Rules Customization:** +- [Milestone 3 epic ticket](https://github.com/elastic/kibana/issues/174168) +- [Tests for prebuilt rule upgrade workflow #202078](https://github.com/elastic/kibana/issues/202078) + +**Milestone 2:** +- [Ensure full test coverage for existing workflows of installing and upgrading prebuilt rules](https://github.com/elastic/kibana/issues/148176) +- [Write test plan and add test coverage for the new workflows of installing and upgrading prebuilt rules](https://github.com/elastic/kibana/issues/148192) + +### Terminology + +- **EPR**: [Elastic Package Registry](https://github.com/elastic/package-registry), service that hosts our **Package**. + +- **Package**: `security_detection_engine` Fleet package that we use to distribute prebuilt detection rules in the form of `security-rule` assets (saved objects). + +- **Real package**: actual latest stable package distributed and pulled from EPR via Fleet. + +- **Mock rules**: `security-rule` assets that are indexed into the `.kibana_security_solution` index directly in the test setup, either by using the ES client _in integration tests_ or by an API request _in Cypress tests_. + +- **Air-gapped environment**: an environment where Kibana doesn't have access to the internet. In general, EPR is not available in such environments, except the cases when the user runs a custom EPR inside the environment. + +- **CTA**: "call to action", usually a button, a link, or a callout message with a button, etc, that invites the user to do some action. + - CTA to install prebuilt rules - at this moment, it's a link button with a counter (implemented) and a callout with a link button (not yet implemented) on the Rule Management page. + - CTA to upgrade prebuilt rules - at this moment, it's a tab with a counter (implemented) and a callout with a link button (not yet implemented) on the Rule Management page. + +### Assumptions + +- Below scenarios only apply to prebuilt detection rules. +- Users should be able to install and upgrade prebuilt rules on the `Basic` license and higher. +- EPR is available for fetching the package unless explicitly indicated otherwise. +- Only the latest **stable** package is checked for installation/upgrade and pre-release packages are ignored. + +### Non-functional requirements + +- Package installation, rule installation and rule upgrade workflows should work: + - regardless of the package type: with historical rule versions or without; + - regardless of the package registry availability: i.e., they should also work in air-gapped environments. +- Rule installation and upgrade workflows should work with packages containing up to 15000 historical rule versions. This is the max number of versions of all rules in the package. This limit is enforced by Fleet. +- Kibana should not crash with Out Of Memory exception during package installation. +- For test purposes, it should be possible to use detection rules package versions lower than the latest. + +### Functional requirements + +- User should be able to install prebuilt rules with and without previewing what exactly they would install (rule properties). +- User should be able to upgrade prebuilt rules with and without previewing what updates they would apply (rule properties of target rule versions). + +## Scenarios + +### Package installation + +#### **Scenario: Package is installed via Fleet** + +**Automation**: 2 e2e tests that install the real package. + +```Gherkin +Given the prebuilt rules package is not installed +When user opens any Security Solution page +Then the package gets installed in the background from EPR +``` + +#### **Scenario: Package is installed via bundled Fleet package in Kibana** + +**Automation**: 2 integration tests. + +```Gherkin +Given the package is not installed +And user is in an air-gapped environment +When user opens any Security Solution page +Then the package gets installed in the background from packages bundled into Kibana +``` + +#### **Scenario: Large package can be installed on a memory restricted Kibana instance** + +**Automation**: 1 integration test. + +```Gherkin +Given the package is not installed +And the package contains the largest amount of historical rule versions (15000) +And the Kibana instance has a memory heap size of 700 Mb (see note below) +When user opens any Security Solution page +Then the package is installed without Kibana crashing with an Out Of Memory error +``` + +**Note**: 600 Mb seems to always crash Kibana with an OOM error. 700 Mb runs with no issues in the Flaky test runner with 100 iterations: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2215. + +### Scenarios for the real package + +#### **Scenario: User can install prebuilt rules from scratch, then install new rules and upgrade existing rules from the new package** + +**Automation**: 1 integration test with real packages. + +```Gherkin +Given there are two package versions: A and B where A < B +And the package of A version is installed +When user calls the status endpoint +Then it should return a 200 response with some number of rules to install and 0 rules to upgrade +When user calls the installation/_review endpoint +Then it should return a 200 response matching the response of the status endpoint +When user calls the installation/_perform_ endpoint +Then it should return a 200 response matching the response of the status endpoint +And rules returned in this response should exist as alert saved objects +When user installs version B of the package +Then it should be installed successfully +When user calls the status endpoint +Then it should return a 200 response with some number of new rules to install and some number of rules to upgrade +When user calls the installation/_review endpoint +Then it should return a 200 response matching the response of the status endpoint +When user calls the installation/_perform_ endpoint +Then rules returned in this response should exist as alert saved objects +When user calls the upgrade/_review endpoint +Then it should return a 200 response matching the response of the status endpoint +When user calls the upgrade/_perform_ endpoint +Then rules returned in this response should exist as alert saved objects +``` + +### Kibana upgrade + +#### **Scenario: User can use prebuilt rules after upgrading Kibana from version A to B** + +**Automation**: not automated, manual testing required. + +```Gherkin +Given user is upgrading Kibana from version to version +And the version Kibana instance has already installed prebuilt rules +When the Kibana upgrade is complete +Then user should be able to install new prebuilt rules +And delete installed prebuilt rules +And upgrade installed prebuilt rules that have newer versions in Kibana version + +Examples: + | A | B | + | 8.7 | 8.9.0 | + | 7.17.x | 8.9.0 | +``` diff --git a/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md b/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/upgrade.md similarity index 55% rename from x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md rename to x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/upgrade.md index e5479ec502865..4beb517f9598a 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/installation_and_upgrade.md +++ b/x-pack/solutions/security/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules/upgrade.md @@ -1,8 +1,8 @@ -# Installation and Upgrade of Prebuilt Rules +# Upgrade of Prebuilt Rules -This is a test plan for the workflows of installing and upgrading prebuilt rules. +This is a test plan for the workflow of upgrading prebuilt rules. -Status: `in progress`. The current test plan matches `Milestone 2` of the [Rule Immutability/Customization](https://github.com/elastic/security-team/issues/1974) epic. It does not cover any past functionality that was removed or functionality to be implemented in the future. The plan is about to change in the future Milestones. +Status: `in progress`. The current test plan matches [Rule Immutability/Customization Milestone 3 epic](https://github.com/elastic/kibana/issues/174168). ## Table of Contents @@ -12,84 +12,73 @@ Status: `in progress`. The current test plan matches `Milestone 2` of the [Rule - [Assumptions](#assumptions) - [Non-functional requirements](#non-functional-requirements) - [Functional requirements](#functional-requirements) -- [Scenarios](#scenarios) - - - [Package installation](#package-installation) - - [**Scenario: Package is installed via Fleet**](#scenario-package-is-installed-via-fleet) - - [**Scenario: Package is installed via bundled Fleet package in Kibana**](#scenario-package-is-installed-via-bundled-fleet-package-in-kibana) - - [**Scenario: Large package can be installed on a small Kibana instance**](#scenario-large-package-can-be-installed-on-a-small-kibana-instance) - - [Rule installation and upgrade via the Prebuilt rules API](#rule-installation-and-upgrade-via-the-prebuilt-rules-api) - - [**Scenario: API can install all prebuilt rules**](#scenario-api-can-install-all-prebuilt-rules) - - [**Scenario: API can install prebuilt rules that are not yet installed**](#scenario-api-can-install-prebuilt-rules-that-are-not-yet-installed) - - [**Scenario: API can upgrade prebuilt rules that are outdated**](#scenario-api-can-upgrade-prebuilt-rules-that-are-outdated) - - [**Scenario: API does not install or upgrade prebuilt rules if they are up to date**](#scenario-api-does-not-install-or-upgrade-prebuilt-rules-if-they-are-up-to-date) - - [Scenarios for the real package](#scenarios-for-the-real-package) - - [**Scenario: User can install prebuilt rules from scratch, then install new rules and upgrade existing rules from the new package**](#scenario-user-can-install-prebuilt-rules-from-scratch-then-install-new-rules-and-upgrade-existing-rules-from-the-new-package) - - [Rule installation and upgrade notifications on the Rule Management page](#rule-installation-and-upgrade-notifications-on-the-rule-management-page) - - [**Scenario: User is NOT notified when no prebuilt rules are installed and there are no prebuilt rules assets**](#scenario-user-is-not-notified-when-no-prebuilt-rules-are-installed-and-there-are-no-prebuilt-rules-assets) - - [**Scenario: User is NOT notified when all prebuilt rules are installed and up to date**](#scenario-user-is-not-notified-when-all-prebuilt-rules-are-installed-and-up-to-date) - - [**Scenario: User is notified when no prebuilt rules are installed and there are rules available to install**](#scenario-user-is-notified-when-no-prebuilt-rules-are-installed-and-there-are-rules-available-to-install) - - [**Scenario: User is notified when some prebuilt rules can be installed**](#scenario-user-is-notified-when-some-prebuilt-rules-can-be-installed) - - [**Scenario: User is notified when some prebuilt rules can be upgraded**](#scenario-user-is-notified-when-some-prebuilt-rules-can-be-upgraded) - - [**Scenario: User is notified when both rules to install and upgrade are available**](#scenario-user-is-notified-when-both-rules-to-install-and-upgrade-are-available) - - [**Scenario: User is notified after a prebuilt rule gets deleted**](#scenario-user-is-notified-after-a-prebuilt-rule-gets-deleted) - - [Rule installation workflow: base cases](#rule-installation-workflow-base-cases) - - [**Scenario: User can install prebuilt rules one by one**](#scenario-user-can-install-prebuilt-rules-one-by-one) - - [**Scenario: User can install multiple prebuilt rules selected on the page**](#scenario-user-can-install-multiple-prebuilt-rules-selected-on-the-page) - - [**Scenario: User can install all available prebuilt rules at once**](#scenario-user-can-install-all-available-prebuilt-rules-at-once) - - [**Scenario: Empty screen is shown when all prebuilt rules are installed**](#scenario-empty-screen-is-shown-when-all-prebuilt-rules-are-installed) - - [**Scenario: User can preview rules available for installation**](#scenario-user-can-preview-rules-available-for-installation) - - [**Scenario: User can install a rule using the rule preview**](#scenario-user-can-install-a-rule-using-the-rule-preview) - - [**Scenario: User can see correct rule information in preview before installing**](#scenario-user-can-see-correct-rule-information-in-preview-before-installing) - - [**Scenario: Tabs and sections without content should be hidden in preview before installing**](#scenario-tabs-and-sections-without-content-should-be-hidden-in-preview-before-installing) - - [Rule installation workflow: filtering, sorting, pagination](#rule-installation-workflow-filtering-sorting-pagination) - - [Rule installation workflow: misc cases](#rule-installation-workflow-misc-cases) - - [**Scenario: User opening the Add Rules page sees a loading skeleton until the package installation is completed**](#scenario-user-opening-the-add-rules-page-sees-a-loading-skeleton-until-the-package-installation-is-completed) - - [**Scenario: User can navigate from the Add Rules page to the Rule Management page via breadcrumbs**](#scenario-user-can-navigate-from-the-add-rules-page-to-the-rule-management-page-via-breadcrumbs) - - [Rule upgrade workflow: base cases](#rule-upgrade-workflow-base-cases) - - [**Scenario: User can upgrade prebuilt rules one by one**](#scenario-user-can-upgrade-prebuilt-rules-one-by-one) - - [**Scenario: User can upgrade multiple prebuilt rules selected on the page**](#scenario-user-can-upgrade-multiple-prebuilt-rules-selected-on-the-page) - - [**Scenario: User can upgrade all available prebuilt rules at once**](#scenario-user-can-upgrade-all-available-prebuilt-rules-at-once) - - [**Scenario: User can preview rules available for upgrade**](#scenario-user-can-preview-rules-available-for-upgrade) - - [**Scenario: User can upgrade a rule using the rule preview**](#scenario-user-can-upgrade-a-rule-using-the-rule-preview) - - [**Scenario: User can see correct rule information in preview before upgrading**](#scenario-user-can-see-correct-rule-information-in-preview-before-upgrading) - - [**Scenario: Tabs and sections without content should be hidden in preview before upgrading**](#scenario-tabs-and-sections-without-content-should-be-hidden-in-preview-before-upgrading) - - [Rule upgrade workflow: filtering, sorting, pagination](#rule-upgrade-workflow-filtering-sorting-pagination) - - [Rule upgrade workflow: viewing rule changes in JSON diff view](#rule-upgrade-workflow-viewing-rule-changes-in-json-diff-view) - - [**Scenario: User can see changes in a side-by-side JSON diff view**](#scenario-user-can-see-changes-in-a-side-by-side-json-diff-view) - - [**Scenario: User can see precisely how property values would change after upgrade**](#scenario-user-can-see-precisely-how-property-values-would-change-after-upgrade) - - [**Scenario: Rule actions and exception lists should not be shown as modified**](#scenario-rule-actions-and-exception-lists-should-not-be-shown-as-modified) - - [**Scenario: Dynamic properties should not be included in preview**](#scenario-dynamic-properties-should-not-be-included-in-preview) - - [**Scenario: Technical properties should not be included in preview**](#scenario-technical-properties-should-not-be-included-in-preview) - - [**Scenario: Properties with semantically equal values should not be shown as modified**](#scenario-properties-with-semantically-equal-values-should-not-be-shown-as-modified) - - [**Scenario: Unchanged sections of a rule should be hidden by default**](#scenario-unchanged-sections-of-a-rule-should-be-hidden-by-default) - - [**Scenario: Properties should be sorted alphabetically**](#scenario-properties-should-be-sorted-alphabetically) - - [Rule upgrade workflow: viewing rule changes in per-field diff view](#rule-upgrade-workflow-viewing-rule-changes-in-per-field-diff-view) - - [**Scenario: User can see changes in a side-by-side per-field diff view**](#scenario-user-can-see-changes-in-a-side-by-side-per-field-diff-view) - - [**Scenario: Field groupings should be rendered together in the same accordion panel**](#scenario-field-groupings-should-be-rendered-together-in-the-same-accordion-panel) - - [**Scenario: Undefined values are displayed with empty diffs**](#scenario-undefined-values-are-displayed-with-empty-diffs) - - [**Scenario: Field diff components have the same grouping and order as in rule details overview**](#scenario-field-diff-components-have-the-same-grouping-and-order-as-in-rule-details-overview) - - [Rule upgrade workflow: preserving rule bound data](#rule-upgrade-workflow-preserving-rule-bound-data) - - [**Scenario: Rule bound data is preserved after upgrading a rule to a newer version with the same rule type**](#scenario-rule-bound-data-is-preserved-after-upgrading-a-rule-to-a-newer-version-with-the-same-rule-type) - - [**Scenario: Rule bound data is preserved after upgrading a rule to a newer version with a different rule type**](#scenario-rule-bound-data-is-preserved-after-upgrading-a-rule-to-a-newer-version-with-a-different-rule-type) - - [Rule upgrade workflow: misc cases](#rule-upgrade-workflow-misc-cases) - - [**Scenario: User doesn't see the Rule Updates tab until the package installation is completed**](#scenario-user-doesnt-see-the-rule-updates-tab-until-the-package-installation-is-completed) - - [Error handling](#error-handling) - - [**Scenario: Error is handled when any operation on prebuilt rules fails**](#scenario-error-is-handled-when-any-operation-on-prebuilt-rules-fails) - - [Authorization / RBAC](#authorization--rbac) - - [**Scenario: User with read privileges on Security Solution cannot install prebuilt rules**](#scenario-user-with-read-privileges-on-security-solution-cannot-install-prebuilt-rules) - - [**Scenario: User with read privileges on Security Solution cannot upgrade prebuilt rules**](#scenario-user-with-read-privileges-on-security-solution-cannot-upgrade-prebuilt-rules) - - [Kibana upgrade](#kibana-upgrade) - - [**Scenario: User can use prebuilt rules after upgrading Kibana from version A to B**](#scenario-user-can-use-prebuilt-rules-after-upgrading-kibana-from-version-a-to-b) + - [Scenarios](#scenarios) + - [Rule installation and upgrade notifications on the Rule Management page](#rule-installation-and-upgrade-notifications-on-the-rule-management-page) + - [**Scenario: User is NOT notified when all installed prebuilt rules are up to date**](#scenario-user-is-not-notified-when-all-installed-prebuilt-rules-are-up-to-date) + - [**Scenario: User is notified when some prebuilt rules can be upgraded**](#scenario-user-is-notified-when-some-prebuilt-rules-can-be-upgraded) + - [**Scenario: User is notified when both rules to install and upgrade are available**](#scenario-user-is-notified-when-both-rules-to-install-and-upgrade-are-available) + - [Rule upgrade workflow: individual upgrade from Rule Updates table](#rule-upgrade-workflow-individual-and-bulk-updates-from-rule-updates-table) + - [**Scenario: User can upgrade conflict-free prebuilt rules one by one**](#scenario-user-can-upgrade-conflict-free-prebuilt-rules-one-by-one) + - [**Scenario: User cannot upgrade prebuilt rules one by one from Rules Update table if they have conflicts**](#scenario-user-cannot-upgrade-prebuilt-rules-one-by-one-from-rules-update-table-if-they-have-conflicts) + - [Rule upgrade workflow: bulk upgrade from Rule Updates table](#rule-upgrade-workflow-individual-and-bulk-updates-from-rule-updates-table) + - [**Scenario: User can upgrade multiple conflict-free prebuilt rules selected on the page**](#scenario-user-can-upgrade-multiple-conflict-free-prebuilt-rules-selected-on-the-page) + - [**Scenario: User cannot upgrade multiple prebuilt rules selected on the page when they have upgrade conflicts**](#scenario-user-cannot-upgrade-multiple-prebuilt-rules-selected-on-the-page-when-they-have-upgrade-conflicts) + - [**Scenario: User can upgrade all available conflict-free prebuilt rules at once**](#scenario-user-can-upgrade-all-available-conflict-free-prebuilt-rules-at-once) + - [**Scenario: User cannot upgrade all prebuilt rules at once if they have upgrade conflicts**](#scenario-user-cannot-upgrade-all-prebuilt-rules-at-once-if-they-have-upgrade-conflicts) + - [**Scenario: User can upgrade only conflict-free rules when a mix of rules with and without conflicts are selected for upgrade in the Rules Table**](#scenario-user-can-upgrade-only-conflict-free-rules-when-a-mix-of-rules-with-and-without-conflicts-are-selected-for-upgrade-in-the-rules-table) + - [**Scenario: User can upgrade only conflict-free rules when user attempts to upgrade all rules and only a subset contains upgrade conflicts**](#scenario-user-can-upgrade-only-conflict-free-rules-when-user-attempts-to-upgrade-all-rules-and-only-a-subset-contains-upgrade-conflicts) + - [Rule upgrade workflow: upgrading rules with rule type change](#rule-upgrade-workflow-upgrading-rules-with-rule-type-change) + - [**Scenario: User can upgrade rule with rule type change individually**](#scenario-user-can-upgrade-rule-with-rule-type-change-individually) + - [**Scenario: User can bulk upgrade selected rules with rule type changes**](#scenario-user-can-bulk-upgrade-selected-rules-with-rule-type-changes) + - [**Scenario: User can bulk upgrade all rules with rule type changes**](#scenario-user-can-bulk-upgrade-all-rules-with-rule-type-changes) + - [Rule upgrade workflow: rule previews](#rule-upgrade-workflow-rule-previews) + - [**Scenario: User can preview rules available for upgrade**](#scenario-user-can-preview-rules-available-for-upgrade) + - [**Scenario: User can upgrade a rule using the rule preview**](#scenario-user-can-upgrade-a-rule-using-the-rule-preview) + - [**Scenario: User can see correct rule information in preview before upgrading**](#scenario-user-can-see-correct-rule-information-in-preview-before-upgrading) + - [**Scenario: Tabs and sections without content should be hidden in preview before upgrading**](#scenario-tabs-and-sections-without-content-should-be-hidden-in-preview-before-upgrading) + - [Rule upgrade workflow: filtering, sorting, pagination](#rule-upgrade-workflow-filtering-sorting-pagination) + - [MILESTONE 2 (Legacy) - Rule upgrade workflow: viewing rule changes in JSON diff view](#milestone-2-legacy---rule-upgrade-workflow-viewing-rule-changes-in-json-diff-view) + - [**Scenario: User can see changes in a side-by-side JSON diff view**](#scenario-user-can-see-changes-in-a-side-by-side-json-diff-view) + - [**Scenario: User can see precisely how property values would change after upgrade**](#scenario-user-can-see-precisely-how-property-values-would-change-after-upgrade) + - [**Scenario: Rule actions and exception lists should not be shown as modified**](#scenario-rule-actions-and-exception-lists-should-not-be-shown-as-modified) + - [**Scenario: Dynamic properties should not be included in preview**](#scenario-dynamic-properties-should-not-be-included-in-preview) + - [**Scenario: Technical properties should not be included in preview**](#scenario-technical-properties-should-not-be-included-in-preview) + - [**Scenario: Properties with semantically equal values should not be shown as modified**](#scenario-properties-with-semantically-equal-values-should-not-be-shown-as-modified) + - [**Scenario: Unchanged sections of a rule should be hidden by default**](#scenario-unchanged-sections-of-a-rule-should-be-hidden-by-default) + - [**Scenario: Properties should be sorted alphabetically**](#scenario-properties-should-be-sorted-alphabetically) + - [MILESTONE 2 (Legacy) - Rule upgrade workflow: viewing rule changes in per-field diff view](#milestone-2-legacy---rule-upgrade-workflow-viewing-rule-changes-in-per-field-diff-view) + - [**Scenario: User can see changes in a side-by-side per-field diff view**](#scenario-user-can-see-changes-in-a-side-by-side-per-field-diff-view) + - [**Scenario: User can see changes when updated rule is a different rule type**](#scenario-user-can-see-changes-when-updated-rule-is-a-different-rule-type) + - [**Scenario: Field groupings should be rendered together in the same accordion panel**](#scenario-field-groupings-should-be-rendered-together-in-the-same-accordion-panel) + - [**Scenario: Undefined values are displayed with empty diffs**](#scenario-undefined-values-are-displayed-with-empty-diffs) + - [**Scenario: Field diff components have the same grouping and order as in rule details overview**](#scenario-field-diff-components-have-the-same-grouping-and-order-as-in-rule-details-overview) + - [Rule upgrade workflow: preserving rule bound data](#rule-upgrade-workflow-preserving-rule-bound-data) + - [**Scenario: Rule bound data is preserved after upgrading a rule to a newer version with the same rule type**](#scenario-rule-bound-data-is-preserved-after-upgrading-a-rule-to-a-newer-version-with-the-same-rule-type) + - [**Scenario: Rule bound data is preserved after upgrading a rule to a newer version with a different rule type**](#scenario-rule-bound-data-is-preserved-after-upgrading-a-rule-to-a-newer-version-with-a-different-rule-type) + - [Rule upgrade workflow: misc cases](#rule-upgrade-workflow-misc-cases) + - [**Scenario: User doesn't see the Rule Updates tab until the package installation is completed**](#scenario-user-doesnt-see-the-rule-updates-tab-until-the-package-installation-is-completed) + - [Error handling](#error-handling) + - [**Scenario: Error is handled when any upgrade operation on prebuilt rules fails**](#scenario-error-is-handled-when-any-upgrade-operation-on-prebuilt-rules-fails) + - [Rule upgrade via the Prebuilt rules API](#rule-upgrade-via-the-prebuilt-rules-api) + - [**Scenario: API can upgrade prebuilt rules that are outdated**](#scenario-api-can-upgrade-prebuilt-rules-that-are-outdated) + - [**Scenario: API does not upgrade prebuilt rules if they are up to date**](#scenario-api-does-not-upgrade-prebuilt-rules-if-they-are-up-to-date) + - [Authorization / RBAC](#authorization-rbac) + - [**Scenario: User with read privileges on Security Solution cannot upgrade prebuilt rules**](#scenario-user-with-read-privileges-on-security-solution-cannot-upgrade-prebuilt-rules) + ## Useful information ### Tickets - [Rule Immutability/Customization](https://github.com/elastic/security-team/issues/1974) epic + +**Milestone 3 - Prebuilt Rules Customization:** +- [Milestone 3 epic ticket](https://github.com/elastic/kibana/issues/174168) +- [Tests for prebuilt rule upgrade workflow #202078](https://github.com/elastic/kibana/issues/202078) + +**Milestone 2:** - [Ensure full test coverage for existing workflows of installing and upgrading prebuilt rules](https://github.com/elastic/kibana/issues/148176) - [Write test plan and add test coverage for the new workflows of installing and upgrading prebuilt rules](https://github.com/elastic/kibana/issues/148192) -- [Document the new UI for installing and upgrading prebuilt detection rules](https://github.com/elastic/security-docs/issues/3496) ### Terminology @@ -205,266 +194,15 @@ Examples: ## Scenarios -### Package installation - -#### **Scenario: Package is installed via Fleet** - -**Automation**: 2 e2e tests that install the real package. - -```Gherkin -Given the package is not installed -When user opens the Rule Management page -Then the package gets installed in the background from EPR -``` - -#### **Scenario: Package is installed via bundled Fleet package in Kibana** - -**Automation**: 2 integration tests. - -```Gherkin -Given the package is not installed -And user is in an air-gapped environment -When user opens the Rule Management page -Then the package gets installed in the background from packages bundled into Kibana -``` - -#### **Scenario: Large package can be installed on a small Kibana instance** - -**Automation**: 1 integration test. - -```Gherkin -Given the package is not installed -And the package contains the largest amount of historical rule versions (15000) -And the Kibana instance has a memory heap size of 700 Mb (see note below) -When user opens the Rule Management page -Then the package is installed without Kibana crashing with an Out Of Memory error -``` - -**Note**: 600 Mb seems to always crash Kibana with an OOM error. 700 Mb runs with no issues in the Flaky test runner with 100 iterations: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2215. - -### Rule installation and upgrade via the Prebuilt rules API - -There's a legacy prebuilt rules API and a new one. Both should be tested against two types of the package: with and without historical rule versions. - -#### **Scenario: API can install all prebuilt rules** - -**Automation**: 8 integration tests with mock rules: 4 examples below \* 2 (we split checking API response and installed rules into two different tests). - -```Gherkin -Given the package is installed -And the package contains N rules -When user installs all rules via install -Then the endpoint should return 200 with -And N rule objects should be created -And each rule object should have correct id and version - -Examples: - | package_type | api | install_response | - | with historical versions | legacy | installed: N, updated: 0 | - | w/o historical versions | legacy | installed: N, updated: 0 | - | with historical versions | new | total: N, succeeded: N | - | w/o historical versions | new | total: N, succeeded: N | -``` - -Notes: - -- Legacy API: - - install: `PUT /api/detection_engine/rules/prepackaged` -- New API: - - install: `POST /internal/detection_engine/prebuilt_rules/installation/_perform` - -#### **Scenario: API can install prebuilt rules that are not yet installed** - -**Automation**: 4 integration tests with mock rules. - -```Gherkin -Given the package is installed -And the package contains N rules -When user installs all rules via install -And deletes one of the installed rules -And gets prebuilt rules status via status -Then the endpoint should return 200 with -When user installs all rules via install again -Then the endpoint should return 200 with - -Examples: - | package_type | api | status_response | install_response | - | with historical versions | legacy | not_installed: 1 | installed: 1, updated: 0 | - | w/o historical versions | legacy | not_installed: 1 | installed: 1, updated: 0 | - | with historical versions | new | to_install: 1 | total: 1, succeeded: 1 | - | w/o historical versions | new | to_install: 1 | total: 1, succeeded: 1 | -``` - -Notes: - -- Legacy API: - - install: `PUT /api/detection_engine/rules/prepackaged` - - status: `GET /api/detection_engine/rules/prepackaged/_status` -- New API: - - install: `POST /internal/detection_engine/prebuilt_rules/installation/_perform` - - status: `GET /internal/detection_engine/prebuilt_rules/status` - -#### **Scenario: API can upgrade prebuilt rules that are outdated** - -**Automation**: 4 integration tests with mock rules. - -```Gherkin -Given the package is installed -And the package contains N rules -When user installs all rules via install -And new X+1 version of a rule asset -And user gets prebuilt rules status via status -Then the endpoint should return 200 with -When user upgrades all rules via upgrade -Then the endpoint should return 200 with - -Examples: - | package_type | api | assets_update | status_response | upgrade_response | - | with historical versions | legacy | gets added | not_updated: 1 | installed: 0, updated: 1 | - | w/o historical versions | legacy | replaces X | not_updated: 1 | installed: 0, updated: 1 | - | with historical versions | new | gets added | to_upgrade: 1 | total: 1, succeeded: 1 | - | w/o historical versions | new | replaces X | to_upgrade: 1 | total: 1, succeeded: 1 | -``` +### Rule upgrade notifications on the Rule Management page -TODO: Check why for the legacy API Dmitrii has added 2 integration tests for `rule package with historical versions` instead of 1: - -- `should update outdated prebuilt rules when previous historical versions available` -- `should update outdated prebuilt rules when previous historical versions unavailable` - -(NOTE: the second scenario tests that, if a new version of a rule is released, it can upgrade the current instance of that rule even if the historical versions of that rule are no longer in the package) - -Notes: - -- Legacy API: - - install: `PUT /api/detection_engine/rules/prepackaged` - - upgrade: `PUT /api/detection_engine/rules/prepackaged` - - status: `GET /api/detection_engine/rules/prepackaged/_status` -- New API: - - install: `POST /internal/detection_engine/prebuilt_rules/installation/_perform` - - upgrade: `POST /internal/detection_engine/prebuilt_rules/upgrade/_perform` - - status: `GET /internal/detection_engine/prebuilt_rules/status` - -#### **Scenario: API does not install or upgrade prebuilt rules if they are up to date** - -**Automation**: 4 integration tests with mock rules. - -```Gherkin -Given the package is installed -And the package contains N rules -When user installs all rules via install -And user gets prebuilt rules status via status -Then the endpoint should return 200 with -When user calls install -Then the endpoint should return 200 with -When user calls upgrade -Then the endpoint should return 200 with - -Examples: - | package_type | api | status_response | install_response | upgrade_response | - | with historical versions | legacy | not_installed: 0, not_updated: 0 | installed: 0, updated: 0 | installed: 0, updated: 0 | - | w/o historical versions | legacy | not_installed: 0, not_updated: 0 | installed: 0, updated: 0 | installed: 0, updated: 0 | - | with historical versions | new | to_install: 0, to_upgrade: 0 | total: 0, succeeded: 0 | total: 0, succeeded: 0 | - | w/o historical versions | new | to_install: 0, to_upgrade: 0 | total: 0, succeeded: 0 | total: 0, succeeded: 0 | -``` - -Notes: - -- Legacy API: - - install: `PUT /api/detection_engine/rules/prepackaged` - - upgrade: `PUT /api/detection_engine/rules/prepackaged` - - status: `GET /api/detection_engine/rules/prepackaged/_status` -- New API: - - install: `POST /internal/detection_engine/prebuilt_rules/installation/_perform` - - upgrade: `POST /internal/detection_engine/prebuilt_rules/upgrade/_perform` - - status: `GET /internal/detection_engine/prebuilt_rules/status` - -### Scenarios for the real package - -#### **Scenario: User can install prebuilt rules from scratch, then install new rules and upgrade existing rules from the new package** - -**Automation**: 1 integration test with real packages. - -```Gherkin -Given there are two package versions: N-1 and N -And the package of N-1 version is installed -When user calls the status endpoint -Then it should return a 200 response with some number of rules to install and 0 rules to upgrade -When user calls the installation/_review endpoint -Then it should return a 200 response matching the response of the status endpoint -When user calls the installation/_perform_ endpoint -Then it should return a 200 response matching the response of the status endpoint -And rules returned in this response should exist as alert saved objects -When user installs the package of N version -Then it should be installed successfully -When user calls the status endpoint -Then it should return a 200 response with some number of new rules to install and some number of rules to upgrade -When user calls the installation/_review endpoint -Then it should return a 200 response matching the response of the status endpoint -When user calls the installation/_perform_ endpoint -Then rules returned in this response should exist as alert saved objects -When user calls the upgrade/_review endpoint -Then it should return a 200 response matching the response of the status endpoint -When user calls the upgrade/_perform_ endpoint -Then rules returned in this response should exist as alert saved objects -``` - -### Rule installation and upgrade notifications on the Rule Management page - -#### **Scenario: User is NOT notified when no prebuilt rules are installed and there are no prebuilt rules assets** - -**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. - -```Gherkin -Given no prebuilt rules are installed in Kibana -And no prebuilt rule assets exist -When user opens the Rule Management page -Then user should NOT see a CTA to install prebuilt rules -And user should NOT see a number of rules available to install -And user should NOT see a CTA to upgrade prebuilt rules -And user should NOT see a number of rules available to upgrade -And user should NOT see the Rule Updates table -``` - -#### **Scenario: User is NOT notified when all prebuilt rules are installed and up to date** +#### **Scenario: User is NOT notified when all installed prebuilt rules are up to date** **Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. ```Gherkin Given all the latest prebuilt rules are installed in Kibana When user opens the Rule Management page -Then user should NOT see a CTA to install prebuilt rules -And user should NOT see a number of rules available to install -And user should NOT see a CTA to upgrade prebuilt rules -And user should NOT see a number of rules available to upgrade -And user should NOT see the Rule Updates table -``` - -#### **Scenario: User is notified when no prebuilt rules are installed and there are rules available to install** - -**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. - -```Gherkin -Given no prebuilt rules are installed in Kibana -And there are X prebuilt rules available to install -When user opens the Rule Management page -Then user should see a CTA to install prebuilt rules -And user should see a number of rules available to install (X) -And user should NOT see a CTA to upgrade prebuilt rules -And user should NOT see a number of rules available to upgrade -And user should NOT see the Rule Updates table -``` - -#### **Scenario: User is notified when some prebuilt rules can be installed** - -**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. - -```Gherkin -Given X prebuilt rules are installed in Kibana -And there are Y more prebuilt rules available to install -And for all X installed rules there are no new versions available -When user opens the Rule Management page -Then user should see a CTA to install prebuilt rules -And user should see the number of rules available to install (Y) And user should NOT see a CTA to upgrade prebuilt rules And user should NOT see a number of rules available to upgrade And user should NOT see the Rule Updates table @@ -500,242 +238,210 @@ And user should see a CTA to upgrade prebuilt rules And user should see the number of rules available to upgrade (Z) ``` -#### **Scenario: User is notified after a prebuilt rule gets deleted** +### Rule upgrade workflow: individual updates from Rule Updates table -**Automation**: 1 e2e test with mock rules + 1 integration test with mock rules for the /status endpoint. +#### **Scenario: User can upgrade conflict-free prebuilt rules one by one** + +**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /upgrade/\* endpoints in integration. ```Gherkin Given X prebuilt rules are installed in Kibana -And there are no more prebuilt rules available to install -When user opens the Rule Management page -And user deletes Y prebuilt rules -Then user should see a CTA to install prebuilt rules -And user should see the number of rules available to install (Y) +And for Y of the installed rules there are new versions available +When user is on the Rule Updates table +Then Y rules available for upgrade should be displayed in the table +When user upgrades one individual rule without previewing it +Then success message should be displayed after upgrade +And the upgraded rule should be removed from the table +And user should see the number of rules available to upgrade decreased by 1 ``` -### Rule installation workflow: base cases - -#### **Scenario: User can install prebuilt rules one by one** +#### **Scenario: User cannot upgrade prebuilt rules one by one from Rules Update table if they have conflicts** -**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /installation/\* endpoints in integration. +**Automation**: 1 e2e test with mock rules ```Gherkin -Given no prebuilt rules are installed in Kibana -And there are X prebuilt rules available to install -When user opens the Add Rules page -Then prebuilt rules available for installation should be displayed in the table -When user installs one individual rule without previewing it -Then success message should be displayed after installation -And the installed rule should be removed from the table -When user navigates back to the Rule Management page -Then user should see a CTA to install prebuilt rules -And user should see the number of rules available to install decreased by 1 +Given X prebuilt rules are installed in Kibana +And for Y of the installed rules there are new versions available +And user is on the Rule Updates table +Then Y rules available for upgrade should be displayed in the table +And for Z (where Z < Y) of the rules with upgrades there are upgrade conflicts +Then for those Z rules the Upgrade Rule button should be disabled +And the user should not be able to upgrade them directly from the table +And there should be a message/tooltip indicating why the rule cannot be upgraded directly ``` -#### **Scenario: User can install multiple prebuilt rules selected on the page** +### Rule upgrade workflow: bulk updates from Rule Updates table -**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /installation/\* endpoints in integration. +#### **Scenario: User can upgrade multiple conflict-free prebuilt rules selected on the page** + +**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /upgrade/\* endpoints in integration. ```Gherkin -Given no prebuilt rules are installed in Kibana -And there are X prebuilt rules available to install -When user opens the Add Rules page -Then prebuilt rules available for installation should be displayed in the table -When user selects rules -Then user should see a CTA to install number of rules +Given X prebuilt rules are installed in Kibana +And for Y of the installed rules there are new versions available +And user opens the Rule Updates table +Then Y rules available for upgrade should be displayed in the table +When user selects Z (where Z < Y) rules, which have no upgrade conflicts +Then user should see a CTA to upgrade rules When user clicks the CTA -Then success message should be displayed after installation -And all the installed rules should be removed from the table -When user navigates back to the Rule Management page -Then user should see a CTA to install prebuilt rules -And user should see the number of rules available to install decreased by number of installed rules +Then success message should be displayed after upgrade +And all the upgraded rules should be removed from the table +And user should see the number of rules available to upgrade decreased by number of upgraded rules Examples: - | Y | + | Z | | a few rules on the page, e.g. 2 | | all rules on the page, e.g. 12 | ``` -#### **Scenario: User can install all available prebuilt rules at once** +#### **Scenario: User cannot upgrade selected prebuilt rules with conflicts** -**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /installation/\* endpoints in integration. +**Automation**: 1 e2e test with mock rules ```Gherkin -Given no prebuilt rules are installed in Kibana -And there are X prebuilt rules available to install -When user opens the Add Rules page -Then prebuilt rules available for installation should be displayed in the table -When user installs all rules -Then success message should be displayed after installation -And all the rules should be removed from the table -And user should see a message indicating that all available rules have been installed -And user should see a CTA that leads to the Rule Management page -When user clicks on the CTA -Then user should be navigated back to Rule Management page -And user should NOT see a CTA to install prebuilt rules -And user should NOT see a number of rules available to install -``` - -#### **Scenario: Empty screen is shown when all prebuilt rules are installed** - -**Automation**: 1 e2e test with mock rules + 1 integration test. +Given X prebuilt rules are installed in Kibana +And for Y of the installed rules there are new versions available +And all of those Y new versions have conflicts with the current versions +And user is on the Rule Management page +When user is on the Rule Updates table +When user selects rules, all of which have upgrade conflicts +Then user should see a CTA to upgrade number of rules, which should be disabled +When user hovers on the CTA to upgrade multiple rules +Then a message should be displayed that informs the user why the rules cannot be updated -```Gherkin -Given all the available prebuilt rules are installed in Kibana -When user opens the Add Rules page -Then user should see a message indicating that all available rules have been installed -And user should see a CTA that leads to the Rule Management page +Examples: + | Z | + | a few rules on the page, e.g. 2 | + | all rules on the page, e.g. 12 | ``` -#### **Scenario: User can preview rules available for installation** +#### **Scenario: User can upgrade all available conflict-free prebuilt rules at once** -**Automation**: 1 e2e test +**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /upgrade/\* endpoints in integration. ```Gherkin -Given no prebuilt rules are installed in Kibana -And there are 2 rules available to install -When user opens the Add Rules page -Then all rules available for installation should be displayed in the table -When user opens the rule preview for the 1st rule -Then the preview should open -When user closes the preview -Then it should disappear +Given X prebuilt rules are installed in Kibana +And for Y of the installed rules there are new versions available +And those Y new versions don't have conflicts with the current versions +When user is on the Rule Updates table +Then Y rules available for upgrade should be displayed in the table +When user upgrades all rules +Then success message should be displayed after upgrade +And user should NOT see a CTA to upgrade prebuilt rules +And user should NOT see a number of rules available to upgrade ``` -#### **Scenario: User can install a rule using the rule preview** +#### **Scenario: User cannot upgrade all prebuilt rules at once if they have upgrade conflicts** -**Automation**: 1 e2e test +**Automation**: 1 e2e test with mock rules ```Gherkin -Given no prebuilt rules are installed in Kibana -And there are 2 rules available to install -When user opens the Add Rules page -Then all rules available for installation should be displayed in the table -When user opens the rule preview for the rule -Then the preview should open -When user installs the rule using a CTA in the rule preview -Then the rule should be installed -And a success message should be displayed after installation -And the rule should be removed from the Add Rules table -When user navigates back to the Rule Management page -Then user should see a CTA to install prebuilt rules -And user should see the number of rules available to install as initial number minus 1 +Given X prebuilt rules are installed in Kibana +And for Y of the installed rules there are new versions available +And all Y new versions have conflicts with the current versions +When user opens the Rule Updates table +Then Y rules available for upgrade should be displayed in the table +Then user should see a CTA to upgrade all rules +And the CTA to upgrade all rules should be disabled +When user hovers on the CTA to upgrade all rules +Then a message should be displayed that informs the user why the rules cannot be updated ``` -#### **Scenario: User can see correct rule information in preview before installing** +#### **Scenario: User can upgrade only conflict-free rules when a mix of rules with and without conflicts are selected for upgrade** -**Automation**: 1 e2e test +**Automation**: 1 e2e test with mock rules ```Gherkin -Given no prebuilt rules are installed in Kibana -And there are X prebuilt rules of all types available to install -When user opens the Add Rules page -Then all X rules available for installation should be displayed in the table -When user opens a rule preview for any rule -Then the preview should appear -And all properties of a rule should be displayed in the correct tab and section of the preview (see examples of rule properties above) -``` - -#### **Scenario: Tabs and sections without content should be hidden in preview before installing** - -**Automation**: 1 e2e test +Given X prebuilt rules are installed in Kibana +And for Y of the installed rules there are new versions available +And a subset Z of the rules have conflicts with the current versions +And user is on the Rule Updates table +Then Y rules available for upgrade should be displayed in the table +And user selects rules, which is a mixture of rules with and without upgrade conflicts +Then user should see a CTA to upgrade number of rules, which is enabled +When user clicks the CTA +A modal window should inform the user that only W rules without conflicts will be upgraded +When user confirms the action in the modal +Then success message should be displayed after upgrade informing that W rules were updated +And the W upgraded rules should be removed from the table +And the remaining Z - W rules should still be present in the table +And user should see the number of rules available to upgrade decreased by W number of upgraded rules -```Gherkin -Given no prebuilt rules are installed in Kibana -And there is at least 1 rule available to install -And this rule has neither Setup guide nor Investigation guide -When user opens the Add Rules page -Then all rules available for installation should be displayed in the table -When user opens the rule preview for this rule -Then the preview should open -And the Setup Guide section should NOT be displayed in the Overview tab -And the Investigation Guide tab should NOT be displayed +Examples: + | Z | + | a few rules on the page, e.g. 2 | + | all rules on the page, e.g. 12 | ``` -### Rule installation workflow: filtering, sorting, pagination - -TODO: add scenarios https://github.com/elastic/kibana/issues/166215 - -### Rule installation workflow: misc cases - -#### **Scenario: User opening the Add Rules page sees a loading skeleton until the package installation is completed** +#### **Scenario: User can upgrade only conflict-free rules when attempting to upgrade all rules** -**Automation**: unit tests. +**Automation**: 1 e2e test with mock rules ```Gherkin -Given prebuilt rules package is not installed -When user opens the Add Rules page -Then user should see a loading skeleton until the package installation is completed +Given X prebuilt rules are installed in Kibana +And for Y of the installed rules there are new versions available +And Z (where Z < Y) rules have conflicts with the current versions +And user is on the Rule Updates table +Then Y rules available for upgrade should be displayed in the table +Then user should see an enabled CTA to upgrade all rules +When user clicks the CTA +A modal window should inform the user that only K (where K < Y) rules without conflicts will be upgraded +When user confirms the action in the modal +Then success message should be displayed after upgrade informing that K rules were updated +And the K upgraded rules should be removed from the table +And the remaining M = Y - K rules should still be present in the table +And user should see the number of rules available to upgrade decreased by K number of upgraded rules ``` -#### **Scenario: User can navigate from the Add Rules page to the Rule Management page via breadcrumbs** - -**Automation**: 1 e2e test. -```Gherkin -Given user is on the Add Rules page -When user navigates to the Rule Management page via breadcrumbs -Then the Rule Management page should be displayed -``` +### Rule upgrade workflow: upgrading rules with rule type change -### Rule upgrade workflow: base cases +#### **Scenario: User can upgrade rule with rule type change individually** -#### **Scenario: User can upgrade prebuilt rules one by one** - -**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /upgrade/\* endpoints in integration. +**Automation**: 1 e2e test with mock rules ```Gherkin -Given X prebuilt rules are installed in Kibana -And for Y of the installed rules there are new versions available -And user is on the Rule Management page +Given a prebuilt rule is installed in Kibana +And this rule has an update available that changes its rule type When user opens the Rule Updates table -Then Y rules available for upgrade should be displayed in the table -When user upgrades one individual rule without previewing it -Then success message should be displayed after upgrade -And the upgraded rule should be removed from the table -And user should see the number of rules available to upgrade decreased by 1 +Then this rule should be displayed in the table +And the Upgrade Rule button should be disabled +And the user should not be able to upgrade them directly from the table +And there should be a message/tooltip indicating why the rule cannot be upgraded directly ``` -#### **Scenario: User can upgrade multiple prebuilt rules selected on the page** +#### **Scenario: User can bulk upgrade selected rules with rule type changes** -**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /upgrade/\* endpoints in integration. + +**Automation**: 1 e2e test with mock rules ```Gherkin Given X prebuilt rules are installed in Kibana -And for Y of the installed rules there are new versions available -And user is on the Rule Management page +And Y of these rules have updates available that change their rule types When user opens the Rule Updates table -Then Y rules available for upgrade should be displayed in the table -When user selects rules -Then user should see a CTA to upgrade number of rules -When user clicks the CTA -Then success message should be displayed after upgrade -And all the upgraded rules should be removed from the table -And user should see the number of rules available to upgrade decreased by number of upgraded rules - -Examples: - | Z | - | a few rules on the page, e.g. 2 | - | all rules on the page, e.g. 12 | +Then Y rules should be displayed in the table +When user selects Z rules (where Z < Y) with rule type changes +The button to upgrade the Z selected rules should be disabled +And the user should not be able to upgrade them directly from the table +And there should be a message/tooltip indicating why the rule cannot be upgraded directly ``` -#### **Scenario: User can upgrade all available prebuilt rules at once** +#### **Scenario: User can bulk upgrade all rules with rule type changes** -**Automation**: 1 e2e test with mock rules + integration tests with mock rules that would test /status and /upgrade/\* endpoints in integration. +**Automation**: 1 e2e test with mock rules ```Gherkin Given X prebuilt rules are installed in Kibana -And for Y of the installed rules there are new versions available -And user is on the Rule Management page +And all X rules have updates available that change their rule types When user opens the Rule Updates table -Then Y rules available for upgrade should be displayed in the table -When user upgrades all rules -Then success message should be displayed after upgrade -And user should NOT see a CTA to upgrade prebuilt rules -And user should NOT see a number of rules available to upgrade -And user should NOT see the Rule Updates table +Then X rules should be displayed in the table +The button to upgrade all rules with should be disabled +And the user should not be able to upgrade them directly from the table +And there should be a message/tooltip indicating why the rule cannot be upgraded directly ``` +### Rule upgrade workflow: rule previews + #### **Scenario: User can preview rules available for upgrade** ```Gherkin @@ -807,7 +513,9 @@ And the Investigation Guide tab should NOT be displayed TODO: add scenarios https://github.com/elastic/kibana/issues/166215 -### Rule upgrade workflow: viewing rule changes in JSON diff view +### MILESTONE 2 (Legacy) - Rule upgrade workflow: viewing rule changes in JSON diff view + +> These flow were created for Milestone 2 of the Prebuilt Rules Customization epic, before users could customize prebuilt rules. This section should be deleted once Milestone 3 goes live. #### **Scenario: User can see changes in a side-by-side JSON diff view** @@ -953,7 +661,9 @@ When a user expands all hidden sections Then all properties of the rule should be sorted alphabetically ``` -### Rule upgrade workflow: viewing rule changes in per-field diff view +### MILESTONE 2 (Legacy) - Rule upgrade workflow: viewing rule changes in per-field diff view + +> These flow were created for Milestone 2 of the Prebuilt Rules Customization epic, before users could customize prebuilt rules. This section should be deleted once Milestone 3 goes live. #### **Scenario: User can see changes in a side-by-side per-field diff view** @@ -1091,7 +801,7 @@ Then user should NOT see the Rule Updates tab until the package installation is ### Error handling -#### **Scenario: Error is handled when any operation on prebuilt rules fails** +#### **Scenario: Error is handled when any upgrade operation on prebuilt rules fails** **Automation**: e2e test with mock rules @@ -1102,29 +812,92 @@ Then user should see an error message Examples: | operation | - | installing all | - | installing selected | - | installing individual | | upgrading all | | upgrading selected | | upgrading individual | ``` -### Authorization / RBAC -#### **Scenario: User with read privileges on Security Solution cannot install prebuilt rules** +### Rule upgrade via the Prebuilt rules API + +There's a legacy prebuilt rules API and a new one. Both should be tested against two types of the package: with and without historical rule versions. + +#### **Scenario: API can upgrade prebuilt rules that are outdated** -**Automation**: 1 e2e test with mock rules + 3 integration tests with mock rules for the status and installation endpoints. +**Automation**: 4 integration tests with mock rules. ```Gherkin -Given user with "Security: read" privileges on Security Solution -And no prebuilt rules are installed in Kibana -And there are prebuilt rules available to install -When user opens the Add Rules page -Then user should see prebuilt rules available to install -But user should not be able to install them +Given the package is installed +And the package contains N rules +When user installs all rules via install +And new X+1 version of a rule asset +And user gets prebuilt rules status via status +Then the endpoint should return 200 with +When user upgrades all rules via upgrade +Then the endpoint should return 200 with + +Examples: + | package_type | api | assets_update | status_response | upgrade_response | + | with historical versions | legacy | gets added | not_updated: 1 | installed: 0, updated: 1 | + | w/o historical versions | legacy | replaces X | not_updated: 1 | installed: 0, updated: 1 | + | with historical versions | new | gets added | to_upgrade: 1 | total: 1, succeeded: 1 | + | w/o historical versions | new | replaces X | to_upgrade: 1 | total: 1, succeeded: 1 | ``` +TODO: Check why for the legacy API Dmitrii has added 2 integration tests for `rule package with historical versions` instead of 1: + +- `should update outdated prebuilt rules when previous historical versions available` +- `should update outdated prebuilt rules when previous historical versions unavailable` + +(NOTE: the second scenario tests that, if a new version of a rule is released, it can upgrade the current instance of that rule even if the historical versions of that rule are no longer in the package) + +Notes: + +- Legacy API: + - install: `PUT /api/detection_engine/rules/prepackaged` + - upgrade: `PUT /api/detection_engine/rules/prepackaged` + - status: `GET /api/detection_engine/rules/prepackaged/_status` +- New API: + - install: `POST /internal/detection_engine/prebuilt_rules/installation/_perform` + - upgrade: `POST /internal/detection_engine/prebuilt_rules/upgrade/_perform` + - status: `GET /internal/detection_engine/prebuilt_rules/status` + +#### **Scenario: API does not upgrade prebuilt rules if they are up to date** + +**Automation**: 4 integration tests with mock rules. + +```Gherkin +Given the package is installed +And the package contains N rules +When user installs all rules via install +And user gets prebuilt rules status via status +Then the endpoint should return 200 with +When user calls install +Then the endpoint should return 200 with +When user calls upgrade +Then the endpoint should return 200 with + +Examples: + | package_type | api | status_response | install_response | upgrade_response | + | with historical versions | legacy | not_installed: 0, not_updated: 0 | installed: 0, updated: 0 | installed: 0, updated: 0 | + | w/o historical versions | legacy | not_installed: 0, not_updated: 0 | installed: 0, updated: 0 | installed: 0, updated: 0 | + | with historical versions | new | to_install: 0, to_upgrade: 0 | total: 0, succeeded: 0 | total: 0, succeeded: 0 | + | w/o historical versions | new | to_install: 0, to_upgrade: 0 | total: 0, succeeded: 0 | total: 0, succeeded: 0 | +``` + +Notes: + +- Legacy API: + - install: `PUT /api/detection_engine/rules/prepackaged` + - upgrade: `PUT /api/detection_engine/rules/prepackaged` + - status: `GET /api/detection_engine/rules/prepackaged/_status` +- New API: + - install: `POST /internal/detection_engine/prebuilt_rules/installation/_perform` + - upgrade: `POST /internal/detection_engine/prebuilt_rules/upgrade/_perform` + - status: `GET /internal/detection_engine/prebuilt_rules/status` + +### Authorization / RBAC + #### **Scenario: User with read privileges on Security Solution cannot upgrade prebuilt rules** **Automation**: 1 e2e test with mock rules + 3 integration tests with mock rules for the status and upgrade endpoints. @@ -1137,24 +910,4 @@ When user opens the Rule Management page And user opens the Rule Updates table Then user should see prebuilt rules available to upgrade But user should not be able to upgrade them -``` - -### Kibana upgrade - -#### **Scenario: User can use prebuilt rules after upgrading Kibana from version A to B** - -**Automation**: not automated, manual testing required. - -```Gherkin -Given user is upgrading Kibana from version to version -And the instance contains already installed prebuilt rules -When the upgrade is complete -Then user should be able to install new prebuilt rules -And delete installed prebuilt rules -And upgrade installed prebuilt rules that have newer versions in - -Examples: - | A | B | - | 8.7 | 8.9.0 | - | 7.17.x | 8.9.0 | -``` +``` \ No newline at end of file diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.test.tsx index 594629e9f9e27..911c5a6bdba40 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.test.tsx @@ -9,11 +9,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import { AlertsPreview } from './alerts_preview'; import { TestProviders } from '../../../common/mock/test_providers'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { ParsedAlertsData } from '../../../overview/components/detection_response/alerts_by_status/types'; import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; -import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; const mockAlertsData: ParsedAlertsData = { open: { @@ -35,18 +33,14 @@ const mockAlertsData: ParsedAlertsData = { // Mock hooks jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); -jest.mock('../../../entity_analytics/api/hooks/use_risk_score'); -jest.mock('@kbn/expandable-flyout'); describe('AlertsPreview', () => { const mockOpenLeftPanel = jest.fn(); beforeEach(() => { - (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, }); - (useRiskScore as jest.Mock).mockReturnValue({ data: [{ host: { risk: 75 } }] }); (useMisconfigurationPreview as jest.Mock).mockReturnValue({ data: { count: { passed: 1, failed: 1 } }, }); @@ -58,7 +52,11 @@ describe('AlertsPreview', () => { it('renders', () => { const { getByTestId } = render( - + ); @@ -68,7 +66,11 @@ describe('AlertsPreview', () => { it('renders correct alerts number', () => { const { getByTestId } = render( - + ); @@ -78,7 +80,11 @@ describe('AlertsPreview', () => { it('should render the correct number of distribution bar section based on the number of severities', () => { const { queryAllByTestId } = render( - + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx index 8592ed61abe33..cd5fcc93495a1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/alerts/alerts_preview.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { capitalize } from 'lodash'; import type { EuiThemeComputed } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui'; @@ -18,8 +18,11 @@ import type { } from '../../../overview/components/detection_response/alerts_by_status/types'; import { ExpandablePanel } from '../../../flyout/shared/components/expandable_panel'; import { getSeverityColor } from '../../../detections/components/alerts_kpis/severity_level_panel/helpers'; -import { CspInsightLeftPanelSubTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; -import { useNavigateEntityInsight } from '../../hooks/use_entity_insight'; +import type { EntityDetailsPath } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import { + CspInsightLeftPanelSubTab, + EntityDetailsLeftPanelTab, +} from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; const AlertsCount = ({ alertsTotal, @@ -58,14 +61,14 @@ const AlertsCount = ({ export const AlertsPreview = ({ alertsData, - field, - value, isPreviewMode, + openDetailsPanel, + isLinkEnabled, }: { alertsData: ParsedAlertsData; - field: 'host.name' | 'user.name'; - value: string; isPreviewMode?: boolean; + openDetailsPanel: (path: EntityDetailsPath) => void; + isLinkEnabled: boolean; }) => { const { euiTheme } = useEuiTheme(); @@ -90,15 +93,16 @@ export const AlertsPreview = ({ const hasNonClosedAlerts = totalAlertsCount > 0; - const { goToEntityInsightTab } = useNavigateEntityInsight({ - field, - value, - queryIdExtension: isPreviewMode ? 'ALERTS_PREVIEW_TRUE' : 'ALERTS_PREVIEW_FALSE', - subTab: CspInsightLeftPanelSubTab.ALERTS, - }); + const goToEntityInsightTab = useCallback(() => { + openDetailsPanel({ + tab: EntityDetailsLeftPanelTab.CSP_INSIGHTS, + subTab: CspInsightLeftPanelSubTab.ALERTS, + }); + }, [openDetailsPanel]); + const link = useMemo( () => - !isPreviewMode + isLinkEnabled ? { callback: goToEntityInsightTab, tooltip: ( @@ -109,7 +113,7 @@ export const AlertsPreview = ({ ), } : undefined, - [isPreviewMode, goToEntityInsightTab] + [isLinkEnabled, goToEntityInsightTab] ); return ( ({ value, field, isPreviewMode, + isLinkEnabled, + openDetailsPanel, }: { value: string; field: 'host.name' | 'user.name'; isPreviewMode?: boolean; + isLinkEnabled: boolean; + openDetailsPanel: (path: EntityDetailsPath) => void; }) => { const { euiTheme } = useEuiTheme(); const insightContent: React.ReactElement[] = []; @@ -55,9 +60,9 @@ export const EntityInsight = ({ <> @@ -67,14 +72,26 @@ export const EntityInsight = ({ if (showMisconfigurationsPreview) insightContent.push( <> - + ); if (showVulnerabilitiesPreview) insightContent.push( <> - + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.test.tsx index a3c6bcd38d261..2d79ecdb2783f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.test.tsx @@ -10,25 +10,19 @@ import { render } from '@testing-library/react'; import { MisconfigurationsPreview } from './misconfiguration_preview'; import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; -import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { TestProviders } from '../../../common/mock/test_providers'; // Mock hooks jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); -jest.mock('../../../entity_analytics/api/hooks/use_risk_score'); -jest.mock('@kbn/expandable-flyout'); describe('MisconfigurationsPreview', () => { const mockOpenLeftPanel = jest.fn(); beforeEach(() => { - (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, }); - (useRiskScore as jest.Mock).mockReturnValue({ data: [{ host: { risk: 75 } }] }); (useMisconfigurationPreview as jest.Mock).mockReturnValue({ data: { count: { passed: 1, failed: 1 } }, }); @@ -37,7 +31,12 @@ describe('MisconfigurationsPreview', () => { it('renders', () => { const { getByTestId } = render( - + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx index c7c1889a5838b..2db803fbcda3a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { css } from '@emotion/react'; import type { EuiThemeComputed } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, useEuiTheme, EuiTitle } from '@elastic/eui'; @@ -20,8 +20,11 @@ import { uiMetricService, } from '@kbn/cloud-security-posture-common/utils/ui_metrics'; import { ExpandablePanel } from '../../../flyout/shared/components/expandable_panel'; -import { CspInsightLeftPanelSubTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; -import { useNavigateEntityInsight } from '../../hooks/use_entity_insight'; +import type { EntityDetailsPath } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import { + CspInsightLeftPanelSubTab, + EntityDetailsLeftPanelTab, +} from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; export const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => { if (passedFindingsStats === 0 && failedFindingsStats === 0) return []; @@ -88,10 +91,14 @@ export const MisconfigurationsPreview = ({ value, field, isPreviewMode, + isLinkEnabled, + openDetailsPanel, }: { value: string; field: 'host.name' | 'user.name'; isPreviewMode?: boolean; + isLinkEnabled: boolean; + openDetailsPanel: (path: EntityDetailsPath) => void; }) => { const { hasMisconfigurationFindings, passedFindings, failedFindings } = useHasMisconfigurations( field, @@ -103,15 +110,16 @@ export const MisconfigurationsPreview = ({ }, []); const { euiTheme } = useEuiTheme(); - const { goToEntityInsightTab } = useNavigateEntityInsight({ - field, - value, - queryIdExtension: 'MISCONFIGURATION_PREVIEW', - subTab: CspInsightLeftPanelSubTab.MISCONFIGURATIONS, - }); + const goToEntityInsightTab = useCallback(() => { + openDetailsPanel({ + tab: EntityDetailsLeftPanelTab.CSP_INSIGHTS, + subTab: CspInsightLeftPanelSubTab.MISCONFIGURATIONS, + }); + }, [openDetailsPanel]); + const link = useMemo( () => - !isPreviewMode + isLinkEnabled ? { callback: goToEntityInsightTab, tooltip: ( @@ -122,7 +130,7 @@ export const MisconfigurationsPreview = ({ ), } : undefined, - [isPreviewMode, goToEntityInsightTab] + [isLinkEnabled, goToEntityInsightTab] ); return ( { const mockOpenLeftPanel = jest.fn(); beforeEach(() => { - (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openLeftPanel: mockOpenLeftPanel }); (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, }); - (useRiskScore as jest.Mock).mockReturnValue({ data: [{ host: { risk: 75 } }] }); (useMisconfigurationPreview as jest.Mock).mockReturnValue({ data: { count: { passed: 1, failed: 1 } }, }); @@ -37,7 +31,12 @@ describe('VulnerabilitiesPreview', () => { it('renders', () => { const { getByTestId } = render( - + ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx index bbdf05b001637..eb5f022eecc95 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { css } from '@emotion/react'; import type { EuiThemeComputed } from '@elastic/eui'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, useEuiTheme, EuiTitle } from '@elastic/eui'; @@ -23,8 +23,11 @@ import { } from '@kbn/cloud-security-posture-common/utils/ui_metrics'; import { METRIC_TYPE } from '@kbn/analytics'; import { ExpandablePanel } from '../../../flyout/shared/components/expandable_panel'; -import { CspInsightLeftPanelSubTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; -import { useNavigateEntityInsight } from '../../hooks/use_entity_insight'; +import type { EntityDetailsPath } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import { + CspInsightLeftPanelSubTab, + EntityDetailsLeftPanelTab, +} from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; const VulnerabilitiesCount = ({ vulnerabilitiesTotal, @@ -63,10 +66,14 @@ export const VulnerabilitiesPreview = ({ value, field, isPreviewMode, + isLinkEnabled, + openDetailsPanel, }: { value: string; field: 'host.name' | 'user.name'; isPreviewMode?: boolean; + isLinkEnabled: boolean; + openDetailsPanel: (path: EntityDetailsPath) => void; }) => { useEffect(() => { uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ENTITY_FLYOUT_WITH_VULNERABILITY_PREVIEW); @@ -93,15 +100,16 @@ export const VulnerabilitiesPreview = ({ const { euiTheme } = useEuiTheme(); - const { goToEntityInsightTab } = useNavigateEntityInsight({ - field, - value, - queryIdExtension: 'VULNERABILITIES_PREVIEW', - subTab: CspInsightLeftPanelSubTab.VULNERABILITIES, - }); + const goToEntityInsightTab = useCallback(() => { + openDetailsPanel({ + tab: EntityDetailsLeftPanelTab.CSP_INSIGHTS, + subTab: CspInsightLeftPanelSubTab.VULNERABILITIES, + }); + }, [openDetailsPanel]); + const link = useMemo( () => - !isPreviewMode + isLinkEnabled ? { callback: goToEntityInsightTab, tooltip: ( @@ -112,7 +120,7 @@ export const VulnerabilitiesPreview = ({ ), } : undefined, - [isPreviewMode, goToEntityInsightTab] + [isLinkEnabled, goToEntityInsightTab] ); return ( ; DashboardsLandingCallout: React.ComponentType<{}>; - EnablementModalCallout: React.ComponentType<{}>; + AdditionalChargesMessage: React.ComponentType<{}>; }>; export type SetComponents = (components: ContractComponents) => void; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.test.tsx index c16eb7e557a4b..b2c97a33b2ff4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.test.tsx @@ -182,9 +182,9 @@ describe('EntityStoreEnablementModal', () => { }); it('should render additional charges message when available', async () => { - const EnablementModalCalloutMock = () => ; + const AdditionalChargesMessageMock = () => ; mockUseContractComponents.mockReturnValue({ - EnablementModalCallout: EnablementModalCalloutMock, + AdditionalChargesMessage: AdditionalChargesMessageMock, }); await renderComponent(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx index e3d01ec82907e..a3f4461998905 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/components/enablement_modal.tsx @@ -73,7 +73,7 @@ export const EntityStoreEnablementModal: React.FC - {EnablementModalCallout && } + {AdditionalChargesMessage && } = () => { riskScoreData={{ ...mockRiskScoreState, data: [] }} queryId={'testQuery'} recalculatingScore={false} + isLinkEnabled /> @@ -34,7 +35,7 @@ export const Default: Story = () => { ); }; -export const PreviewMode: Story = () => { +export const LinkEnabledInPreviewMode: Story = () => { return ( @@ -43,6 +44,8 @@ export const PreviewMode: Story = () => { riskScoreData={{ ...mockRiskScoreState, data: [] }} queryId={'testQuery'} recalculatingScore={false} + openDetailsPanel={() => {}} + isLinkEnabled isPreviewMode /> @@ -50,3 +53,21 @@ export const PreviewMode: Story = () => { ); }; + +export const LinkDisabled: Story = () => { + return ( + + +
+ {}} + isLinkEnabled={false} + /> +
+
+
+ ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx index 9cc773df320b0..04f6ec369e302 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx @@ -41,6 +41,7 @@ describe('FlyoutRiskSummary', () => { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} + isLinkEnabled /> ); @@ -63,6 +64,28 @@ describe('FlyoutRiskSummary', () => { (mockHostRiskScoreState.data?.[0].host.risk.category_2_score ?? 0) }` ); + + expect(getByTestId('riskInputsTitleLink')).toBeInTheDocument(); + expect(getByTestId('riskInputsTitleIcon')).toBeInTheDocument(); + }); + + it('renders link without icon when in preview mode', () => { + const { getByTestId, queryByTestId } = render( + + {}} + recalculatingScore={false} + isLinkEnabled + isPreviewMode + /> + + ); + + expect(getByTestId('risk-summary-table')).toBeInTheDocument(); + expect(getByTestId('riskInputsTitleLink')).toBeInTheDocument(); + expect(queryByTestId('riskInputsTitleIcon')).not.toBeInTheDocument(); }); it('renders risk summary table when riskScoreData is empty', () => { @@ -73,6 +96,7 @@ describe('FlyoutRiskSummary', () => { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} + isLinkEnabled /> ); @@ -87,6 +111,7 @@ describe('FlyoutRiskSummary', () => { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} + isLinkEnabled /> ); @@ -94,7 +119,7 @@ describe('FlyoutRiskSummary', () => { expect(queryByTestId('riskInputsTitleLink')).not.toBeInTheDocument(); }); - it('risk summary header does not render expand icon when in preview mode', () => { + it('risk summary header does not render link when link is not enabled', () => { const { queryByTestId } = render( { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} - isPreviewMode + isLinkEnabled={false} /> ); expect(queryByTestId('riskInputsTitleLink')).not.toBeInTheDocument(); - expect(queryByTestId('riskInputsTitleIcon')).not.toBeInTheDocument(); }); it('renders visualization embeddable', () => { @@ -119,6 +143,7 @@ describe('FlyoutRiskSummary', () => { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} + isLinkEnabled /> ); @@ -134,6 +159,7 @@ describe('FlyoutRiskSummary', () => { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} + isLinkEnabled /> ); @@ -149,6 +175,7 @@ describe('FlyoutRiskSummary', () => { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} + isLinkEnabled /> ); @@ -176,6 +203,7 @@ describe('FlyoutRiskSummary', () => { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} + isLinkEnabled /> ); @@ -198,6 +226,7 @@ describe('FlyoutRiskSummary', () => { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} + isLinkEnabled /> ); @@ -220,6 +249,7 @@ describe('FlyoutRiskSummary', () => { queryId={'testQuery'} openDetailsPanel={() => {}} recalculatingScore={false} + isLinkEnabled /> ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx index 0c42543a7f91e..f655f346f93f5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx @@ -23,6 +23,7 @@ import { euiThemeVars } from '@kbn/ui-theme'; import dateMath from '@kbn/datemath'; import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../common/lib/kibana/kibana_react'; +import type { EntityDetailsPath } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; import { EntityDetailsLeftPanelTab } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; import { ONE_WEEK_IN_HOURS } from '../../../flyout/entity_details/shared/constants'; @@ -49,7 +50,8 @@ export interface RiskSummaryProps { riskScoreData: RiskScoreState; recalculatingScore: boolean; queryId: string; - openDetailsPanel?: (tab: EntityDetailsLeftPanelTab) => void; + openDetailsPanel: (path: EntityDetailsPath) => void; + isLinkEnabled: boolean; isPreviewMode?: boolean; } @@ -58,6 +60,7 @@ const FlyoutRiskSummaryComponent = ({ recalculatingScore, queryId, openDetailsPanel, + isLinkEnabled, isPreviewMode, }: RiskSummaryProps) => { const { telemetry } = useKibana().services; @@ -178,8 +181,8 @@ const FlyoutRiskSummaryComponent = ({ link: riskScoreData.loading ? undefined : { - callback: openDetailsPanel - ? () => openDetailsPanel(EntityDetailsLeftPanelTab.RISK_INPUTS) + callback: isLinkEnabled + ? () => openDetailsPanel({ tab: EntityDetailsLeftPanelTab.RISK_INPUTS }) : undefined, tooltip: ( {}} recalculatingScore={false} + isLinkEnabled={true} /> )) .add('no observed data', () => ( @@ -62,6 +63,7 @@ storiesOf('Components/HostPanelContent', module) hostName={'test-host-name'} onAssetCriticalityChange={() => {}} recalculatingScore={false} + isLinkEnabled={true} /> )) .add('loading', () => ( @@ -87,5 +89,6 @@ storiesOf('Components/HostPanelContent', module) hostName={'test-host-name'} onAssetCriticalityChange={() => {}} recalculatingScore={false} + isLinkEnabled={true} /> )); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx index 9c2ce61dea7fc..5b8746675cfdf 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx @@ -17,7 +17,7 @@ import { ObservedEntity } from '../shared/components/observed_entity'; import { HOST_PANEL_OBSERVED_HOST_QUERY_ID, HOST_PANEL_RISK_SCORE_QUERY_ID } from '.'; import type { ObservedEntityData } from '../shared/components/observed_entity/types'; import { useObservedHostFields } from './hooks/use_observed_host_fields'; -import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header'; +import type { EntityDetailsPath } from '../shared/components/left_panel/left_panel_header'; interface HostPanelContentProps { observedHost: ObservedEntityData; @@ -25,11 +25,12 @@ interface HostPanelContentProps { contextID: string; scopeId: string; isDraggable: boolean; - openDetailsPanel?: (tab: EntityDetailsLeftPanelTab) => void; + openDetailsPanel: (path: EntityDetailsPath) => void; hostName: string; onAssetCriticalityChange: () => void; recalculatingScore: boolean; isPreviewMode?: boolean; + isLinkEnabled: boolean; } export const HostPanelContent = ({ @@ -43,6 +44,7 @@ export const HostPanelContent = ({ openDetailsPanel, onAssetCriticalityChange, isPreviewMode, + isLinkEnabled, }: HostPanelContentProps) => { const observedFields = useObservedHostFields(observedHost); @@ -56,6 +58,7 @@ export const HostPanelContent = ({ queryId={HOST_PANEL_RISK_SCORE_QUERY_ID} openDetailsPanel={openDetailsPanel} isPreviewMode={isPreviewMode} + isLinkEnabled={isLinkEnabled} /> @@ -64,7 +67,13 @@ export const HostPanelContent = ({ entity={{ name: hostName, type: 'host' }} onChange={onAssetCriticalityChange} /> - + { + const original = jest.requireActual('../../../../common/lib/kibana'); + return { + ...original, + useKibana: () => ({ + ...original.useKibana(), + services: { + ...original.useKibana().services, + telemetry: mockedTelemetry, + }, + }), + }; +}); + +const mockProps = { + hostName: 'testHost', + scopeId: 'testScopeId', + isRiskScoreExist: false, + hasMisconfigurationFindings: false, + hasVulnerabilitiesFindings: false, + hasNonClosedAlerts: false, + contextID: 'testContextID', + isPreviewMode: false, +}; + +const tab = EntityDetailsLeftPanelTab.RISK_INPUTS; +const subTab = CspInsightLeftPanelSubTab.MISCONFIGURATIONS; + +const mockOpenLeftPanel = jest.fn(); +const mockOpenFlyout = jest.fn(); + +describe('useNavigateToHostDetails', () => { + describe('when preview navigation is enabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ + openLeftPanel: mockOpenLeftPanel, + openFlyout: mockOpenFlyout, + }); + }); + + it('returns callback that opens details panel when not in preview mode', () => { + const { result } = renderHook(() => useNavigateToHostDetails(mockProps)); + + expect(result.current.isLinkEnabled).toBe(true); + result.current.openDetailsPanel({ tab, subTab }); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: HostDetailsPanelKey, + params: { + name: mockProps.hostName, + scopeId: mockProps.scopeId, + isRiskScoreExist: mockProps.isRiskScoreExist, + path: { tab, subTab }, + hasMisconfigurationFindings: mockProps.hasMisconfigurationFindings, + hasVulnerabilitiesFindings: mockProps.hasVulnerabilitiesFindings, + hasNonClosedAlerts: mockProps.hasNonClosedAlerts, + }, + }); + expect(mockOpenFlyout).not.toHaveBeenCalled(); + }); + + it('returns callback that opens flyout when in preview mode', () => { + const { result } = renderHook(() => + useNavigateToHostDetails({ ...mockProps, isPreviewMode: true }) + ); + + expect(result.current.isLinkEnabled).toBe(true); + result.current.openDetailsPanel({ tab, subTab }); + + expect(mockOpenFlyout).toHaveBeenCalledWith({ + right: { + id: HostPanelKey, + params: { + contextID: mockProps.contextID, + scopeId: mockProps.scopeId, + hostName: mockProps.hostName, + isDraggable: undefined, + }, + }, + left: { + id: HostDetailsPanelKey, + params: { + name: mockProps.hostName, + scopeId: mockProps.scopeId, + isRiskScoreExist: mockProps.isRiskScoreExist, + path: { tab, subTab }, + hasMisconfigurationFindings: mockProps.hasMisconfigurationFindings, + hasVulnerabilitiesFindings: mockProps.hasVulnerabilitiesFindings, + hasNonClosedAlerts: mockProps.hasNonClosedAlerts, + }, + }, + }); + expect(mockOpenLeftPanel).not.toHaveBeenCalled(); + }); + }); + + describe('when preview navigation is not enabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ + openLeftPanel: mockOpenLeftPanel, + openFlyout: mockOpenFlyout, + }); + }); + + it('returns callback that opens details panel when not in preview mode', () => { + const { result } = renderHook(() => useNavigateToHostDetails(mockProps)); + + expect(result.current.isLinkEnabled).toBe(true); + result.current.openDetailsPanel({ tab, subTab }); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: HostDetailsPanelKey, + params: { + name: mockProps.hostName, + scopeId: mockProps.scopeId, + isRiskScoreExist: mockProps.isRiskScoreExist, + path: { tab, subTab }, + hasMisconfigurationFindings: mockProps.hasMisconfigurationFindings, + hasVulnerabilitiesFindings: mockProps.hasVulnerabilitiesFindings, + hasNonClosedAlerts: mockProps.hasNonClosedAlerts, + }, + }); + expect(mockOpenFlyout).not.toHaveBeenCalled(); + }); + + it('returns empty callback and isLinkEnabled is false when in preview mode', () => { + const { result } = renderHook(() => + useNavigateToHostDetails({ ...mockProps, isPreviewMode: true }) + ); + + expect(result.current.isLinkEnabled).toBe(false); + result.current.openDetailsPanel({ tab, subTab }); + + expect(mockOpenLeftPanel).not.toHaveBeenCalled(); + expect(mockOpenFlyout).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_navigate_to_host_details.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_navigate_to_host_details.ts new file mode 100644 index 0000000000000..2834446193e8b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/hooks/use_navigate_to_host_details.ts @@ -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 { useCallback, useMemo } from 'react'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useKibana } from '../../../../common/lib/kibana'; +import { HostPanelKey } from '..'; +import { HostDetailsPanelKey } from '../../host_details_left'; +import type { EntityDetailsPath } from '../../shared/components/left_panel/left_panel_header'; +import { EntityEventTypes } from '../../../../common/lib/telemetry'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; + +interface UseNavigateToHostDetailsParams { + hostName: string; + scopeId: string; + isRiskScoreExist: boolean; + hasMisconfigurationFindings: boolean; + hasVulnerabilitiesFindings: boolean; + hasNonClosedAlerts: boolean; + isPreviewMode?: boolean; + contextID: string; + isDraggable?: boolean; +} + +interface UseNavigateToHostDetailsResult { + openDetailsPanel: (path: EntityDetailsPath) => void; + isLinkEnabled: boolean; +} + +export const useNavigateToHostDetails = ({ + hostName, + scopeId, + isRiskScoreExist, + hasMisconfigurationFindings, + hasVulnerabilitiesFindings, + hasNonClosedAlerts, + isPreviewMode, + contextID, + isDraggable, +}: UseNavigateToHostDetailsParams): UseNavigateToHostDetailsResult => { + const { telemetry } = useKibana().services; + const { openLeftPanel, openFlyout } = useExpandableFlyoutApi(); + const isNewNavigationEnabled = useIsExperimentalFeatureEnabled( + 'newExpandableFlyoutNavigationEnabled' + ); + + telemetry.reportEvent(EntityEventTypes.RiskInputsExpandedFlyoutOpened, { + entity: 'host', + }); + + const isLinkEnabled = useMemo(() => { + return !isPreviewMode || (isNewNavigationEnabled && isPreviewMode); + }, [isNewNavigationEnabled, isPreviewMode]); + + const openDetailsPanel = useCallback( + (path?: EntityDetailsPath) => { + const left = { + id: HostDetailsPanelKey, + params: { + name: hostName, + scopeId, + isRiskScoreExist, + path, + hasMisconfigurationFindings, + hasVulnerabilitiesFindings, + hasNonClosedAlerts, + }, + }; + + const right = { + id: HostPanelKey, + params: { + contextID, + scopeId, + hostName, + isDraggable, + }, + }; + + // When new navigation is enabled, nevigation in preview is enabled and open a new flyout + if (isNewNavigationEnabled && isPreviewMode) { + openFlyout({ right, left }); + } + // When not in preview mode, open left panel as usual + else if (!isPreviewMode) { + openLeftPanel(left); + } + }, + [ + isNewNavigationEnabled, + isPreviewMode, + openFlyout, + openLeftPanel, + hostName, + scopeId, + isRiskScoreExist, + hasMisconfigurationFindings, + hasVulnerabilitiesFindings, + hasNonClosedAlerts, + contextID, + isDraggable, + ] + ); + + return { openDetailsPanel, isLinkEnabled }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx index abf7d5cf591dd..6df18796d45e5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useMemo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations'; import { useHasVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities'; @@ -18,7 +17,6 @@ import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_ref import { RISK_INPUTS_TAB_QUERY_ID } from '../../../entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab'; import type { Refetch } from '../../../common/types'; import { useCalculateEntityRiskScore } from '../../../entity_analytics/api/hooks/use_calculate_entity_risk_score'; -import { useKibana } from '../../../common/lib/kibana/kibana_react'; import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_criteria'; import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; import { useQueryInspector } from '../../../common/components/page/manage_query'; @@ -33,10 +31,10 @@ import { HostPanelHeader } from './header'; import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider'; import type { ObservedEntityData } from '../shared/components/observed_entity/types'; import { useObservedHost } from './hooks/use_observed_host'; -import { HostDetailsPanelKey } from '../host_details_left'; import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header'; import { HostPreviewPanelFooter } from '../host_preview/footer'; -import { EntityEventTypes } from '../../../common/lib/telemetry'; +import { useNavigateToHostDetails } from './hooks/use_navigate_to_host_details'; + export interface HostPanelProps extends Record { contextID: string; scopeId: string; @@ -67,8 +65,6 @@ export const HostPanel = ({ isDraggable, isPreviewMode, }: HostPanelProps) => { - const { telemetry } = useKibana().services; - const { openLeftPanel } = useExpandableFlyoutApi(); const { to, from, isInitializing, setQuery, deleteQuery } = useGlobalTime(); const hostNameFilterQuery = useMemo( () => (hostName ? buildHostNamesFilter([hostName]) : undefined), @@ -119,45 +115,26 @@ export const HostPanel = ({ setQuery, }); - const openTabPanel = useCallback( - (tab?: EntityDetailsLeftPanelTab) => { - telemetry.reportEvent(EntityEventTypes.RiskInputsExpandedFlyoutOpened, { - entity: 'host', - }); - - openLeftPanel({ - id: HostDetailsPanelKey, - params: { - name: hostName, - scopeId, - isRiskScoreExist, - path: tab ? { tab } : undefined, - hasMisconfigurationFindings, - hasVulnerabilitiesFindings, - hasNonClosedAlerts, - }, - }); - }, - [ - telemetry, - openLeftPanel, - hostName, - scopeId, - isRiskScoreExist, - hasMisconfigurationFindings, - hasVulnerabilitiesFindings, - hasNonClosedAlerts, - ] - ); + const { openDetailsPanel, isLinkEnabled } = useNavigateToHostDetails({ + hostName, + scopeId, + isRiskScoreExist, + hasMisconfigurationFindings, + hasVulnerabilitiesFindings, + hasNonClosedAlerts, + isPreviewMode, + contextID, + isDraggable, + }); const openDefaultPanel = useCallback( () => - openTabPanel( - isRiskScoreExist + openDetailsPanel({ + tab: isRiskScoreExist ? EntityDetailsLeftPanelTab.RISK_INPUTS - : EntityDetailsLeftPanelTab.CSP_INSIGHTS - ), - [isRiskScoreExist, openTabPanel] + : EntityDetailsLeftPanelTab.CSP_INSIGHTS, + }), + [isRiskScoreExist, openDetailsPanel] ); const observedHost = useObservedHost(hostName, scopeId); @@ -204,7 +181,8 @@ export const HostPanel = ({ contextID={contextID} scopeId={scopeId} isDraggable={!!isDraggable} - openDetailsPanel={!isPreviewMode ? openTabPanel : undefined} + openDetailsPanel={openDetailsPanel} + isLinkEnabled={isLinkEnabled} recalculatingScore={recalculatingScore} onAssetCriticalityChange={calculateEntityRiskScore} isPreviewMode={isPreviewMode} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx index 254985b865840..2d7fc23115eb7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx @@ -31,6 +31,11 @@ export enum CspInsightLeftPanelSubTab { ALERTS = 'alertsTabId', } +export interface EntityDetailsPath { + tab: EntityDetailsLeftPanelTab; + subTab?: CspInsightLeftPanelSubTab; +} + export interface PanelHeaderProps { /** * Id of the tab selected in the parent component to display its content diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user.test.tsx index 632f32dca57b0..b02f81b0f445e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user.test.tsx @@ -20,6 +20,7 @@ describe('ManagedUser', () => { scopeId: '', isDraggable: false, openDetailsPanel: () => {}, + isLinkEnabled: true, }; it('renders', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user.tsx index a67de667612c8..48cb42e2a4335 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user.tsx @@ -18,7 +18,7 @@ import { import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/css'; -import type { EntityDetailsLeftPanelTab } from '../../shared/components/left_panel/left_panel_header'; +import type { EntityDetailsPath } from '../../shared/components/left_panel/left_panel_header'; import { UserAssetTableType } from '../../../../explore/users/store/model'; import type { ManagedUserFields } from '../../../../../common/search_strategy/security_solution/users/managed_details'; import { ManagedUserDatasetKey } from '../../../../../common/search_strategy/security_solution/users/managed_details'; @@ -44,11 +44,15 @@ export const ManagedUser = ({ contextID, isDraggable, openDetailsPanel, + isPreviewMode, + isLinkEnabled, }: { managedUser: ManagedUserData; contextID: string; isDraggable: boolean; - openDetailsPanel?: (tab: EntityDetailsLeftPanelTab) => void; + openDetailsPanel: (path: EntityDetailsPath) => void; + isPreviewMode?: boolean; + isLinkEnabled: boolean; }) => { const entraManagedUser = managedUser.data?.[ManagedUserDatasetKey.ENTRA]; const oktaManagedUser = managedUser.data?.[ManagedUserDatasetKey.OKTA]; @@ -127,6 +131,8 @@ export const ManagedUser = ({ managedUser={entraManagedUser.fields} tableType={UserAssetTableType.assetEntra} openDetailsPanel={openDetailsPanel} + isLinkEnabled={isLinkEnabled} + isPreviewMode={isPreviewMode} > { managedUser={mockEntraUserFields} tableType={UserAssetTableType.assetEntra} openDetailsPanel={() => {}} + isLinkEnabled >
@@ -28,5 +29,45 @@ describe('ManagedUserAccordion', () => { ); expect(getByTestId('test-children')).toBeInTheDocument(); + expect(getByTestId('managed-user-accordion-userAssetEntraTitleLink')).toBeInTheDocument(); + expect(getByTestId('managed-user-accordion-userAssetEntraTitleIcon')).toBeInTheDocument(); + }); + + it('renders link without icon when in preview mode', () => { + const { getByTestId, queryByTestId } = render( + + {}} + isLinkEnabled + isPreviewMode + > +
+ + + ); + + expect(getByTestId('managed-user-accordion-userAssetEntraTitleLink')).toBeInTheDocument(); + expect(queryByTestId('managed-user-accordion-userAssetEntraTitleIcon')).not.toBeInTheDocument(); + }); + + it('does not render link when link is not enabled', () => { + const { queryByTestId } = render( + + {}} + isLinkEnabled={false} + > +
+ + + ); + + expect(queryByTestId('managed-user-accordion-userAssetEntraTitleLink')).not.toBeInTheDocument(); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user_accordion.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user_accordion.tsx index 8d9007713549e..4e4745b33fc06 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user_accordion.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/managed_user_accordion.tsx @@ -11,6 +11,7 @@ import React from 'react'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; import { get } from 'lodash/fp'; +import type { EntityDetailsPath } from '../../shared/components/left_panel/left_panel_header'; import { EntityDetailsLeftPanelTab } from '../../shared/components/left_panel/left_panel_header'; import { ExpandablePanel } from '../../../shared/components/expandable_panel'; import type { ManagedUserFields } from '../../../../../common/search_strategy/security_solution/users/managed_details'; @@ -23,7 +24,9 @@ interface ManagedUserAccordionProps { title: string; managedUser: ManagedUserFields; tableType: UserAssetTableType; - openDetailsPanel?: (tab: EntityDetailsLeftPanelTab) => void; + openDetailsPanel: (path: EntityDetailsPath) => void; + isLinkEnabled: boolean; + isPreviewMode?: boolean; } export const ManagedUserAccordion: React.FC = ({ @@ -32,6 +35,8 @@ export const ManagedUserAccordion: React.FC = ({ managedUser, tableType, openDetailsPanel, + isLinkEnabled, + isPreviewMode, }) => { const xsFontSize = useEuiFontSize('xxs').fontSize; const timestamp = get('@timestamp[0]', managedUser) as unknown as string | undefined; @@ -41,7 +46,7 @@ export const ManagedUserAccordion: React.FC = ({ data-test-subj={`managed-user-accordion-${tableType}`} header={{ title, - iconType: 'arrowStart', + iconType: !isPreviewMode ? 'arrowStart' : undefined, headerContent: timestamp && ( = ({ ), link: { - callback: openDetailsPanel + callback: isLinkEnabled ? () => - openDetailsPanel( - tableType === UserAssetTableType.assetOkta - ? EntityDetailsLeftPanelTab.OKTA - : EntityDetailsLeftPanelTab.ENTRA - ) + openDetailsPanel({ + tab: + tableType === UserAssetTableType.assetOkta + ? EntityDetailsLeftPanelTab.OKTA + : EntityDetailsLeftPanelTab.ENTRA, + }) : undefined, tooltip: ( {}} recalculatingScore={false} + isLinkEnabled={true} /> )) .add('integration disabled', () => ( @@ -56,6 +57,7 @@ storiesOf('Components/UserPanelContent', module) userName={'test-user-name'} onAssetCriticalityChange={() => {}} recalculatingScore={false} + isLinkEnabled={true} /> )) .add('no managed data', () => ( @@ -74,6 +76,7 @@ storiesOf('Components/UserPanelContent', module) userName={'test-user-name'} onAssetCriticalityChange={() => {}} recalculatingScore={false} + isLinkEnabled={true} /> )) .add('no observed data', () => ( @@ -112,6 +115,7 @@ storiesOf('Components/UserPanelContent', module) userName={'test-user-name'} onAssetCriticalityChange={() => {}} recalculatingScore={false} + isLinkEnabled={true} /> )) .add('loading', () => ( @@ -154,5 +158,6 @@ storiesOf('Components/UserPanelContent', module) userName={'test-user-name'} onAssetCriticalityChange={() => {}} recalculatingScore={false} + isLinkEnabled={true} /> )); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx index 08295038a1bd8..975e780582ac6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx @@ -22,7 +22,7 @@ import { FlyoutBody } from '../../shared/components/flyout_body'; import { ObservedEntity } from '../shared/components/observed_entity'; import type { ObservedEntityData } from '../shared/components/observed_entity/types'; import { useObservedUserItems } from './hooks/use_observed_user_items'; -import type { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header'; +import type { EntityDetailsPath } from '../shared/components/left_panel/left_panel_header'; import { EntityInsight } from '../../../cloud_security_posture/components/entity_insight'; interface UserPanelContentProps { @@ -35,8 +35,9 @@ interface UserPanelContentProps { scopeId: string; isDraggable: boolean; onAssetCriticalityChange: () => void; - openDetailsPanel?: (tab: EntityDetailsLeftPanelTab) => void; + openDetailsPanel: (path: EntityDetailsPath) => void; isPreviewMode?: boolean; + isLinkEnabled: boolean; } export const UserPanelContent = ({ @@ -51,6 +52,7 @@ export const UserPanelContent = ({ openDetailsPanel, onAssetCriticalityChange, isPreviewMode, + isLinkEnabled, }: UserPanelContentProps) => { const observedFields = useObservedUserItems(observedUser); const isManagedUserEnable = useIsExperimentalFeatureEnabled('newUserDetailsFlyoutManagedUser'); @@ -65,6 +67,7 @@ export const UserPanelContent = ({ queryId={USER_PANEL_RISK_SCORE_QUERY_ID} openDetailsPanel={openDetailsPanel} isPreviewMode={isPreviewMode} + isLinkEnabled={isLinkEnabled} /> @@ -73,7 +76,13 @@ export const UserPanelContent = ({ entity={{ name: userName, type: 'user' }} onChange={onAssetCriticalityChange} /> - + )} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_navigate_to_user_details.test.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_navigate_to_user_details.test.ts new file mode 100644 index 0000000000000..58f9860389d69 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_navigate_to_user_details.test.ts @@ -0,0 +1,172 @@ +/* + * 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 { useNavigateToUserDetails } from './use_navigate_to_user_details'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { + CspInsightLeftPanelSubTab, + EntityDetailsLeftPanelTab, +} from '../../shared/components/left_panel/left_panel_header'; +import { UserDetailsPanelKey } from '../../user_details_left'; +import { UserPanelKey } from '..'; +import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; + +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../../../common/hooks/use_experimental_features'); + +const mockedTelemetry = createTelemetryServiceMock(); +jest.mock('../../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../../common/lib/kibana'); + return { + ...original, + useKibana: () => ({ + ...original.useKibana(), + services: { + ...original.useKibana().services, + telemetry: mockedTelemetry, + }, + }), + }; +}); + +const mockProps = { + userName: 'testUser', + scopeId: 'testScopeId', + isRiskScoreExist: false, + hasMisconfigurationFindings: false, + hasNonClosedAlerts: false, + contextID: 'testContextID', + isPreviewMode: false, + email: ['test@test.com'], +}; + +const tab = EntityDetailsLeftPanelTab.RISK_INPUTS; +const subTab = CspInsightLeftPanelSubTab.MISCONFIGURATIONS; + +const mockOpenLeftPanel = jest.fn(); +const mockOpenFlyout = jest.fn(); + +describe('useNavigateToUserDetails', () => { + describe('when preview navigation is enabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ + openLeftPanel: mockOpenLeftPanel, + openFlyout: mockOpenFlyout, + }); + }); + + it('returns callback that opens details panel when not in preview mode', () => { + const { result } = renderHook(() => useNavigateToUserDetails(mockProps)); + + expect(result.current.isLinkEnabled).toBe(true); + result.current.openDetailsPanel({ tab, subTab }); + + expect(result.current.isLinkEnabled).toBe(true); + result.current.openDetailsPanel({ tab, subTab }); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: UserDetailsPanelKey, + params: { + user: { + name: mockProps.userName, + email: mockProps.email, + }, + scopeId: mockProps.scopeId, + isRiskScoreExist: mockProps.isRiskScoreExist, + path: { tab, subTab }, + hasMisconfigurationFindings: mockProps.hasMisconfigurationFindings, + hasNonClosedAlerts: mockProps.hasNonClosedAlerts, + }, + }); + }); + + it('returns callback that opens flyout when in preview mode', () => { + const { result } = renderHook(() => + useNavigateToUserDetails({ ...mockProps, isPreviewMode: true }) + ); + + expect(result.current.isLinkEnabled).toBe(true); + result.current.openDetailsPanel({ tab, subTab }); + + expect(mockOpenFlyout).toHaveBeenCalledWith({ + right: { + id: UserPanelKey, + params: { + contextID: mockProps.contextID, + scopeId: mockProps.scopeId, + userName: mockProps.userName, + }, + }, + left: { + id: UserDetailsPanelKey, + params: { + user: { + name: mockProps.userName, + email: mockProps.email, + }, + scopeId: mockProps.scopeId, + isRiskScoreExist: mockProps.isRiskScoreExist, + path: { tab, subTab }, + hasMisconfigurationFindings: mockProps.hasMisconfigurationFindings, + hasNonClosedAlerts: mockProps.hasNonClosedAlerts, + }, + }, + }); + expect(mockOpenLeftPanel).not.toHaveBeenCalled(); + }); + }); + + describe('when preview navigation is disabled', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ + openLeftPanel: mockOpenLeftPanel, + openFlyout: mockOpenFlyout, + }); + }); + + it('returns callback that opens details panel when not in preview mode', () => { + const { result } = renderHook(() => useNavigateToUserDetails(mockProps)); + + expect(result.current.isLinkEnabled).toBe(true); + result.current.openDetailsPanel({ tab, subTab }); + + expect(mockOpenLeftPanel).toHaveBeenCalledWith({ + id: UserDetailsPanelKey, + params: { + user: { + name: mockProps.userName, + email: mockProps.email, + }, + scopeId: mockProps.scopeId, + isRiskScoreExist: mockProps.isRiskScoreExist, + path: { tab, subTab }, + hasMisconfigurationFindings: mockProps.hasMisconfigurationFindings, + hasNonClosedAlerts: mockProps.hasNonClosedAlerts, + }, + }); + expect(mockOpenFlyout).not.toHaveBeenCalled(); + }); + + it('returns empty callback and isLinkEnabled is false when in preview mode', () => { + const { result } = renderHook(() => + useNavigateToUserDetails({ ...mockProps, isPreviewMode: true }) + ); + + expect(result.current.isLinkEnabled).toBe(false); + result.current.openDetailsPanel({ tab, subTab }); + + expect(mockOpenLeftPanel).not.toHaveBeenCalled(); + expect(mockOpenFlyout).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_navigate_to_user_details.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_navigate_to_user_details.ts new file mode 100644 index 0000000000000..1eed953f703b6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_navigate_to_user_details.ts @@ -0,0 +1,114 @@ +/* + * 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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useCallback } from 'react'; +import type { EntityDetailsPath } from '../../shared/components/left_panel/left_panel_header'; +import { UserPanelKey } from '..'; +import { useKibana } from '../../../../common/lib/kibana'; +import { EntityEventTypes } from '../../../../common/lib/telemetry'; +import { UserDetailsPanelKey } from '../../user_details_left'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; + +interface UseNavigateToUserDetailsParams { + userName: string; + email?: string[]; + scopeId: string; + contextID: string; + isDraggable?: boolean; + isRiskScoreExist: boolean; + hasMisconfigurationFindings: boolean; + hasNonClosedAlerts: boolean; + isPreviewMode?: boolean; +} + +interface UseNavigateToUserDetailsResult { + /** + * Opens the user details panel + */ + openDetailsPanel: (path: EntityDetailsPath) => void; + /** + * Whether the link is enabled + */ + isLinkEnabled: boolean; +} + +export const useNavigateToUserDetails = ({ + userName, + email, + scopeId, + contextID, + isDraggable, + isRiskScoreExist, + hasMisconfigurationFindings, + hasNonClosedAlerts, + isPreviewMode, +}: UseNavigateToUserDetailsParams): UseNavigateToUserDetailsResult => { + const { telemetry } = useKibana().services; + const { openLeftPanel, openFlyout } = useExpandableFlyoutApi(); + const isNewNavigationEnabled = useIsExperimentalFeatureEnabled( + 'newExpandableFlyoutNavigationEnabled' + ); + + const isLinkEnabled = !isPreviewMode || (isNewNavigationEnabled && isPreviewMode); + + const openDetailsPanel = useCallback( + (path: EntityDetailsPath) => { + telemetry.reportEvent(EntityEventTypes.RiskInputsExpandedFlyoutOpened, { entity: 'user' }); + + const left = { + id: UserDetailsPanelKey, + params: { + isRiskScoreExist, + scopeId, + user: { + name: userName, + email, + }, + path, + hasMisconfigurationFindings, + hasNonClosedAlerts, + }, + }; + + const right = { + id: UserPanelKey, + params: { + contextID, + userName, + scopeId, + isDraggable, + }, + }; + + // When new navigation is enabled, nevigation in preview is enabled and open a new flyout + if (isNewNavigationEnabled && isPreviewMode) { + openFlyout({ right, left }); + } + // When not in preview mode, open left panel as usual + else if (!isPreviewMode) { + openLeftPanel(left); + } + }, + [ + telemetry, + openLeftPanel, + isRiskScoreExist, + scopeId, + userName, + email, + hasMisconfigurationFindings, + hasNonClosedAlerts, + isNewNavigationEnabled, + isPreviewMode, + openFlyout, + contextID, + isDraggable, + ] + ); + + return { openDetailsPanel, isLinkEnabled }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx index 182740a5afa57..7d11cb80369c4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useMemo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations'; import { TableId } from '@kbn/securitysolution-data-table'; import { useNonClosedAlerts } from '../../../cloud_security_posture/hooks/use_non_closed_alerts'; @@ -15,7 +14,6 @@ import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_ref import type { Refetch } from '../../../common/types'; import { RISK_INPUTS_TAB_QUERY_ID } from '../../../entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab'; import { useCalculateEntityRiskScore } from '../../../entity_analytics/api/hooks/use_calculate_entity_risk_score'; -import { useKibana } from '../../../common/lib/kibana/kibana_react'; import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; import { ManagedUserDatasetKey } from '../../../../common/search_strategy/security_solution/users/managed_details'; import { useManagedUser } from '../shared/hooks/use_managed_user'; @@ -30,12 +28,11 @@ import { FlyoutLoading } from '../../shared/components/flyout_loading'; import { FlyoutNavigation } from '../../shared/components/flyout_navigation'; import { UserPanelContent } from './content'; import { UserPanelHeader } from './header'; -import { UserDetailsPanelKey } from '../user_details_left'; import { useObservedUser } from './hooks/use_observed_user'; import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header'; import { UserPreviewPanelFooter } from '../user_preview/footer'; import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from '../../../overview/components/detection_response/alerts_by_status/types'; -import { EntityEventTypes } from '../../../common/lib/telemetry'; +import { useNavigateToUserDetails } from './hooks/use_navigate_to_user_details'; export interface UserPanelProps extends Record { contextID: string; @@ -65,7 +62,6 @@ export const UserPanel = ({ isDraggable, isPreviewMode, }: UserPanelProps) => { - const { telemetry } = useKibana().services; const userNameFilterQuery = useMemo( () => (userName ? buildUserNamesFilter([userName]) : undefined), [userName] @@ -120,47 +116,26 @@ export const UserPanel = ({ setQuery, }); - const { openLeftPanel } = useExpandableFlyoutApi(); - const openPanelTab = useCallback( - (tab?: EntityDetailsLeftPanelTab) => { - telemetry.reportEvent(EntityEventTypes.RiskInputsExpandedFlyoutOpened, { - entity: 'user', - }); - - openLeftPanel({ - id: UserDetailsPanelKey, - params: { - isRiskScoreExist: !!userRiskData?.user?.risk, - scopeId, - user: { - name: userName, - email, - }, - path: tab ? { tab } : undefined, - hasMisconfigurationFindings, - hasNonClosedAlerts, - }, - }); - }, - [ - telemetry, - openLeftPanel, - userRiskData?.user?.risk, - scopeId, - userName, - email, - hasMisconfigurationFindings, - hasNonClosedAlerts, - ] - ); + const { openDetailsPanel, isLinkEnabled } = useNavigateToUserDetails({ + userName, + email, + scopeId, + contextID, + isDraggable, + isRiskScoreExist: !!userRiskData?.user?.risk, + hasMisconfigurationFindings, + hasNonClosedAlerts, + isPreviewMode, + }); + const openPanelFirstTab = useCallback( () => - openPanelTab( - isRiskScoreExist + openDetailsPanel({ + tab: isRiskScoreExist ? EntityDetailsLeftPanelTab.RISK_INPUTS - : EntityDetailsLeftPanelTab.CSP_INSIGHTS - ), - [isRiskScoreExist, openPanelTab] + : EntityDetailsLeftPanelTab.CSP_INSIGHTS, + }), + [isRiskScoreExist, openDetailsPanel] ); const hasUserDetailsData = @@ -213,8 +188,9 @@ export const UserPanel = ({ contextID={contextID} scopeId={scopeId} isDraggable={!!isDraggable} - openDetailsPanel={!isPreviewMode ? openPanelTab : undefined} + openDetailsPanel={openDetailsPanel} isPreviewMode={isPreviewMode} + isLinkEnabled={isLinkEnabled} /> {isPreviewMode && ( { +export const AdditionalChargesMessage: React.FC = () => { return (
{ADDITIONAL_CHARGES_MESSAGE} @@ -18,4 +18,4 @@ export const EnablementModalCallout: React.FC = () => { }; // eslint-disable-next-line import/no-default-export -export default EnablementModalCallout; +export default AdditionalChargesMessage; diff --git a/x-pack/solutions/security/plugins/security_solution_serverless/public/components/enablement_modal_callout/index.tsx b/x-pack/solutions/security/plugins/security_solution_serverless/public/components/additional_charges_message/index.tsx similarity index 68% rename from x-pack/solutions/security/plugins/security_solution_serverless/public/components/enablement_modal_callout/index.tsx rename to x-pack/solutions/security/plugins/security_solution_serverless/public/components/additional_charges_message/index.tsx index 0bc65a33d6530..c35e4ed653ffa 100644 --- a/x-pack/solutions/security/plugins/security_solution_serverless/public/components/enablement_modal_callout/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution_serverless/public/components/additional_charges_message/index.tsx @@ -7,13 +7,13 @@ import React from 'react'; import type { Services } from '../../common/services'; import { ServicesProvider } from '../../common/services'; -import { EnablementModalCallout } from './lazy'; +import { AdditionalChargesMessage } from './lazy'; -export const getEnablementModalCallout = (services: Services): React.ComponentType => - function EnablementModalCalloutComponent() { +export const getAdditionalChargesMessage = (services: Services): React.ComponentType => + function AdditionalChargesMessageComponent() { return ( - + ); }; diff --git a/x-pack/solutions/security/plugins/security_solution_serverless/public/components/enablement_modal_callout/lazy.tsx b/x-pack/solutions/security/plugins/security_solution_serverless/public/components/additional_charges_message/lazy.tsx similarity index 70% rename from x-pack/solutions/security/plugins/security_solution_serverless/public/components/enablement_modal_callout/lazy.tsx rename to x-pack/solutions/security/plugins/security_solution_serverless/public/components/additional_charges_message/lazy.tsx index 547a15fc535e9..e2708b7351019 100644 --- a/x-pack/solutions/security/plugins/security_solution_serverless/public/components/enablement_modal_callout/lazy.tsx +++ b/x-pack/solutions/security/plugins/security_solution_serverless/public/components/additional_charges_message/lazy.tsx @@ -8,10 +8,10 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; -const EnablementModalCalloutLazy = lazy(() => import('./enablement_modal_callout')); +const AdditionalChargesMessageLazy = lazy(() => import('./additional_charges_message')); -export const EnablementModalCallout = () => ( +export const AdditionalChargesMessage = () => ( }> - + ); diff --git a/x-pack/solutions/security/plugins/security_solution_serverless/public/plugin.ts b/x-pack/solutions/security/plugins/security_solution_serverless/public/plugin.ts index 30e0f86ccdacf..09eb56ec0edcb 100644 --- a/x-pack/solutions/security/plugins/security_solution_serverless/public/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution_serverless/public/plugin.ts @@ -23,7 +23,7 @@ import { type ExperimentalFeatures, } from '../common/experimental_features'; import { setOnboardingSettings } from './onboarding'; -import { getEnablementModalCallout } from './components/enablement_modal_callout'; +import { getAdditionalChargesMessage } from './components/additional_charges_message'; export class SecuritySolutionServerlessPlugin implements @@ -70,7 +70,7 @@ export class SecuritySolutionServerlessPlugin securitySolution.setComponents({ DashboardsLandingCallout: getDashboardsLandingCallout(services), - EnablementModalCallout: getEnablementModalCallout(services), + AdditionalChargesMessage: getAdditionalChargesMessage(services), }); setOnboardingSettings(services); diff --git a/x-pack/test/cloud_security_posture_api/routes/graph.ts b/x-pack/test/cloud_security_posture_api/routes/graph.ts index e2be81a7d40e5..18a61b85c5f40 100644 --- a/x-pack/test/cloud_security_posture_api/routes/graph.ts +++ b/x-pack/test/cloud_security_posture_api/routes/graph.ts @@ -75,7 +75,7 @@ export default function (providerContext: FtrProviderContext) { }); describe('Validation', () => { - it('should return 400 when missing `eventIds` field', async () => { + it('should return 400 when missing `originEventIds` field', async () => { await postGraph(supertest, { // @ts-expect-error ignore error for testing query: { @@ -171,6 +171,7 @@ export default function (providerContext: FtrProviderContext) { 'primary', `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` ); + expect(edge.type).equal('dashed'); }); }); @@ -201,6 +202,7 @@ export default function (providerContext: FtrProviderContext) { 'danger', `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` ); + expect(edge.type).equal('solid'); }); }); @@ -231,6 +233,7 @@ export default function (providerContext: FtrProviderContext) { 'primary', `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` ); + expect(edge.type).equal('solid'); }); }); @@ -261,6 +264,7 @@ export default function (providerContext: FtrProviderContext) { 'danger', `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` ); + expect(edge.type).equal('solid'); }); }); @@ -303,6 +307,7 @@ export default function (providerContext: FtrProviderContext) { 'warning', `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` ); + expect(edge.type).equal('dashed'); }); }); @@ -351,10 +356,11 @@ export default function (providerContext: FtrProviderContext) { : 'primary', `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` ); + expect(edge.type).equal('dashed'); }); }); - it('should support more than 1 eventIds', async () => { + it('should support more than 1 originEventIds', async () => { const response = await postGraph(supertest, { query: { originEventIds: [ @@ -384,6 +390,7 @@ export default function (providerContext: FtrProviderContext) { 'danger', `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` ); + expect(edge.type).equal('solid'); }); }); @@ -429,6 +436,7 @@ export default function (providerContext: FtrProviderContext) { idx <= 1 ? 'danger' : 'warning', `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` ); + expect(edge.type).equal(idx <= 1 ? 'solid' : 'dashed'); }); });