diff --git a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx index d88a0d3660473..b7cd105a7b004 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/plugin.tsx @@ -76,6 +76,7 @@ import { defaultDeepLinks } from './app/links/default_deep_links'; import { AIValueReportLocatorDefinition } from '../common/locators/ai_value_report/locator'; import { registerAttachmentUiDefinitions } from './agent_builder/attachment_types'; import { registerRuleAttachment } from './agent_builder/attachment_types/rule_attachment'; +import { registerWorkflowSteps } from './workflows/step_types'; export class Plugin implements IPlugin { private config: SecuritySolutionUiConfigType; @@ -127,20 +128,8 @@ export class Plugin implements IPlugin { - const [coreStart] = await core.getStartServices(); - return registerWorkflowSteps(workflowsExtensions, coreStart); - }) - .catch((error) => { - this.logger.error( - `Error registering security workflow steps: ${ - error instanceof Error ? error.message : String(error) - }` - ); - }); + registerWorkflowSteps(workflowsExtensions, core); } // Lazily instantiate subPlugins and initialize services diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/jest.config.js b/x-pack/solutions/security/plugins/security_solution/public/workflows/jest.config.js new file mode 100644 index 0000000000000..3486315858190 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/jest.config.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../../../..', + roots: ['/x-pack/solutions/security/plugins/security_solution/public/workflows'], + coverageDirectory: + '/target/kibana-coverage/jest/x-pack/solutions/security/plugins/security_solution/public/workflows', + coverageReporters: ['text', 'html'], + collectCoverageFrom: [ + '/x-pack/solutions/security/plugins/security_solution/public/workflows/**/*.{ts,tsx}', + ], + moduleNameMapper: require('../../server/__mocks__/module_name_map'), +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.test.ts b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.test.ts new file mode 100644 index 0000000000000..11510c6cbd5e6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { coreMock } from '@kbn/core/public/mocks'; +import type { + WorkflowsExtensionsPublicPluginSetup, + PublicStepDefinition, +} from '@kbn/workflows-extensions/public'; +import { registerWorkflowSteps } from './register_workflow_steps'; +import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step'; +import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step'; +import { + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT, +} from '../../../common/constants'; + +type StepLoader = () => Promise; + +const createWorkflowsExtensionsMock = (): jest.Mocked => ({ + registerStepDefinition: jest.fn(), + registerTriggerDefinition: jest.fn(), +}); + +describe('registerWorkflowSteps (public)', () => { + const buildCoreMock = (featureFlagEnabled: boolean) => { + const core = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + coreStart.featureFlags.getBooleanValue.mockReturnValue(featureFlagEnabled); + core.getStartServices.mockResolvedValue([coreStart, {}, {}]); + return { core, coreStart }; + }; + + it('calls registerStepDefinition synchronously for both steps', () => { + const { core } = buildCoreMock(true); + const workflowsExtensions = createWorkflowsExtensionsMock(); + + registerWorkflowSteps(workflowsExtensions, core); + + expect(workflowsExtensions.registerStepDefinition).toHaveBeenCalledTimes(2); + // getStartServices is called once eagerly to create the shared memoized promise + expect(core.getStartServices).toHaveBeenCalledTimes(1); + }); + + it('async loader returns step definitions when feature flag is enabled', async () => { + const { core } = buildCoreMock(true); + const workflowsExtensions = createWorkflowsExtensionsMock(); + + registerWorkflowSteps(workflowsExtensions, core); + + const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map( + ([arg]) => arg as StepLoader + ); + + await expect(loader1()).resolves.toBe(renderAlertNarrativeStepDefinition); + await expect(loader2()).resolves.toBe(buildAlertEntityGraphStepDefinition); + }); + + it('async loader returns undefined when feature flag is disabled', async () => { + const { core } = buildCoreMock(false); + const workflowsExtensions = createWorkflowsExtensionsMock(); + + registerWorkflowSteps(workflowsExtensions, core); + + const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map( + ([arg]) => arg as StepLoader + ); + + await expect(loader1()).resolves.toBeUndefined(); + await expect(loader2()).resolves.toBeUndefined(); + }); + + it('checks the feature flag exactly once even when both loaders resolve', async () => { + const { core, coreStart } = buildCoreMock(true); + const workflowsExtensions = createWorkflowsExtensionsMock(); + + registerWorkflowSteps(workflowsExtensions, core); + + const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map( + ([arg]) => arg as StepLoader + ); + await Promise.all([loader1(), loader2()]); + + expect(coreStart.featureFlags.getBooleanValue).toHaveBeenCalledTimes(1); + expect(coreStart.featureFlags.getBooleanValue).toHaveBeenCalledWith( + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.ts b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.ts index d94ec08351671..4e052ccd5c150 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/workflows/step_types/register_workflow_steps.ts @@ -6,28 +6,41 @@ */ import type { WorkflowsExtensionsPublicPluginSetup } from '@kbn/workflows-extensions/public'; -import type { CoreStart } from '@kbn/core/public'; -import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step'; -import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step'; +import type { CoreSetup } from '@kbn/core/public'; import { REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT, } from '../../../common/constants'; /** - * Registers all security workflow steps with the workflowsExtensions plugin + * Registers all security workflow steps with the workflowsExtensions plugin. + * Registration is synchronous; each step uses an async loader to perform the + * feature-flag check at resolution time. */ -export const registerWorkflowSteps = async ( +export const registerWorkflowSteps = ( workflowsExtensions: WorkflowsExtensionsPublicPluginSetup, - core: CoreStart -): Promise => { - const registerAlertValidationStepsEnabled = await core.featureFlags.getBooleanValue( - REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, - REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT - ); + core: CoreSetup +): void => { + const isEnabled = core + .getStartServices() + .then(([coreStart]) => + coreStart.featureFlags.getBooleanValue( + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT + ) + ); - if (registerAlertValidationStepsEnabled) { - workflowsExtensions.registerStepDefinition(renderAlertNarrativeStepDefinition); - workflowsExtensions.registerStepDefinition(buildAlertEntityGraphStepDefinition); - } + workflowsExtensions.registerStepDefinition(async () => { + if (!(await isEnabled)) return undefined; + return import('./render_alert_narrative_step').then( + (m) => m.renderAlertNarrativeStepDefinition + ); + }); + + workflowsExtensions.registerStepDefinition(async () => { + if (!(await isEnabled)) return undefined; + return import('./build_alert_entity_graph_step').then( + (m) => m.buildAlertEntityGraphStepDefinition + ); + }); }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts index 9606ab9bc098a..b8131745657a8 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/plugin.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/plugin.ts @@ -820,20 +820,7 @@ export class Plugin implements ISecuritySolutionPlugin { this.registerAgentBuilderAttachmentsAndTools(plugins, core, this.logger); if (plugins.workflowsExtensions) { - const workflowsExtensions = plugins.workflowsExtensions; - core - .getStartServices() - .then(async ([coreStart]) => { - await registerWorkflowSteps(workflowsExtensions, coreStart); - }) - .catch((error) => { - this.logger.error( - `[RegisterAlertValidationSteps] Error registering alert validation steps: ${error.message}`, - { - error: error.stack, - } - ); - }); + registerWorkflowSteps(plugins.workflowsExtensions, core); } setupAlertsCapabilitiesSwitcher({ diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.test.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.test.ts new file mode 100644 index 0000000000000..c7a6506d32684 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { coreMock } from '@kbn/core/server/mocks'; +import type { + WorkflowsExtensionsServerPluginSetup, + ServerStepDefinition, +} from '@kbn/workflows-extensions/server'; +import { registerWorkflowSteps } from './register_workflow_steps'; +import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step'; +import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step'; +import { + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT, +} from '../../../common/constants'; + +type StepLoader = () => Promise; + +const createWorkflowsExtensionsMock = (): jest.Mocked => ({ + registerStepDefinition: jest.fn(), + registerTriggerDefinition: jest.fn(), + registerTriggerEventHandler: jest.fn(), +}); + +describe('registerWorkflowSteps (server)', () => { + const buildCoreMock = (featureFlagEnabled: boolean) => { + const core = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + coreStart.featureFlags.getBooleanValue.mockResolvedValue(featureFlagEnabled); + core.getStartServices.mockResolvedValue([coreStart, {}, {}]); + return { core, coreStart }; + }; + + it('calls registerStepDefinition synchronously for both steps', () => { + const { core } = buildCoreMock(true); + const workflowsExtensions = createWorkflowsExtensionsMock(); + + registerWorkflowSteps(workflowsExtensions, core); + + expect(workflowsExtensions.registerStepDefinition).toHaveBeenCalledTimes(2); + // getStartServices is called once eagerly to create the shared memoized promise + expect(core.getStartServices).toHaveBeenCalledTimes(1); + }); + + it('async loader returns step definitions when feature flag is enabled', async () => { + const { core } = buildCoreMock(true); + const workflowsExtensions = createWorkflowsExtensionsMock(); + + registerWorkflowSteps(workflowsExtensions, core); + + const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map( + ([arg]) => arg as StepLoader + ); + + await expect(loader1()).resolves.toBe(renderAlertNarrativeStepDefinition); + await expect(loader2()).resolves.toBe(buildAlertEntityGraphStepDefinition); + }); + + it('async loader returns undefined when feature flag is disabled', async () => { + const { core } = buildCoreMock(false); + const workflowsExtensions = createWorkflowsExtensionsMock(); + + registerWorkflowSteps(workflowsExtensions, core); + + const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map( + ([arg]) => arg as StepLoader + ); + + await expect(loader1()).resolves.toBeUndefined(); + await expect(loader2()).resolves.toBeUndefined(); + }); + + it('checks the feature flag exactly once even when both loaders resolve', async () => { + const { core, coreStart } = buildCoreMock(true); + const workflowsExtensions = createWorkflowsExtensionsMock(); + + registerWorkflowSteps(workflowsExtensions, core); + + const [loader1, loader2] = workflowsExtensions.registerStepDefinition.mock.calls.map( + ([arg]) => arg as StepLoader + ); + await Promise.all([loader1(), loader2()]); + + expect(coreStart.featureFlags.getBooleanValue).toHaveBeenCalledTimes(1); + expect(coreStart.featureFlags.getBooleanValue).toHaveBeenCalledWith( + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT + ); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.ts b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.ts index 978ad0ff0dc2d..2803a60022f36 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/workflows/step_types/register_workflow_steps.ts @@ -6,7 +6,7 @@ */ import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; -import type { CoreStart } from '@kbn/core/server'; +import type { CoreSetup } from '@kbn/core/server'; import { renderAlertNarrativeStepDefinition } from './render_alert_narrative_step'; import { buildAlertEntityGraphStepDefinition } from './build_alert_entity_graph_step'; import { @@ -15,19 +15,30 @@ import { } from '../../../common/constants'; /** - * Registers all security workflow steps with the workflowsExtensions plugin + * Registers all security workflow steps with the workflowsExtensions plugin. + * Registration is synchronous; each step uses an async loader to perform the + * feature-flag check at resolution time. */ -export const registerWorkflowSteps = async ( +export const registerWorkflowSteps = ( workflowsExtensions: WorkflowsExtensionsServerPluginSetup, - coreStart: CoreStart -): Promise => { - const registerAlertValidationStepsEnabled = await coreStart.featureFlags.getBooleanValue( - REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, - REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT - ); + core: CoreSetup +): void => { + const isEnabled = core + .getStartServices() + .then(([coreStart]) => + coreStart.featureFlags.getBooleanValue( + REGISTER_ALERT_VALIDATION_STEPS_FEATURE_FLAG, + REGISTER_ALERT_VALIDATION_STEP_FEATURE_FLAG_DEFAULT + ) + ); - if (registerAlertValidationStepsEnabled) { - workflowsExtensions.registerStepDefinition(renderAlertNarrativeStepDefinition); - workflowsExtensions.registerStepDefinition(buildAlertEntityGraphStepDefinition); - } + workflowsExtensions.registerStepDefinition(async () => { + if (!(await isEnabled)) return undefined; + return renderAlertNarrativeStepDefinition; + }); + + workflowsExtensions.registerStepDefinition(async () => { + if (!(await isEnabled)) return undefined; + return buildAlertEntityGraphStepDefinition; + }); };