diff --git a/docs/api-generated/rules/rule-apis-passthru.asciidoc b/docs/api-generated/rules/rule-apis-passthru.asciidoc index 39213b9d8807b..1dea766b0dfb3 100644 --- a/docs/api-generated/rules/rule-apis-passthru.asciidoc +++ b/docs/api-generated/rules/rule-apis-passthru.asciidoc @@ -2848,11 +2848,10 @@ Any modifications made to this file will be overwritten.
  • actions_inner -
  • actions_inner_alerts_filter -
  • actions_inner_alerts_filter_query -
  • -
  • actions_inner_alerts_filter_query_filters_inner -
  • -
  • actions_inner_alerts_filter_query_filters_inner_meta -
  • actions_inner_alerts_filter_timeframe -
  • actions_inner_alerts_filter_timeframe_hours -
  • actions_inner_frequency -
  • +
  • aggtype -
  • alert_response_properties - Legacy alert response properties
  • alert_response_properties_executionStatus -
  • alert_response_properties_schedule -
  • @@ -2904,6 +2903,8 @@ Any modifications made to this file will be overwritten.
  • custom_criterion_customMetric_inner -
  • custom_criterion_customMetric_inner_oneOf -
  • custom_criterion_customMetric_inner_oneOf_1 -
  • +
  • filter -
  • +
  • filter_meta -
  • findRules_200_response -
  • findRules_has_reference_parameter -
  • findRules_search_fields_parameter -
  • @@ -2920,6 +2921,7 @@ Any modifications made to this file will be overwritten.
  • getRuleTypes_200_response_inner_authorized_consumers -
  • getRuleTypes_200_response_inner_authorized_consumers_alerts -
  • getRuleTypes_200_response_inner_recovery_action_group -
  • +
  • groupby -
  • legacyFindAlerts_200_response -
  • legacyGetAlertTypes_200_response_inner -
  • legacyGetAlertTypes_200_response_inner_actionVariables -
  • @@ -2932,6 +2934,11 @@ Any modifications made to this file will be overwritten.
  • legacyGetAlertingHealth_200_response_alertingFrameworkHealth_readHealth -
  • non_count_criterion - non count criterion
  • notify_when -
  • +
  • params_es_query_rule -
  • +
  • params_es_query_rule_oneOf -
  • +
  • params_es_query_rule_oneOf_1 -
  • +
  • params_es_query_rule_oneOf_searchConfiguration -
  • +
  • params_es_query_rule_oneOf_searchConfiguration_query -
  • params_index_threshold_rule -
  • params_property_apm_anomaly -
  • params_property_apm_error_count -
  • @@ -2957,6 +2964,8 @@ Any modifications made to this file will be overwritten.
  • rule_response_properties_last_run -
  • rule_response_properties_last_run_alerts_count -
  • schedule -
  • +
  • thresholdcomparator -
  • +
  • timewindowunit -
  • update_rule_request - Update rule request
  • @@ -3128,34 +3137,7 @@ Any modifications made to this file will be overwritten.
    Defines a query filter that determines whether the action runs.
    kql (optional)
    String A filter written in Kibana Query Language (KQL).
    -
    filters (optional)
    array[actions_inner_alerts_filter_query_filters_inner]
    -
    - -
    -

    actions_inner_alerts_filter_query_filters_inner - Up

    -
    A filter written in Elasticsearch Query Domain Specific Language (DSL) as defined in the kbn-es-query package.
    -
    -
    meta (optional)
    -
    query (optional)
    -
    Dollarstate (optional)
    -
    -
    -
    -

    actions_inner_alerts_filter_query_filters_inner_meta - Up

    -
    -
    -
    alias (optional)
    -
    controlledBy (optional)
    -
    disabled (optional)
    -
    field (optional)
    -
    group (optional)
    -
    index (optional)
    -
    isMultiIndex (optional)
    -
    key (optional)
    -
    negate (optional)
    -
    params (optional)
    -
    type (optional)
    -
    value (optional)
    +
    filters (optional)
    @@ -3184,6 +3166,12 @@ Any modifications made to this file will be overwritten.
    throttle (optional)
    String The throttle interval, which defines how often an alert generates repeated actions. It is specified in seconds, minutes, hours, or days and is applicable only if notify_when is set to onThrottleInterval. NOTE: You cannot specify the throttle interval at both the rule and action level. The recommended method is to set it for each action. If you set it at the rule level then update the rule in Kibana, it is automatically changed to use action-specific values.
    +
    +

    aggtype - Up

    +
    The type of aggregation to perform.
    +
    +
    +

    alert_response_properties - Legacy alert response properties Up

    @@ -3360,7 +3348,7 @@ Any modifications made to this file will be overwritten.
    enabled (optional)
    Boolean Indicates whether you want to run the rule on an interval basis after it is created.
    name
    String The name of the rule. While this name does not have to be unique, a distinctive name can help you identify a rule.
    notify_when (optional)
    notify_when
    -
    params
    map[String, oas_any_type_not_mapped] The parameters for an Elasticsearch query rule.
    +
    params
    params_es_query_rule
    rule_type_id
    String The ID of the rule type that you want to call when the rule is scheduled to run.
    Enum:
    .es-query
    @@ -4073,6 +4061,33 @@ Any modifications made to this file will be overwritten.
    filter (optional)
    String
    +
    +

    filter - Up

    +
    A filter written in Elasticsearch Query Domain Specific Language (DSL) as defined in the kbn-es-query package.
    +
    +
    meta (optional)
    +
    query (optional)
    +
    Dollarstate (optional)
    +
    +
    +
    +

    filter_meta - Up

    +
    +
    +
    alias (optional)
    +
    controlledBy (optional)
    +
    disabled (optional)
    +
    field (optional)
    +
    group (optional)
    +
    index (optional)
    +
    isMultiIndex (optional)
    +
    key (optional)
    +
    negate (optional)
    +
    params (optional)
    +
    type (optional)
    +
    value (optional)
    +
    +

    findRules_200_response - Up

    @@ -4230,6 +4245,12 @@ Any modifications made to this file will be overwritten.
    name (optional)
    String
    +
    +

    groupby - Up

    +
    Indicates whether the aggregation is applied over all documents (all) or split into groups (top) using a grouping field (termField). If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to termSize number of groups) are checked.
    +
    +
    +

    legacyFindAlerts_200_response - Up

    @@ -4356,30 +4377,108 @@ Any modifications made to this file will be overwritten.
    +
    +

    params_es_query_rule - Up

    +
    +
    +
    aggField (optional)
    String The name of the numeric field that is used in the aggregation. This property is required when aggType is avg, max, min or sum.
    +
    aggType (optional)
    +
    excludeHitsFromPreviousRun (optional)
    Boolean Indicates whether to exclude matches from previous runs. If true, you can avoid alert duplication by excluding documents that have already been detected by the previous rule run. This option is not available when a grouping field is specified.
    +
    groupBy (optional)
    +
    searchConfiguration (optional)
    +
    searchType
    String The type of query, in this case a query that uses Elasticsearch Query DSL.
    +
    Enum:
    +
    esQuery
    +
    size
    Integer The number of documents to pass to the configured actions when the threshold condition is met.
    +
    termField (optional)
    String This property is required when groupBy is top. The name of the field that is used for grouping the aggregation.
    +
    termSize (optional)
    Integer This property is required when groupBy is top. It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields.
    +
    threshold
    array[Integer] The threshold value that is used with the thresholdComparator. If the thresholdComparator is between or notBetween, you must specify the boundary values.
    +
    thresholdComparator
    +
    timeField
    String The field that is used to calculate the time window.
    +
    timeWindowSize
    Integer The size of the time window (in timeWindowUnit units), which determines how far back to search for documents. Generally it should be a value higher than the rule check interval to avoid gaps in detection.
    +
    timeWindowUnit
    +
    esQuery
    String The query definition, which uses Elasticsearch Query DSL.
    +
    index
    oneOf The indices to query.
    +
    +
    +
    +

    params_es_query_rule_oneOf - Up

    +
    The parameters for an Elasticsearch query rule that uses KQL or Lucene to define the query.
    +
    +
    aggField (optional)
    String The name of the numeric field that is used in the aggregation. This property is required when aggType is avg, max, min or sum.
    +
    aggType (optional)
    +
    excludeHitsFromPreviousRun (optional)
    Boolean Indicates whether to exclude matches from previous runs. If true, you can avoid alert duplication by excluding documents that have already been detected by the previous rule run. This option is not available when a grouping field is specified.
    +
    groupBy (optional)
    +
    searchConfiguration (optional)
    +
    searchType
    String The type of query, in this case a text-based query that uses KQL or Lucene.
    +
    Enum:
    +
    searchSource
    +
    size
    Integer The number of documents to pass to the configured actions when the threshold condition is met.
    +
    termField (optional)
    String This property is required when groupBy is top. The name of the field that is used for grouping the aggregation.
    +
    termSize (optional)
    Integer This property is required when groupBy is top. It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields.
    +
    threshold
    array[Integer] The threshold value that is used with the thresholdComparator. If the thresholdComparator is between or notBetween, you must specify the boundary values.
    +
    thresholdComparator
    +
    timeField (optional)
    String The field that is used to calculate the time window.
    +
    timeWindowSize
    Integer The size of the time window (in timeWindowUnit units), which determines how far back to search for documents. Generally it should be a value higher than the rule check interval to avoid gaps in detection.
    +
    timeWindowUnit
    +
    +
    +
    +

    params_es_query_rule_oneOf_1 - Up

    +
    The parameters for an Elasticsearch query rule that uses Elasticsearch Query DSL to define the query.
    +
    +
    aggField (optional)
    String The name of the numeric field that is used in the aggregation. This property is required when aggType is avg, max, min or sum.
    +
    aggType (optional)
    +
    esQuery
    String The query definition, which uses Elasticsearch Query DSL.
    +
    excludeHitsFromPreviousRun (optional)
    Boolean Indicates whether to exclude matches from previous runs. If true, you can avoid alert duplication by excluding documents that have already been detected by the previous rule run. This option is not available when a grouping field is specified.
    +
    groupBy (optional)
    +
    index
    oneOf The indices to query.
    +
    searchType (optional)
    String The type of query, in this case a query that uses Elasticsearch Query DSL.
    +
    Enum:
    +
    esQuery
    +
    size (optional)
    Integer The number of documents to pass to the configured actions when the threshold condition is met.
    +
    termField (optional)
    String This property is required when groupBy is top. The name of the field that is used for grouping the aggregation.
    +
    termSize (optional)
    Integer This property is required when groupBy is top. It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields.
    +
    threshold
    array[Integer] The threshold value that is used with the thresholdComparator. If the thresholdComparator is between or notBetween, you must specify the boundary values.
    +
    thresholdComparator
    +
    timeField
    String The field that is used to calculate the time window.
    +
    timeWindowSize
    Integer The size of the time window (in timeWindowUnit units), which determines how far back to search for documents. Generally it should be a value higher than the rule check interval to avoid gaps in detection.
    +
    timeWindowUnit
    +
    +
    +
    +

    params_es_query_rule_oneOf_searchConfiguration - Up

    +
    The query definition, which uses KQL or Lucene to fetch the documents from Elasticsearch.
    +
    +
    filter (optional)
    +
    index (optional)
    oneOf The indices to query.
    +
    query (optional)
    +
    +
    +
    +

    params_es_query_rule_oneOf_searchConfiguration_query - Up

    +
    +
    +
    language (optional)
    +
    query (optional)
    +
    +

    params_index_threshold_rule - Up

    The parameters for an index threshold rule.
    aggField (optional)
    String The name of the numeric field that is used in the aggregation. This property is required when aggType is avg, max, min or sum.
    -
    aggType (optional)
    String The type of aggregation to perform.
    -
    Enum:
    -
    avg
    count
    max
    min
    sum
    +
    aggType (optional)
    filterKuery (optional)
    String A KQL expression thats limits the scope of alerts.
    -
    groupBy (optional)
    String Indicates whether the aggregation is applied over all documents (all) or split into groups (top) using a grouping field (termField). If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to termSize number of groups) are checked.
    -
    Enum:
    -
    all
    top
    +
    groupBy (optional)
    index
    array[String] The indices to query.
    termField (optional)
    String This property is required when groupBy is top. The name of the field that is used for grouping the aggregation.
    termSize (optional)
    Integer This property is required when groupBy is top. It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields.
    threshold
    array[Integer] The threshold value that is used with the thresholdComparator. If the thresholdComparator is between or notBetween, you must specify the boundary values.
    -
    thresholdComparator
    String The comparison function for the threshold. For example, "is above", "is above or equals", "is below", "is below or equals", and "is between".
    -
    Enum:
    -
    >
    >=
    <
    <=
    between
    notBetween
    +
    thresholdComparator
    timeField
    String The field that is used to calculate the time window.
    timeWindowSize
    Integer The size of the time window (in timeWindowUnit units), which determines how far back to search for documents. Generally it should be a value higher than the rule check interval to avoid gaps in detection.
    -
    timeWindowUnit
    String The type of units for the time window: seconds, minutes, hours, or days.
    -
    Enum:
    -
    s
    m
    h
    d
    +
    timeWindowUnit
    @@ -4714,6 +4813,18 @@ Any modifications made to this file will be overwritten.
    interval (optional)
    String
    +
    +

    thresholdcomparator - Up

    +
    The comparison function for the threshold. For example, "is above", "is above or equals", "is below", "is below or equals", "is between", and "is not between".
    +
    +
    +
    +
    +

    timewindowunit - Up

    +
    The type of units for the time window: seconds, minutes, hours, or days.
    +
    +
    +

    update_rule_request - Update rule request Up

    The update rule API request body varies depending on the type of rule and actions.
    diff --git a/x-pack/plugins/alerting/docs/openapi/bundled.json b/x-pack/plugins/alerting/docs/openapi/bundled.json index 1ccd3badae642..104b044875c64 100644 --- a/x-pack/plugins/alerting/docs/openapi/bundled.json +++ b/x-pack/plugins/alerting/docs/openapi/bundled.json @@ -49,8 +49,11 @@ "$ref": "#/components/schemas/create_rule_request" }, "examples": { - "createRuleRequest": { - "$ref": "#/components/examples/create_rule_request" + "createEsQueryRuleRequest": { + "$ref": "#/components/examples/create_es_query_rule_request" + }, + "createIndexThresholdRuleRequest": { + "$ref": "#/components/examples/create_index_threshold_rule_request" } } } @@ -65,8 +68,11 @@ "$ref": "#/components/schemas/rule_response_properties" }, "examples": { - "createRuleResponse": { - "$ref": "#/components/examples/create_rule_response" + "createEsQueryRuleResponse": { + "$ref": "#/components/examples/create_es_query_rule_response" + }, + "createIndexThresholdRuleResponse": { + "$ref": "#/components/examples/create_index_threshold_rule_response" } } } @@ -246,8 +252,11 @@ "$ref": "#/components/schemas/create_rule_request" }, "examples": { - "createRuleIdRequest": { - "$ref": "#/components/examples/create_rule_request" + "createEsQueryRuleIdRequest": { + "$ref": "#/components/examples/create_es_query_rule_request" + }, + "createIndexThreholdRuleIdRequest": { + "$ref": "#/components/examples/create_index_threshold_rule_request" } } } @@ -262,8 +271,11 @@ "$ref": "#/components/schemas/rule_response_properties" }, "examples": { - "createRuleIdResponse": { - "$ref": "#/components/examples/create_rule_response" + "createEsQueryRuleIdResponse": { + "$ref": "#/components/examples/create_es_query_rule_response" + }, + "createIndexThresholdRuleIdResponse": { + "$ref": "#/components/examples/create_index_threshold_rule_response" } } } @@ -2530,6 +2542,60 @@ } }, "schemas": { + "filter": { + "type": "object", + "description": "A filter written in Elasticsearch Query Domain Specific Language (DSL) as defined in the `kbn-es-query` package.", + "properties": { + "meta": { + "type": "object", + "properties": { + "alias": { + "type": "string", + "nullable": true + }, + "controlledBy": { + "type": "string" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "string" + }, + "group": { + "type": "string" + }, + "index": { + "type": "string" + }, + "isMultiIndex": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "object" + }, + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "query": { + "type": "object" + }, + "$state": { + "type": "object" + } + } + }, "notify_when": { "type": "string", "description": "Indicates how often alerts generate actions. Valid values include: `onActionGroupChange`: Actions run when the alert status changes; `onActiveAlert`: Actions run when the alert becomes active and at each check interval while the rule conditions are met; `onThrottleInterval`: Actions run when the alert becomes active and at the interval specified in the throttle property while the rule conditions are met. NOTE: You cannot specify `notify_when` at both the rule and action level. The recommended method is to set it for each action. If you set it at the rule level then update the rule in Kibana, it is automatically changed to use action-specific values.\n", @@ -2574,58 +2640,7 @@ "filters": { "type": "array", "items": { - "type": "object", - "description": "A filter written in Elasticsearch Query Domain Specific Language (DSL) as defined in the `kbn-es-query` package.", - "properties": { - "meta": { - "type": "object", - "properties": { - "alias": { - "type": "string", - "nullable": true - }, - "controlledBy": { - "type": "string" - }, - "disabled": { - "type": "boolean" - }, - "field": { - "type": "string" - }, - "group": { - "type": "string" - }, - "index": { - "type": "string" - }, - "isMultiIndex": { - "type": "boolean" - }, - "key": { - "type": "string" - }, - "negate": { - "type": "boolean" - }, - "params": { - "type": "object" - }, - "type": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "query": { - "type": "object" - }, - "$state": { - "type": "object" - } - } + "$ref": "#/components/schemas/filter" } } } @@ -3278,6 +3293,264 @@ } } }, + "aggfield": { + "description": "The name of the numeric field that is used in the aggregation. This property is required when `aggType` is `avg`, `max`, `min` or `sum`.\n", + "type": "string" + }, + "aggtype": { + "description": "The type of aggregation to perform.", + "type": "string", + "enum": [ + "avg", + "count", + "max", + "min", + "sum" + ], + "default": "count" + }, + "excludehitsfrompreviousrun": { + "description": "Indicates whether to exclude matches from previous runs. If `true`, you can avoid alert duplication by excluding documents that have already been detected by the previous rule run. This option is not available when a grouping field is specified.\n", + "type": "boolean" + }, + "groupby": { + "description": "Indicates whether the aggregation is applied over all documents (`all`) or split into groups (`top`) using a grouping field (`termField`). If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to `termSize` number of groups) are checked.\n", + "type": "string", + "enum": [ + "all", + "top" + ], + "default": "all" + }, + "termfield": { + "description": "This property is required when `groupBy` is `top`. The name of the field that is used for grouping the aggregation.\n", + "type": "string" + }, + "termsize": { + "description": "This property is required when `groupBy` is `top`. It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields.\n", + "type": "integer" + }, + "threshold": { + "description": "The threshold value that is used with the `thresholdComparator`. If the `thresholdComparator` is `between` or `notBetween`, you must specify the boundary values.\n", + "type": "array", + "items": { + "type": "integer", + "example": 4000 + } + }, + "thresholdcomparator": { + "description": "The comparison function for the threshold. For example, \"is above\", \"is above or equals\", \"is below\", \"is below or equals\", \"is between\", and \"is not between\".", + "type": "string", + "enum": [ + ">", + ">=", + "<", + "<=", + "between", + "notBetween" + ], + "example": ">" + }, + "timefield": { + "description": "The field that is used to calculate the time window.", + "type": "string" + }, + "timewindowsize": { + "description": "The size of the time window (in `timeWindowUnit` units), which determines how far back to search for documents. Generally it should be a value higher than the rule check interval to avoid gaps in detection.\n", + "type": "integer", + "example": 5 + }, + "timewindowunit": { + "description": "The type of units for the time window: seconds, minutes, hours, or days.\n", + "type": "string", + "enum": [ + "s", + "m", + "h", + "d" + ], + "example": "m" + }, + "params_es_query_rule": { + "oneOf": [ + { + "type": "object", + "description": "The parameters for an Elasticsearch query rule that uses KQL or Lucene to define the query.", + "required": [ + "searchType", + "size", + "threshold", + "thresholdComparator", + "timeWindowSize", + "timeWindowUnit" + ], + "properties": { + "aggField": { + "$ref": "#/components/schemas/aggfield" + }, + "aggType": { + "$ref": "#/components/schemas/aggtype" + }, + "excludeHitsFromPreviousRun": { + "$ref": "#/components/schemas/excludehitsfrompreviousrun" + }, + "groupBy": { + "$ref": "#/components/schemas/groupby" + }, + "searchConfiguration": { + "description": "The query definition, which uses KQL or Lucene to fetch the documents from Elasticsearch.", + "type": "object", + "properties": { + "filter": { + "type": "array", + "items": { + "$ref": "#/components/schemas/filter" + } + }, + "index": { + "description": "The indices to query.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "query": { + "type": "object", + "properties": { + "language": { + "type": "string", + "example": "kuery" + }, + "query": { + "type": "string" + } + } + } + } + }, + "searchType": { + "description": "The type of query, in this case a text-based query that uses KQL or Lucene.", + "type": "string", + "enum": [ + "searchSource" + ], + "example": "searchSource" + }, + "size": { + "description": "The number of documents to pass to the configured actions when the threshold condition is met.\n", + "type": "integer" + }, + "termField": { + "$ref": "#/components/schemas/termfield" + }, + "termSize": { + "$ref": "#/components/schemas/termsize" + }, + "threshold": { + "$ref": "#/components/schemas/threshold" + }, + "thresholdComparator": { + "$ref": "#/components/schemas/thresholdcomparator" + }, + "timeField": { + "$ref": "#/components/schemas/timefield" + }, + "timeWindowSize": { + "$ref": "#/components/schemas/timewindowsize" + }, + "timeWindowUnit": { + "$ref": "#/components/schemas/timewindowunit" + } + } + }, + { + "type": "object", + "description": "The parameters for an Elasticsearch query rule that uses Elasticsearch Query DSL to define the query.", + "required": [ + "esQuery", + "index", + "threshold", + "thresholdComparator", + "timeField", + "timeWindowSize", + "timeWindowUnit" + ], + "properties": { + "aggField": { + "$ref": "#/components/schemas/aggfield" + }, + "aggType": { + "$ref": "#/components/schemas/aggtype" + }, + "esQuery": { + "description": "The query definition, which uses Elasticsearch Query DSL.", + "type": "string" + }, + "excludeHitsFromPreviousRun": { + "$ref": "#/components/schemas/excludehitsfrompreviousrun" + }, + "groupBy": { + "$ref": "#/components/schemas/groupby" + }, + "index": { + "description": "The indices to query.", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "searchType": { + "description": "The type of query, in this case a query that uses Elasticsearch Query DSL.", + "type": "string", + "enum": [ + "esQuery" + ], + "default": "esQuery", + "example": "esQuery" + }, + "size": { + "description": "The number of documents to pass to the configured actions when the threshold condition is met.\n", + "type": "integer" + }, + "termField": { + "$ref": "#/components/schemas/termfield" + }, + "termSize": { + "$ref": "#/components/schemas/termsize" + }, + "threshold": { + "$ref": "#/components/schemas/threshold" + }, + "thresholdComparator": { + "$ref": "#/components/schemas/thresholdcomparator" + }, + "timeField": { + "$ref": "#/components/schemas/timefield" + }, + "timeWindowSize": { + "$ref": "#/components/schemas/timewindowsize" + }, + "timeWindowUnit": { + "$ref": "#/components/schemas/timewindowunit" + } + } + } + ] + }, "create_es_query_rule_request": { "title": "Create Elasticsearch query rule request", "description": "A rule that runs a user-configured query, compares the number of matches to a configured threshold, and schedules actions to run when the threshold condition is met. \n", @@ -3306,9 +3579,7 @@ "$ref": "#/components/schemas/notify_when" }, "params": { - "type": "object", - "description": "The parameters for an Elasticsearch query rule.", - "additionalProperties": true + "$ref": "#/components/schemas/params_es_query_rule" }, "rule_type_id": { "type": "string", @@ -3391,33 +3662,17 @@ ], "properties": { "aggField": { - "description": "The name of the numeric field that is used in the aggregation. This property is required when `aggType` is `avg`, `max`, `min` or `sum`.\n", - "type": "string" + "$ref": "#/components/schemas/aggfield" }, "aggType": { - "description": "The type of aggregation to perform.", - "type": "string", - "enum": [ - "avg", - "count", - "max", - "min", - "sum" - ], - "default": "count" + "$ref": "#/components/schemas/aggtype" }, "filterKuery": { "description": "A KQL expression thats limits the scope of alerts.", "type": "string" }, "groupBy": { - "description": "Indicates whether the aggregation is applied over all documents (`all`) or split into groups (`top`) using a grouping field (`termField`). If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to `termSize` number of groups) are checked.\n", - "type": "string", - "enum": [ - "all", - "top" - ], - "default": "all" + "$ref": "#/components/schemas/groupby" }, "index": { "description": "The indices to query.", @@ -3427,53 +3682,25 @@ } }, "termField": { - "description": "This property is required when `groupBy` is `top`. The name of the field that is used for grouping the aggregation.\n", - "type": "string" + "$ref": "#/components/schemas/termfield" }, "termSize": { - "description": "This property is required when `groupBy` is `top`. It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields.\n", - "type": "integer" + "$ref": "#/components/schemas/termsize" }, "threshold": { - "description": "The threshold value that is used with the `thresholdComparator`. If the `thresholdComparator` is `between` or `notBetween`, you must specify the boundary values.\n", - "type": "array", - "items": { - "type": "integer" - }, - "example": 4000 + "$ref": "#/components/schemas/threshold" }, "thresholdComparator": { - "description": "The comparison function for the threshold. For example, \"is above\", \"is above or equals\", \"is below\", \"is below or equals\", and \"is between\".", - "type": "string", - "enum": [ - ">", - ">=", - "<", - "<=", - "between", - "notBetween" - ], - "example": ">" + "$ref": "#/components/schemas/thresholdcomparator" }, "timeField": { - "description": "The field that is used to calculate the time window.", - "type": "string" + "$ref": "#/components/schemas/timefield" }, "timeWindowSize": { - "description": "The size of the time window (in `timeWindowUnit` units), which determines how far back to search for documents. Generally it should be a value higher than the rule check interval to avoid gaps in detection.\n", - "type": "integer", - "example": 5 + "$ref": "#/components/schemas/timewindowsize" }, "timeWindowUnit": { - "description": "The type of units for the time window: seconds, minutes, hours, or days.\n", - "type": "string", - "enum": [ - "s", - "m", - "h", - "d" - ], - "example": "m" + "$ref": "#/components/schemas/timewindowunit" } } }, @@ -6481,7 +6708,38 @@ } }, "examples": { - "create_rule_request": { + "create_es_query_rule_request": { + "summary": "Create an Elasticsearch query rule.", + "value": { + "consumer": "alerts", + "name": "my Elasticsearch query rule", + "params": { + "aggType": "count", + "excludeHitsFromPreviousRun": true, + "groupBy": "all", + "searchConfiguration": { + "query": { + "query": "\"\"geo.src : \"US\" \"\"", + "language": "kuery" + }, + "index": "90943e30-9a47-11e8-b64d-95841ca0b247" + }, + "searchType": "searchSource", + "size": 100, + "threshold": [ + 1000 + ], + "thresholdComparator": ">", + "timeWindowSize": 5, + "timeWindowUnit": "m" + }, + "rule_type_id": ".es-query", + "schedule": { + "interval": "1m" + } + } + }, + "create_index_threshold_rule_request": { "summary": "Create an index threshold rule.", "value": { "actions": [ @@ -6526,7 +6784,59 @@ ] } }, - "create_rule_response": { + "create_es_query_rule_response": { + "summary": "The create rule API returns a JSON object that contains details about the rule.", + "value": { + "id": "7bd506d0-2284-11ee-8fad-6101956ced88", + "enabled": true, + "name": "my Elasticsearch query rule\"", + "tags": [], + "rule_type_id": ".es-query", + "consumer": "alerts", + "schedule": { + "interval": "1m" + }, + "actions": [], + "params": { + "searchConfiguration": { + "query": { + "query": "\"\"geo.src : \"US\" \"\"", + "language": "kuery" + }, + "index": "90943e30-9a47-11e8-b64d-95841ca0b247" + }, + "searchType": "searchSource", + "timeWindowSize": 5, + "timeWindowUnit": "m", + "threshold": [ + 1000 + ], + "thresholdComparator": ">", + "size": 100, + "aggType": "count", + "groupBy": "all", + "excludeHitsFromPreviousRun": true + }, + "created_by": "elastic", + "updated_by": "elastic", + "created_at": "2023-07-14T20:24:50.729Z", + "updated_at": "2023-07-14T20:24:50.729Z", + "api_key_owner": "elastic", + "api_key_created_by_user": false, + "throttle": null, + "notify_when": null, + "mute_all": false, + "muted_alert_ids": [], + "scheduled_task_id": "7bd506d0-2284-11ee-8fad-6101956ced88", + "execution_status": { + "status": "pending", + "last_execution_date": "2023-07-14T20:24:50.729Z" + }, + "revision": 0, + "running": false + } + }, + "create_index_threshold_rule_response": { "summary": "The create rule API returns a JSON object that contains details about the rule.", "value": { "actions": [ diff --git a/x-pack/plugins/alerting/docs/openapi/bundled.yaml b/x-pack/plugins/alerting/docs/openapi/bundled.yaml index d97845f0a1d2f..d0f2cccf0a9cc 100644 --- a/x-pack/plugins/alerting/docs/openapi/bundled.yaml +++ b/x-pack/plugins/alerting/docs/openapi/bundled.yaml @@ -33,8 +33,10 @@ paths: schema: $ref: '#/components/schemas/create_rule_request' examples: - createRuleRequest: - $ref: '#/components/examples/create_rule_request' + createEsQueryRuleRequest: + $ref: '#/components/examples/create_es_query_rule_request' + createIndexThresholdRuleRequest: + $ref: '#/components/examples/create_index_threshold_rule_request' responses: '200': description: Indicates a successful call. @@ -43,8 +45,10 @@ paths: schema: $ref: '#/components/schemas/rule_response_properties' examples: - createRuleResponse: - $ref: '#/components/examples/create_rule_response' + createEsQueryRuleResponse: + $ref: '#/components/examples/create_es_query_rule_response' + createIndexThresholdRuleResponse: + $ref: '#/components/examples/create_index_threshold_rule_response' '401': description: Authorization information is missing or invalid. content: @@ -149,8 +153,10 @@ paths: schema: $ref: '#/components/schemas/create_rule_request' examples: - createRuleIdRequest: - $ref: '#/components/examples/create_rule_request' + createEsQueryRuleIdRequest: + $ref: '#/components/examples/create_es_query_rule_request' + createIndexThreholdRuleIdRequest: + $ref: '#/components/examples/create_index_threshold_rule_request' responses: '200': description: Indicates a successful call. @@ -159,8 +165,10 @@ paths: schema: $ref: '#/components/schemas/rule_response_properties' examples: - createRuleIdResponse: - $ref: '#/components/examples/create_rule_response' + createEsQueryRuleIdResponse: + $ref: '#/components/examples/create_es_query_rule_response' + createIndexThresholdRuleIdResponse: + $ref: '#/components/examples/create_index_threshold_rule_response' '401': description: Authorization information is missing or invalid. content: @@ -1593,6 +1601,42 @@ components: type: string example: ac4e6b90-6be7-11eb-ba0d-9b1c1f912d74 schemas: + filter: + type: object + description: A filter written in Elasticsearch Query Domain Specific Language (DSL) as defined in the `kbn-es-query` package. + properties: + meta: + type: object + properties: + alias: + type: string + nullable: true + controlledBy: + type: string + disabled: + type: boolean + field: + type: string + group: + type: string + index: + type: string + isMultiIndex: + type: boolean + key: + type: string + negate: + type: boolean + params: + type: object + type: + type: string + value: + type: string + query: + type: object + $state: + type: object notify_when: type: string description: | @@ -1635,41 +1679,7 @@ components: filters: type: array items: - type: object - description: A filter written in Elasticsearch Query Domain Specific Language (DSL) as defined in the `kbn-es-query` package. - properties: - meta: - type: object - properties: - alias: - type: string - nullable: true - controlledBy: - type: string - disabled: - type: boolean - field: - type: string - group: - type: string - index: - type: string - isMultiIndex: - type: boolean - key: - type: string - negate: - type: boolean - params: - type: object - type: - type: string - value: - type: string - query: - type: object - $state: - type: object + $ref: '#/components/schemas/filter' timeframe: type: object description: Defines a period that limits whether the action runs. @@ -2150,6 +2160,197 @@ components: $ref: '#/components/schemas/tags' throttle: $ref: '#/components/schemas/throttle' + aggfield: + description: | + The name of the numeric field that is used in the aggregation. This property is required when `aggType` is `avg`, `max`, `min` or `sum`. + type: string + aggtype: + description: The type of aggregation to perform. + type: string + enum: + - avg + - count + - max + - min + - sum + default: count + excludehitsfrompreviousrun: + description: | + Indicates whether to exclude matches from previous runs. If `true`, you can avoid alert duplication by excluding documents that have already been detected by the previous rule run. This option is not available when a grouping field is specified. + type: boolean + groupby: + description: | + Indicates whether the aggregation is applied over all documents (`all`) or split into groups (`top`) using a grouping field (`termField`). If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to `termSize` number of groups) are checked. + type: string + enum: + - all + - top + default: all + termfield: + description: | + This property is required when `groupBy` is `top`. The name of the field that is used for grouping the aggregation. + type: string + termsize: + description: | + This property is required when `groupBy` is `top`. It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields. + type: integer + threshold: + description: | + The threshold value that is used with the `thresholdComparator`. If the `thresholdComparator` is `between` or `notBetween`, you must specify the boundary values. + type: array + items: + type: integer + example: 4000 + thresholdcomparator: + description: The comparison function for the threshold. For example, "is above", "is above or equals", "is below", "is below or equals", "is between", and "is not between". + type: string + enum: + - '>' + - '>=' + - < + - <= + - between + - notBetween + example: '>' + timefield: + description: The field that is used to calculate the time window. + type: string + timewindowsize: + description: | + The size of the time window (in `timeWindowUnit` units), which determines how far back to search for documents. Generally it should be a value higher than the rule check interval to avoid gaps in detection. + type: integer + example: 5 + timewindowunit: + description: | + The type of units for the time window: seconds, minutes, hours, or days. + type: string + enum: + - s + - m + - h + - d + example: m + params_es_query_rule: + oneOf: + - type: object + description: The parameters for an Elasticsearch query rule that uses KQL or Lucene to define the query. + required: + - searchType + - size + - threshold + - thresholdComparator + - timeWindowSize + - timeWindowUnit + properties: + aggField: + $ref: '#/components/schemas/aggfield' + aggType: + $ref: '#/components/schemas/aggtype' + excludeHitsFromPreviousRun: + $ref: '#/components/schemas/excludehitsfrompreviousrun' + groupBy: + $ref: '#/components/schemas/groupby' + searchConfiguration: + description: The query definition, which uses KQL or Lucene to fetch the documents from Elasticsearch. + type: object + properties: + filter: + type: array + items: + $ref: '#/components/schemas/filter' + index: + description: The indices to query. + oneOf: + - type: string + - type: array + items: + type: string + query: + type: object + properties: + language: + type: string + example: kuery + query: + type: string + searchType: + description: The type of query, in this case a text-based query that uses KQL or Lucene. + type: string + enum: + - searchSource + example: searchSource + size: + description: | + The number of documents to pass to the configured actions when the threshold condition is met. + type: integer + termField: + $ref: '#/components/schemas/termfield' + termSize: + $ref: '#/components/schemas/termsize' + threshold: + $ref: '#/components/schemas/threshold' + thresholdComparator: + $ref: '#/components/schemas/thresholdcomparator' + timeField: + $ref: '#/components/schemas/timefield' + timeWindowSize: + $ref: '#/components/schemas/timewindowsize' + timeWindowUnit: + $ref: '#/components/schemas/timewindowunit' + - type: object + description: The parameters for an Elasticsearch query rule that uses Elasticsearch Query DSL to define the query. + required: + - esQuery + - index + - threshold + - thresholdComparator + - timeField + - timeWindowSize + - timeWindowUnit + properties: + aggField: + $ref: '#/components/schemas/aggfield' + aggType: + $ref: '#/components/schemas/aggtype' + esQuery: + description: The query definition, which uses Elasticsearch Query DSL. + type: string + excludeHitsFromPreviousRun: + $ref: '#/components/schemas/excludehitsfrompreviousrun' + groupBy: + $ref: '#/components/schemas/groupby' + index: + description: The indices to query. + oneOf: + - type: array + items: + type: string + - type: string + searchType: + description: The type of query, in this case a query that uses Elasticsearch Query DSL. + type: string + enum: + - esQuery + default: esQuery + example: esQuery + size: + description: | + The number of documents to pass to the configured actions when the threshold condition is met. + type: integer + termField: + $ref: '#/components/schemas/termfield' + termSize: + $ref: '#/components/schemas/termsize' + threshold: + $ref: '#/components/schemas/threshold' + thresholdComparator: + $ref: '#/components/schemas/thresholdcomparator' + timeField: + $ref: '#/components/schemas/timefield' + timeWindowSize: + $ref: '#/components/schemas/timewindowsize' + timeWindowUnit: + $ref: '#/components/schemas/timewindowunit' create_es_query_rule_request: title: Create Elasticsearch query rule request description: | @@ -2173,9 +2374,7 @@ components: notify_when: $ref: '#/components/schemas/notify_when' params: - type: object - description: The parameters for an Elasticsearch query rule. - additionalProperties: true + $ref: '#/components/schemas/params_es_query_rule' rule_type_id: type: string description: The ID of the rule type that you want to call when the rule is scheduled to run. @@ -2236,79 +2435,33 @@ components: - timeWindowUnit properties: aggField: - description: | - The name of the numeric field that is used in the aggregation. This property is required when `aggType` is `avg`, `max`, `min` or `sum`. - type: string + $ref: '#/components/schemas/aggfield' aggType: - description: The type of aggregation to perform. - type: string - enum: - - avg - - count - - max - - min - - sum - default: count + $ref: '#/components/schemas/aggtype' filterKuery: description: A KQL expression thats limits the scope of alerts. type: string groupBy: - description: | - Indicates whether the aggregation is applied over all documents (`all`) or split into groups (`top`) using a grouping field (`termField`). If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to `termSize` number of groups) are checked. - type: string - enum: - - all - - top - default: all + $ref: '#/components/schemas/groupby' index: description: The indices to query. type: array items: type: string termField: - description: | - This property is required when `groupBy` is `top`. The name of the field that is used for grouping the aggregation. - type: string + $ref: '#/components/schemas/termfield' termSize: - description: | - This property is required when `groupBy` is `top`. It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields. - type: integer + $ref: '#/components/schemas/termsize' threshold: - description: | - The threshold value that is used with the `thresholdComparator`. If the `thresholdComparator` is `between` or `notBetween`, you must specify the boundary values. - type: array - items: - type: integer - example: 4000 + $ref: '#/components/schemas/threshold' thresholdComparator: - description: The comparison function for the threshold. For example, "is above", "is above or equals", "is below", "is below or equals", and "is between". - type: string - enum: - - '>' - - '>=' - - < - - <= - - between - - notBetween - example: '>' + $ref: '#/components/schemas/thresholdcomparator' timeField: - description: The field that is used to calculate the time window. - type: string + $ref: '#/components/schemas/timefield' timeWindowSize: - description: | - The size of the time window (in `timeWindowUnit` units), which determines how far back to search for documents. Generally it should be a value higher than the rule check interval to avoid gaps in detection. - type: integer - example: 5 + $ref: '#/components/schemas/timewindowsize' timeWindowUnit: - description: | - The type of units for the time window: seconds, minutes, hours, or days. - type: string - enum: - - s - - m - - h - - d - example: m + $ref: '#/components/schemas/timewindowunit' create_index_threshold_rule_request: title: Create index threshold rule request description: A rule that runs an Elasticsearch query, aggregates field values from documents, compares them to threshold values, and schedules actions to run when the thresholds are met. @@ -4431,7 +4584,31 @@ components: nullable: true example: elastic examples: - create_rule_request: + create_es_query_rule_request: + summary: Create an Elasticsearch query rule. + value: + consumer: alerts + name: my Elasticsearch query rule + params: + aggType: count + excludeHitsFromPreviousRun: true + groupBy: all + searchConfiguration: + query: + query: '""geo.src : "US" ""' + language: kuery + index: 90943e30-9a47-11e8-b64d-95841ca0b247 + searchType: searchSource + size: 100 + threshold: + - 1000 + thresholdComparator: '>' + timeWindowSize: 5 + timeWindowUnit: m + rule_type_id: .es-query + schedule: + interval: 1m + create_index_threshold_rule_request: summary: Create an index threshold rule. value: actions: @@ -4469,7 +4646,51 @@ components: interval: 1m tags: - cpu - create_rule_response: + create_es_query_rule_response: + summary: The create rule API returns a JSON object that contains details about the rule. + value: + id: 7bd506d0-2284-11ee-8fad-6101956ced88 + enabled: true + name: my Elasticsearch query rule" + tags: [] + rule_type_id: .es-query + consumer: alerts + schedule: + interval: 1m + actions: [] + params: + searchConfiguration: + query: + query: '""geo.src : "US" ""' + language: kuery + index: 90943e30-9a47-11e8-b64d-95841ca0b247 + searchType: searchSource + timeWindowSize: 5 + timeWindowUnit: m + threshold: + - 1000 + thresholdComparator: '>' + size: 100 + aggType: count + groupBy: all + excludeHitsFromPreviousRun: true + created_by: elastic + updated_by: elastic + created_at: '2023-07-14T20:24:50.729Z' + updated_at: '2023-07-14T20:24:50.729Z' + api_key_owner: elastic + api_key_created_by_user: false + throttle: null + notify_when: null + mute_all: false + muted_alert_ids: [] + scheduled_task_id: 7bd506d0-2284-11ee-8fad-6101956ced88 + execution_status: + status: pending + last_execution_date: '2023-07-14T20:24:50.729Z' + revision: 0 + running: false + create_index_threshold_rule_response: summary: The create rule API returns a JSON object that contains details about the rule. value: actions: diff --git a/x-pack/plugins/alerting/docs/openapi/components/examples/create_es_query_rule_request.yaml b/x-pack/plugins/alerting/docs/openapi/components/examples/create_es_query_rule_request.yaml new file mode 100644 index 0000000000000..b17f6626b34dc --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/examples/create_es_query_rule_request.yaml @@ -0,0 +1,23 @@ +summary: Create an Elasticsearch query rule. +value: + consumer: alerts + name: my Elasticsearch query rule + params: + aggType: count + excludeHitsFromPreviousRun: true + groupBy: all + searchConfiguration: + query: + query: '""geo.src : "US" ""' + language: kuery + index: 90943e30-9a47-11e8-b64d-95841ca0b247 + searchType: searchSource + size: 100 + threshold: + - 1000 + thresholdComparator: ">" + timeWindowSize: 5 + timeWindowUnit: m + rule_type_id: .es-query + schedule: + interval: 1m diff --git a/x-pack/plugins/alerting/docs/openapi/components/examples/create_es_query_rule_response.yaml b/x-pack/plugins/alerting/docs/openapi/components/examples/create_es_query_rule_response.yaml new file mode 100644 index 0000000000000..5f24e00421a6f --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/examples/create_es_query_rule_response.yaml @@ -0,0 +1,43 @@ +summary: The create rule API returns a JSON object that contains details about the rule. +value: + id: 7bd506d0-2284-11ee-8fad-6101956ced88 + enabled: true + name: my Elasticsearch query rule" + tags: [] + rule_type_id: .es-query + consumer: alerts + schedule: + interval: 1m + actions: [] + params: + searchConfiguration: + query: + query: '""geo.src : "US" ""' + language: kuery + index: 90943e30-9a47-11e8-b64d-95841ca0b247 + searchType: searchSource + timeWindowSize: 5 + timeWindowUnit: m + threshold: + - 1000 + thresholdComparator: ">" + size: 100 + aggType: count + groupBy: all + excludeHitsFromPreviousRun: true + created_by: elastic + updated_by: elastic + created_at: '2023-07-14T20:24:50.729Z' + updated_at: '2023-07-14T20:24:50.729Z' + api_key_owner: elastic + api_key_created_by_user: false + throttle: null + notify_when: null + mute_all: false + muted_alert_ids: [] + scheduled_task_id: 7bd506d0-2284-11ee-8fad-6101956ced88 + execution_status: + status: pending + last_execution_date: '2023-07-14T20:24:50.729Z' + revision: 0 + running: false \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/examples/create_rule_request.yaml b/x-pack/plugins/alerting/docs/openapi/components/examples/create_index_threshold_rule_request.yaml similarity index 100% rename from x-pack/plugins/alerting/docs/openapi/components/examples/create_rule_request.yaml rename to x-pack/plugins/alerting/docs/openapi/components/examples/create_index_threshold_rule_request.yaml diff --git a/x-pack/plugins/alerting/docs/openapi/components/examples/create_rule_response.yaml b/x-pack/plugins/alerting/docs/openapi/components/examples/create_index_threshold_rule_response.yaml similarity index 100% rename from x-pack/plugins/alerting/docs/openapi/components/examples/create_rule_response.yaml rename to x-pack/plugins/alerting/docs/openapi/components/examples/create_index_threshold_rule_response.yaml diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/actions.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/actions.yaml index 2410363de6d01..e2b959fb3cf53 100644 --- a/x-pack/plugins/alerting/docs/openapi/components/schemas/actions.yaml +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/actions.yaml @@ -25,41 +25,7 @@ items: filters: type: array items: - type: object - description: A filter written in Elasticsearch Query Domain Specific Language (DSL) as defined in the `kbn-es-query` package. - properties: - meta: - type: object - properties: - alias: - type: string - nullable: true - controlledBy: - type: string - disabled: - type: boolean - field: - type: string - group: - type: string - index: - type: string - isMultiIndex: - type: boolean - key: - type: string - negate: - type: boolean - params: - type: object - type: - type: string - value: - type: string - query: - type: object - $state: - type: object + $ref: 'filter.yaml' timeframe: type: object description: Defines a period that limits whether the action runs. diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/aggfield.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/aggfield.yaml new file mode 100644 index 0000000000000..fcc0b9c95edd6 --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/aggfield.yaml @@ -0,0 +1,5 @@ +description: > + The name of the numeric field that is used in the aggregation. + This property is required when `aggType` is `avg`, `max`, `min` or `sum`. +type: string + \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/aggtype.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/aggtype.yaml new file mode 100644 index 0000000000000..f64209bc6eaba --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/aggtype.yaml @@ -0,0 +1,10 @@ +description: The type of aggregation to perform. +type: string +enum: + - avg + - count + - max + - min + - sum +default: count + \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/create_es_query_rule_request.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/create_es_query_rule_request.yaml index 1c42087c59895..d8ca95e1e482d 100644 --- a/x-pack/plugins/alerting/docs/openapi/components/schemas/create_es_query_rule_request.yaml +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/create_es_query_rule_request.yaml @@ -20,10 +20,7 @@ properties: notify_when: $ref: 'notify_when.yaml' params: - type: object - description: The parameters for an Elasticsearch query rule. - # TO-DO: Add the parameter details for this rule. - additionalProperties: true + $ref: 'params_es_query_rule.yaml' rule_type_id: type: string description: The ID of the rule type that you want to call when the rule is scheduled to run. diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/excludehitsfrompreviousrun.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/excludehitsfrompreviousrun.yaml new file mode 100644 index 0000000000000..d6f8880219bf9 --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/excludehitsfrompreviousrun.yaml @@ -0,0 +1,5 @@ +description: > + Indicates whether to exclude matches from previous runs. + If `true`, you can avoid alert duplication by excluding documents that have already been detected by the previous rule run. + This option is not available when a grouping field is specified. +type: boolean \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/filter.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/filter.yaml new file mode 100644 index 0000000000000..cb6a77c215682 --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/filter.yaml @@ -0,0 +1,36 @@ +type: object +description: A filter written in Elasticsearch Query Domain Specific Language (DSL) as defined in the `kbn-es-query` package. +properties: + meta: + type: object + properties: + alias: + type: string + nullable: true + controlledBy: + type: string + disabled: + type: boolean + field: + type: string + group: + type: string + index: + type: string + isMultiIndex: + type: boolean + key: + type: string + negate: + type: boolean + params: + type: object + type: + type: string + value: + type: string + query: + type: object + $state: + type: object + \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/groupby.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/groupby.yaml new file mode 100644 index 0000000000000..cc1a3540b9f52 --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/groupby.yaml @@ -0,0 +1,8 @@ +description: > + Indicates whether the aggregation is applied over all documents (`all`) or split into groups (`top`) using a grouping field (`termField`). + If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to `termSize` number of groups) are checked. +type: string +enum: + - all + - top +default: all \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/params_es_query_rule.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/params_es_query_rule.yaml new file mode 100644 index 0000000000000..c728bd961b06b --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/params_es_query_rule.yaml @@ -0,0 +1,120 @@ +oneOf: + - type: object + description: The parameters for an Elasticsearch query rule that uses KQL or Lucene to define the query. + required: + - searchType + - size + - threshold + - thresholdComparator + - timeWindowSize + - timeWindowUnit + properties: + aggField: + $ref: 'aggfield.yaml' + aggType: + $ref: 'aggtype.yaml' + excludeHitsFromPreviousRun: + $ref: 'excludehitsfrompreviousrun.yaml' + groupBy: + $ref: 'groupby.yaml' + searchConfiguration: + description: The query definition, which uses KQL or Lucene to fetch the documents from Elasticsearch. + type: object + properties: + filter: + type: array + items: + $ref: 'filter.yaml' + index: + description: The indices to query. + oneOf: + - type: string + - type: array + items: + type: string + query: + type: object + properties: + language: + type: string + example: kuery + query: + type: string + searchType: + description: The type of query, in this case a text-based query that uses KQL or Lucene. + type: string + enum: + - searchSource + example: searchSource + size: + description: > + The number of documents to pass to the configured actions when the threshold condition is met. + type: integer + termField: + $ref: 'termfield.yaml' + termSize: + $ref: 'termsize.yaml' + threshold: + $ref: 'threshold.yaml' + thresholdComparator: + $ref: 'thresholdcomparator.yaml' + timeField: + $ref: 'timefield.yaml' + timeWindowSize: + $ref: 'timewindowsize.yaml' + timeWindowUnit: + $ref: 'timewindowunit.yaml' + - type: object + description: The parameters for an Elasticsearch query rule that uses Elasticsearch Query DSL to define the query. + required: + - esQuery + - index + - threshold + - thresholdComparator + - timeField + - timeWindowSize + - timeWindowUnit + properties: + aggField: + $ref: 'aggfield.yaml' + aggType: + $ref: 'aggtype.yaml' + esQuery: + description: The query definition, which uses Elasticsearch Query DSL. + type: string + excludeHitsFromPreviousRun: + $ref: 'excludehitsfrompreviousrun.yaml' + groupBy: + $ref: 'groupby.yaml' + index: + description: The indices to query. + oneOf: + - type: array + items: + type: string + - type: string + searchType: + description: The type of query, in this case a query that uses Elasticsearch Query DSL. + type: string + enum: + - esQuery + default: esQuery + example: esQuery + size: + description: > + The number of documents to pass to the configured actions when the threshold condition is met. + type: integer + termField: + $ref: 'termfield.yaml' + termSize: + $ref: 'termsize.yaml' + threshold: + $ref: 'threshold.yaml' + thresholdComparator: + $ref: 'thresholdcomparator.yaml' + timeField: + $ref: 'timefield.yaml' + timeWindowSize: + $ref: 'timewindowsize.yaml' + timeWindowUnit: + $ref: 'timewindowunit.yaml' diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/params_index_threshold_rule.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/params_index_threshold_rule.yaml index 39988043be72a..c1755bf7d3310 100644 --- a/x-pack/plugins/alerting/docs/openapi/components/schemas/params_index_threshold_rule.yaml +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/params_index_threshold_rule.yaml @@ -9,82 +9,30 @@ required: - timeWindowUnit properties: aggField: - description: > - The name of the numeric field that is used in the aggregation. - This property is required when `aggType` is `avg`, `max`, `min` or `sum`. - type: string + $ref: 'aggfield.yaml' aggType: - description: The type of aggregation to perform. - type: string - enum: - - avg - - count - - max - - min - - sum - default: count + $ref: 'aggtype.yaml' filterKuery: description: A KQL expression thats limits the scope of alerts. type: string groupBy: - description: > - Indicates whether the aggregation is applied over all documents (`all`) or split into groups (`top`) using a grouping field (`termField`). - If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to `termSize` number of groups) are checked. - type: string - enum: - - all - - top - default: all + $ref: 'groupby.yaml' index: description: The indices to query. type: array items: type: string termField: - description: > - This property is required when `groupBy` is `top`. - The name of the field that is used for grouping the aggregation. - type: string + $ref: 'termfield.yaml' termSize: - description: > - This property is required when `groupBy` is `top`. - It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields. - type: integer + $ref: 'termsize.yaml' threshold: - description: > - The threshold value that is used with the `thresholdComparator`. - If the `thresholdComparator` is `between` or `notBetween`, you must specify the boundary values. - type: array - items: - type: integer - example: 4000 + $ref: 'threshold.yaml' thresholdComparator: - description: The comparison function for the threshold. For example, "is above", "is above or equals", "is below", "is below or equals", and "is between". - type: string - enum: - - ">" - - ">=" - - "<" - - "<=" - - between - - notBetween - example: ">" + $ref: 'thresholdcomparator.yaml' timeField: - description: The field that is used to calculate the time window. - type: string + $ref: 'timefield.yaml' timeWindowSize: - description: > - The size of the time window (in `timeWindowUnit` units), which determines how far back to search for documents. - Generally it should be a value higher than the rule check interval to avoid gaps in detection. - type: integer - example: 5 + $ref: 'timewindowsize.yaml' timeWindowUnit: - description: > - The type of units for the time window: seconds, minutes, hours, or days. - type: string - enum: - - s - - m - - h - - d - example: "m" \ No newline at end of file + $ref: 'timewindowunit.yaml' \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/termfield.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/termfield.yaml new file mode 100644 index 0000000000000..95fc7b6335612 --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/termfield.yaml @@ -0,0 +1,4 @@ +description: > + This property is required when `groupBy` is `top`. + The name of the field that is used for grouping the aggregation. +type: string diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/termsize.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/termsize.yaml new file mode 100644 index 0000000000000..6004d87d720f7 --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/termsize.yaml @@ -0,0 +1,4 @@ +description: > + This property is required when `groupBy` is `top`. + It specifies the number of groups to check against the threshold and therefore limits the number of alerts on high cardinality fields. +type: integer \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/threshold.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/threshold.yaml new file mode 100644 index 0000000000000..4d646f637522d --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/threshold.yaml @@ -0,0 +1,7 @@ +description: > + The threshold value that is used with the `thresholdComparator`. + If the `thresholdComparator` is `between` or `notBetween`, you must specify the boundary values. +type: array +items: + type: integer + example: 4000 \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/thresholdcomparator.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/thresholdcomparator.yaml new file mode 100644 index 0000000000000..3459365ee0a1d --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/thresholdcomparator.yaml @@ -0,0 +1,10 @@ +description: The comparison function for the threshold. For example, "is above", "is above or equals", "is below", "is below or equals", "is between", and "is not between". +type: string +enum: + - ">" + - ">=" + - "<" + - "<=" + - between + - notBetween +example: ">" \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/timefield.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/timefield.yaml new file mode 100644 index 0000000000000..d3cd5aa4d60ae --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/timefield.yaml @@ -0,0 +1,2 @@ +description: The field that is used to calculate the time window. +type: string \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/timewindowsize.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/timewindowsize.yaml new file mode 100644 index 0000000000000..7271f62f8dac2 --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/timewindowsize.yaml @@ -0,0 +1,5 @@ +description: > + The size of the time window (in `timeWindowUnit` units), which determines how far back to search for documents. + Generally it should be a value higher than the rule check interval to avoid gaps in detection. +type: integer +example: 5 \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/components/schemas/timewindowunit.yaml b/x-pack/plugins/alerting/docs/openapi/components/schemas/timewindowunit.yaml new file mode 100644 index 0000000000000..c0f2d458ae0e8 --- /dev/null +++ b/x-pack/plugins/alerting/docs/openapi/components/schemas/timewindowunit.yaml @@ -0,0 +1,9 @@ +description: > + The type of units for the time window: seconds, minutes, hours, or days. +type: string +enum: + - s + - m + - h + - d +example: "m" \ No newline at end of file diff --git a/x-pack/plugins/alerting/docs/openapi/paths/s@{spaceid}@api@alerting@rule.yaml b/x-pack/plugins/alerting/docs/openapi/paths/s@{spaceid}@api@alerting@rule.yaml index 1db9d39b096e6..5a7ebd986a234 100644 --- a/x-pack/plugins/alerting/docs/openapi/paths/s@{spaceid}@api@alerting@rule.yaml +++ b/x-pack/plugins/alerting/docs/openapi/paths/s@{spaceid}@api@alerting@rule.yaml @@ -21,8 +21,10 @@ post: schema: $ref: '../components/schemas/create_rule_request.yaml' examples: - createRuleRequest: - $ref: '../components/examples/create_rule_request.yaml' + createEsQueryRuleRequest: + $ref: '../components/examples/create_es_query_rule_request.yaml' + createIndexThresholdRuleRequest: + $ref: '../components/examples/create_index_threshold_rule_request.yaml' responses: '200': description: Indicates a successful call. @@ -31,8 +33,10 @@ post: schema: $ref: '../components/schemas/rule_response_properties.yaml' examples: - createRuleResponse: - $ref: '../components/examples/create_rule_response.yaml' + createEsQueryRuleResponse: + $ref: '../components/examples/create_es_query_rule_response.yaml' + createIndexThresholdRuleResponse: + $ref: '../components/examples/create_index_threshold_rule_response.yaml' '401': description: Authorization information is missing or invalid. content: diff --git a/x-pack/plugins/alerting/docs/openapi/paths/s@{spaceid}@api@alerting@rule@{ruleid}.yaml b/x-pack/plugins/alerting/docs/openapi/paths/s@{spaceid}@api@alerting@rule@{ruleid}.yaml index 66d05da081457..9bfd620d9bfd5 100644 --- a/x-pack/plugins/alerting/docs/openapi/paths/s@{spaceid}@api@alerting@rule@{ruleid}.yaml +++ b/x-pack/plugins/alerting/docs/openapi/paths/s@{spaceid}@api@alerting@rule@{ruleid}.yaml @@ -102,8 +102,10 @@ post: schema: $ref: '../components/schemas/create_rule_request.yaml' examples: - createRuleIdRequest: - $ref: '../components/examples/create_rule_request.yaml' + createEsQueryRuleIdRequest: + $ref: '../components/examples/create_es_query_rule_request.yaml' + createIndexThreholdRuleIdRequest: + $ref: '../components/examples/create_index_threshold_rule_request.yaml' responses: '200': description: Indicates a successful call. @@ -112,8 +114,10 @@ post: schema: $ref: '../components/schemas/rule_response_properties.yaml' examples: - createRuleIdResponse: - $ref: '../components/examples/create_rule_response.yaml' + createEsQueryRuleIdResponse: + $ref: '../components/examples/create_es_query_rule_response.yaml' + createIndexThresholdRuleIdResponse: + $ref: '../components/examples/create_index_threshold_rule_response.yaml' '401': description: Authorization information is missing or invalid. content: diff --git a/x-pack/plugins/exploratory_view/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/exploratory_view/public/typings/fetch_overview_data/index.ts index c563e6a1c448b..42fc114942301 100644 --- a/x-pack/plugins/exploratory_view/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/exploratory_view/public/typings/fetch_overview_data/index.ts @@ -75,7 +75,7 @@ export type HasData = ( export type ObservabilityFetchDataPlugins = Exclude< ObservabilityApp, - 'observability-overview' | 'stack_monitoring' | 'fleet' | 'synthetics' + 'observability-overview' | 'stack_monitoring' | 'fleet' | 'synthetics' | 'profiling' >; export interface DataHandler< diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression_chart.tsx index 805fd616aff06..856c926c14aec 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression_chart.tsx @@ -10,8 +10,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { first, last } from 'lodash'; import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; -import { getChartTheme } from '../../../utils/get_chart_theme'; -import { useIsDarkMode } from '../../../hooks/use_is_dark_mode'; +import { useTimelineChartTheme } from '../../../utils/use_timeline_chart_theme'; import { InventoryMetricConditions } from '../../../../common/alerting/metrics'; import { Color } from '../../../../common/color_palette'; import { MetricsExplorerAggregation, MetricsExplorerRow } from '../../../../common/http_api'; @@ -45,6 +44,7 @@ export const ExpressionChart: React.FC = ({ nodeType, sourceId, }) => { + const chartTheme = useTimelineChartTheme(); const timerange = useMemo( () => ({ interval: `${expression.timeSize || 1}${expression.timeUnit}`, @@ -77,7 +77,6 @@ export const ExpressionChart: React.FC = ({ region: options.region, timerange, }); - const isDarkMode = useIsDarkMode(); const metric = { field: expression.metric, @@ -192,7 +191,7 @@ export const ExpressionChart: React.FC = ({ tickFormat={dateFormatter} /> - + diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx index 6b2357a4f4611..06f00c26abf26 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx @@ -22,8 +22,7 @@ import { import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { PersistedLogViewReference } from '@kbn/logs-shared-plugin/common'; -import { getChartTheme } from '../../../../utils/get_chart_theme'; -import { useIsDarkMode } from '../../../../hooks/use_is_dark_mode'; +import { useTimelineChartTheme } from '../../../../utils/use_timeline_chart_theme'; import { ExecutionTimeRange } from '../../../../types'; import { ChartContainer, @@ -143,7 +142,7 @@ const CriterionPreviewChart: React.FC = ({ annotations, filterSeriesByGroupName, }) => { - const isDarkMode = useIsDarkMode(); + const chartTheme = useTimelineChartTheme(); const timezone = useKibanaTimeZoneSetting(); const { @@ -331,7 +330,7 @@ const CriterionPreviewChart: React.FC = ({ tickFormat={yAxisFormatter} domain={chartDomain} /> - + diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx index 745a1f0169788..a932821b118b3 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.test.tsx @@ -24,6 +24,10 @@ jest.mock('../../../hooks/use_kibana', () => ({ ...mockStartServices, charts: { activeCursor: jest.fn(), + theme: { + useChartsBaseTheme: jest.fn(() => ({})), + useChartsTheme: jest.fn(() => ({})), + }, }, }, }), diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx index 785ba83e3837e..5ea5ebeecfaf1 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression_chart.tsx @@ -22,8 +22,7 @@ import { useActiveCursor } from '@kbn/charts-plugin/public'; import { DataViewBase } from '@kbn/es-query'; import { first, last } from 'lodash'; -import { getChartTheme } from '../../../utils/get_chart_theme'; -import { useIsDarkMode } from '../../../hooks/use_is_dark_mode'; +import { useTimelineChartTheme } from '../../../utils/use_timeline_chart_theme'; import { MetricsSourceConfiguration } from '../../../../common/metrics_sources'; import { Color } from '../../../../common/color_palette'; import { MetricsExplorerRow, MetricsExplorerAggregation } from '../../../../common/http_api'; @@ -72,7 +71,7 @@ export const ExpressionChart: React.FC = ({ timeRange, }) => { const { charts } = useKibanaContextForPlugin().services; - const isDarkMode = useIsDarkMode(); + const chartTheme = useTimelineChartTheme(); const { isLoading, data } = useMetricsExplorerChartData( expression, @@ -200,7 +199,7 @@ export const ExpressionChart: React.FC = ({ externalPointerEvents={{ tooltip: { visible: true }, }} - theme={getChartTheme(isDarkMode)} + baseTheme={chartTheme.baseTheme} /> diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx index ef4b7874eba32..e078f25d60437 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/metrics/chart_section.tsx @@ -18,9 +18,8 @@ import { } from '@elastic/charts'; import moment from 'moment'; import React from 'react'; -import { useIsDarkMode } from '../../../../../../../hooks/use_is_dark_mode'; +import { useTimelineChartTheme } from '../../../../../../../utils/use_timeline_chart_theme'; import { MetricsExplorerSeries } from '../../../../../../../../common/http_api'; -import { getTimelineChartThemes } from '../../../../../../../utils/get_chart_theme'; import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; import { MetricsExplorerChartType, @@ -58,7 +57,7 @@ export const ChartSection = ({ domain, stack = false, }: Props) => { - const isDarkMode = useIsDarkMode(); + const chartTheme = useTimelineChartTheme(); const metrics = series.map((chartSeries) => chartSeries.metric); const tooltipProps: TooltipProps = { headerFormatter: ({ value }) => moment(value).format('Y-MM-DD HH:mm:ss.SSS'), @@ -93,7 +92,11 @@ export const ChartSection = ({ gridLine={{ visible: true }} /> - + ); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx index 043c7f945da47..b19a859f196ea 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/node_details/tabs/processes/process_row_charts.tsx @@ -19,11 +19,10 @@ import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { first, last } from 'lodash'; import moment from 'moment'; import React, { useMemo } from 'react'; -import { useIsDarkMode } from '../../../../../../../hooks/use_is_dark_mode'; +import { useTimelineChartTheme } from '../../../../../../../utils/use_timeline_chart_theme'; import { Color } from '../../../../../../../../common/color_palette'; import { createFormatter } from '../../../../../../../../common/formatters'; import { MetricsExplorerAggregation } from '../../../../../../../../common/http_api'; -import { getChartTheme } from '../../../../../../../utils/get_chart_theme'; import { calculateDomain } from '../../../../../metrics_explorer/components/helpers/calculate_domain'; import { MetricExplorerSeriesChart } from '../../../../../metrics_explorer/components/series_chart'; import { MetricsExplorerChartType } from '../../../../../metrics_explorer/hooks/use_metrics_explorer_options'; @@ -74,12 +73,12 @@ interface ProcessChartProps { label: string; } const ProcessChart = ({ timeseries, color, label }: ProcessChartProps) => { + const chartTheme = useTimelineChartTheme(); const chartMetric = { color, aggregation: 'avg' as MetricsExplorerAggregation, label, }; - const isDarkMode = useIsDarkMode(); const dateFormatter = useMemo(() => { if (!timeseries) return () => ''; @@ -128,7 +127,7 @@ const ProcessChart = ({ timeseries, color, label }: ProcessChartProps) => { gridLine={{ visible: true }} /> moment(value).format('Y-MM-DD HH:mm:ss.SSS')} /> - + ); diff --git a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx index 466064772cb39..1e69f7fb6d8b4 100644 --- a/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/inventory_view/components/timeline/timeline.tsx @@ -28,6 +28,7 @@ import { EuiFlexItem } from '@elastic/eui'; import { EuiFlexGroup } from '@elastic/eui'; import { EuiIcon } from '@elastic/eui'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { useTimelineChartTheme } from '../../../../../utils/use_timeline_chart_theme'; import { toMetricOpt } from '../../../../../../common/snapshot_metric_i18n'; import { MetricsExplorerAggregation } from '../../../../../../common/http_api'; import { colorTransformer, Color } from '../../../../../../common/color_palette'; @@ -38,12 +39,10 @@ import { useWaffleTimeContext } from '../../hooks/use_waffle_time'; import { useWaffleFiltersContext } from '../../hooks/use_waffle_filters'; import { MetricExplorerSeriesChart } from '../../../metrics_explorer/components/series_chart'; import { MetricsExplorerChartType } from '../../../metrics_explorer/hooks/use_metrics_explorer_options'; -import { getTimelineChartThemes } from '../../../../../utils/get_chart_theme'; import { calculateDomain } from '../../../metrics_explorer/components/helpers/calculate_domain'; import { InfraFormatter } from '../../../../../lib/lib'; import { useMetricsHostsAnomaliesResults } from '../../hooks/use_metrics_hosts_anomalies'; import { useMetricsK8sAnomaliesResults } from '../../hooks/use_metrics_k8s_anomalies'; -import { useIsDarkMode } from '../../../../../hooks/use_is_dark_mode'; interface Props { interval: string; @@ -57,6 +56,8 @@ export const Timeline: React.FC = ({ interval, yAxisFormatter, isVisible const { currentTime, jumpToTime, stopAutoReload } = useWaffleTimeContext(); const { filterQueryAsJson } = useWaffleFiltersContext(); + const chartTheme = useTimelineChartTheme(); + const { loading, error, startTime, endTime, timeseries, reload } = useTimeline( filterQueryAsJson, [metric], @@ -123,7 +124,6 @@ export const Timeline: React.FC = ({ interval, yAxisFormatter, isVisible return niceTimeFormatter([firstTimestamp, lastTimestamp]); }, [timeseries]); - const isDarkMode = useIsDarkMode(); const tooltipProps: TooltipProps = { headerFormatter: ({ value }) => moment(value).format('Y-MM-DD HH:mm:ss.SSS'), }; @@ -295,7 +295,11 @@ export const Timeline: React.FC = ({ interval, yAxisFormatter, isVisible gridLine={{ visible: true }} /> - + diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/chart_section_vis.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/chart_section_vis.tsx index 0e5cf39f47503..b5a58fbd76668 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/chart_section_vis.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/chart_section_vis.tsx @@ -19,7 +19,8 @@ import { Tooltip, } from '@elastic/charts'; import { EuiPageContentBody_Deprecated as EuiPageContentBody } from '@elastic/eui'; -import { useIsDarkMode } from '../../../../hooks/use_is_dark_mode'; +import { useTimelineChartTheme } from '../../../../utils/use_timeline_chart_theme'; + import { SeriesChart } from './series_chart'; import { getFormatter, @@ -30,7 +31,6 @@ import { seriesHasLessThen2DataPoints, } from './helpers'; import { ErrorMessage } from './error_message'; -import { getChartTheme } from '../../../../utils/get_chart_theme'; import { useKibanaUiSetting } from '../../../../utils/use_kibana_ui_setting'; import { VisSectionProps } from '../types'; @@ -46,7 +46,8 @@ export const ChartSectionVis = ({ seriesOverrides, type, }: VisSectionProps) => { - const isDarkMode = useIsDarkMode(); + const chartTheme = useTimelineChartTheme(); + const [dateFormat] = useKibanaUiSetting('dateFormat'); /* eslint-disable-next-line react-hooks/exhaustive-deps */ const valueFormatter = useCallback(getFormatter(formatter, formatterTemplate), [ @@ -135,7 +136,7 @@ export const ChartSectionVis = ({ { - const uiCapabilities = useKibana().services.application?.capabilities; - const isDarkMode = useIsDarkMode(); + const { + services: { + application: { capabilities: uiCapabilities }, + }, + } = useKibanaContextForPlugin(); + + const chartTheme = useTimelineChartTheme(); const { metrics } = options; const [dateFormat] = useKibanaUiSetting('dateFormat'); const handleTimeChange: BrushEndListener = ({ x }) => { @@ -160,7 +164,7 @@ export const MetricsExplorerChart = ({ domain={domain} /> - + ) : options.metrics.length > 0 ? ( diff --git a/x-pack/plugins/infra/public/utils/get_chart_theme.ts b/x-pack/plugins/infra/public/utils/get_chart_theme.ts deleted file mode 100644 index ca9253368c7cd..0000000000000 --- a/x-pack/plugins/infra/public/utils/get_chart_theme.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Theme, PartialTheme, LIGHT_THEME, DARK_THEME, SettingsProps } from '@elastic/charts'; - -// TODO use the EUI charts theme see src/plugins/charts/public/services/theme/README.md -export function getChartTheme(isDarkMode: boolean): Theme { - return isDarkMode ? DARK_THEME : LIGHT_THEME; -} - -const TIMELINE_LIGHT_THEME: PartialTheme = { - crosshair: { - band: { - fill: '#D3DAE6', - }, - }, - axes: { - gridLine: { - horizontal: { - stroke: '#eaeaea', - }, - }, - }, -}; - -export const getTimelineChartThemes = ( - isDarkMode: boolean -): Pick => - isDarkMode - ? { - baseTheme: DARK_THEME, - } - : { - baseTheme: LIGHT_THEME, - theme: TIMELINE_LIGHT_THEME, - }; diff --git a/x-pack/plugins/infra/public/utils/use_timeline_chart_theme.ts b/x-pack/plugins/infra/public/utils/use_timeline_chart_theme.ts new file mode 100644 index 0000000000000..7c562c90ab434 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/use_timeline_chart_theme.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SettingsProps } from '@elastic/charts'; +import { useEuiTheme } from '@elastic/eui'; +import { useKibanaContextForPlugin } from '../hooks/use_kibana'; + +export function useTimelineChartTheme(): Pick { + const { euiTheme } = useEuiTheme(); + const { + services: { charts }, + } = useKibanaContextForPlugin(); + + const baseTheme = charts.theme.useChartsBaseTheme(); + const theme = charts.theme.useChartsTheme(); + + return { + baseTheme, + theme: { + ...theme, + background: { + ...theme.background, + color: 'transparent', + }, + crosshair: { + band: { + ...theme.crosshair?.band, + fill: euiTheme.colors.lightShade, + }, + }, + axes: { + gridLine: { + horizontal: { + visible: false, + }, + vertical: { + ...theme.axes?.gridLine?.vertical, + dash: undefined, + }, + }, + }, + }, + }; +} diff --git a/x-pack/plugins/observability_shared/typings/common.ts b/x-pack/plugins/observability_shared/typings/common.ts index fcfd5db7dcbd9..2cddfe2dc5f26 100644 --- a/x-pack/plugins/observability_shared/typings/common.ts +++ b/x-pack/plugins/observability_shared/typings/common.ts @@ -15,4 +15,5 @@ export type ObservabilityApp = | 'observability-overview' | 'stack_monitoring' | 'ux' - | 'fleet'; + | 'fleet' + | 'profiling'; diff --git a/x-pack/plugins/profiling/kibana.jsonc b/x-pack/plugins/profiling/kibana.jsonc index 88e3bc7f33865..4d81adbea7b4c 100644 --- a/x-pack/plugins/profiling/kibana.jsonc +++ b/x-pack/plugins/profiling/kibana.jsonc @@ -7,7 +7,10 @@ "server": true, "browser": true, "configPath": ["xpack", "profiling"], - "optionalPlugins": ["spaces"], + "optionalPlugins": [ + "spaces", + "usageCollection" + ], "requiredPlugins": [ "charts", "cloud", diff --git a/x-pack/plugins/profiling/public/components/flamegraph/index.tsx b/x-pack/plugins/profiling/public/components/flamegraph/index.tsx index 4d143f77b35d0..39bbc47ac382c 100644 --- a/x-pack/plugins/profiling/public/components/flamegraph/index.tsx +++ b/x-pack/plugins/profiling/public/components/flamegraph/index.tsx @@ -18,6 +18,7 @@ import { import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui'; import { Maybe } from '@kbn/observability-plugin/common/typings'; import React, { useEffect, useMemo, useState } from 'react'; +import { useUiTracker } from '@kbn/observability-shared-plugin/public'; import { ElasticFlameGraph } from '../../../common/flamegraph'; import { getFlamegraphModel } from '../../utils/get_flamegraph_model'; import { FlameGraphLegend } from './flame_graph_legend'; @@ -52,6 +53,7 @@ export function FlameGraph({ onChangeSearchText, }: Props) { const theme = useEuiTheme(); + const trackProfilingEvent = useUiTracker({ app: 'profiling' }); const columnarData = useMemo(() => { return getFlamegraphModel({ @@ -154,6 +156,7 @@ export function FlameGraph({ baselineScaleFactor={baseline} comparisonScaleFactor={comparison} onShowMoreClick={() => { + trackProfilingEvent({ metric: 'flamegraph_node_details_click' }); if (!showInformationWindow) { toggleShowInformationWindow(); } diff --git a/x-pack/plugins/profiling/public/components/topn_functions/index.tsx b/x-pack/plugins/profiling/public/components/topn_functions/index.tsx index 603c007e18950..114ca94429c62 100644 --- a/x-pack/plugins/profiling/public/components/topn_functions/index.tsx +++ b/x-pack/plugins/profiling/public/components/topn_functions/index.tsx @@ -20,6 +20,7 @@ import { i18n } from '@kbn/i18n'; import { last } from 'lodash'; import React, { forwardRef, Ref, useMemo, useState } from 'react'; import { GridOnScrollProps } from 'react-window'; +import { useUiTracker } from '@kbn/observability-shared-plugin/public'; import { TopNFunctions, TopNFunctionSortField } from '../../../common/functions'; import { CPULabelWithHint } from '../cpu_label_with_hint'; import { FrameInformationTooltip } from '../frame_information_window/frame_information_tooltip'; @@ -66,6 +67,7 @@ export const TopNFunctionsGrid = forwardRef( ref: Ref | undefined ) => { const [selectedRow, setSelectedRow] = useState(); + const trackProfilingEvent = useUiTracker({ app: 'profiling' }); function onSort(newSortingColumns: EuiDataGridSorting['columns']) { const lastItem = last(newSortingColumns); @@ -220,6 +222,7 @@ export const TopNFunctionsGrid = forwardRef( }, rowCellRender: function RowCellRender({ rowIndex }) { function handleOnClick() { + trackProfilingEvent({ metric: 'topN_function_details_click' }); setSelectedRow(rows[rowIndex]); } return ( @@ -234,7 +237,7 @@ export const TopNFunctionsGrid = forwardRef( }); } return { columns: gridColumns, leadingControlColumns: gridLeadingControlColumns }; - }, [isDifferentialView, rows, showDiffColumn]); + }, [isDifferentialView, rows, showDiffColumn, trackProfilingEvent]); const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id)); diff --git a/x-pack/plugins/profiling/server/plugin.ts b/x-pack/plugins/profiling/server/plugin.ts index b7e13c02c48b8..8cfccee401433 100644 --- a/x-pack/plugins/profiling/server/plugin.ts +++ b/x-pack/plugins/profiling/server/plugin.ts @@ -7,7 +7,7 @@ import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; import { ProfilingConfig } from '.'; -import { PROFILING_FEATURE } from './feature'; +import { PROFILING_FEATURE, PROFILING_SERVER_FEATURE_ID } from './feature'; import { registerRoutes } from './routes'; import { ProfilingPluginSetup, @@ -41,6 +41,10 @@ export class ProfilingPlugin const config = this.initializerContext.config.get(); + const telemetryUsageCounter = deps.usageCollection?.createUsageCounter( + PROFILING_SERVER_FEATURE_ID + ); + core.getStartServices().then(([coreStart, depsStart]) => { const profilingSpecificEsClient = config.elasticsearch ? coreStart.elasticsearch.createClient('profiling', { @@ -57,6 +61,7 @@ export class ProfilingPlugin start: depsStart, setup: deps, config, + telemetryUsageCounter, }, services: { createProfilingEsClient: ({ diff --git a/x-pack/plugins/profiling/server/routes/index.ts b/x-pack/plugins/profiling/server/routes/index.ts index 37c97b71f7f81..7940001e26467 100644 --- a/x-pack/plugins/profiling/server/routes/index.ts +++ b/x-pack/plugins/profiling/server/routes/index.ts @@ -13,6 +13,7 @@ import { ProfilingPluginSetupDeps, ProfilingPluginStartDeps, ProfilingRequestHandlerContext, + TelemetryUsageCounter, } from '../types'; import { ProfilingESClient } from '../utils/create_profiling_es_client'; import { registerFlameChartSearchRoute } from './flamechart'; @@ -33,6 +34,7 @@ export interface RouteRegisterParameters { start: ProfilingPluginStartDeps; setup: ProfilingPluginSetupDeps; config: ProfilingConfig; + telemetryUsageCounter?: TelemetryUsageCounter; }; services: { createProfilingEsClient: (params: { diff --git a/x-pack/plugins/profiling/server/routes/setup.ts b/x-pack/plugins/profiling/server/routes/setup.ts index c23f3516109e7..ac39c89ef5adb 100644 --- a/x-pack/plugins/profiling/server/routes/setup.ts +++ b/x-pack/plugins/profiling/server/routes/setup.ts @@ -200,11 +200,24 @@ export function registerSetupRoute({ await Promise.all(executeFunctions.map((fn) => fn(setupOptions))); + if (dependencies.telemetryUsageCounter) { + dependencies.telemetryUsageCounter.incrementCounter({ + counterName: `POST ${paths.HasSetupESResources}`, + counterType: 'success', + }); + } + // We return a status code of 202 instead of 200 because enabling // resource management in Elasticsearch is an asynchronous action // and is not guaranteed to complete before Kibana sends a response. return response.accepted(); } catch (error) { + if (dependencies.telemetryUsageCounter) { + dependencies.telemetryUsageCounter.incrementCounter({ + counterName: `POST ${paths.HasSetupESResources}`, + counterType: 'error', + }); + } return handleRouteHandlerError({ error, logger, diff --git a/x-pack/plugins/profiling/server/types.ts b/x-pack/plugins/profiling/server/types.ts index 2fe87e18a545b..e1542e6939896 100644 --- a/x-pack/plugins/profiling/server/types.ts +++ b/x-pack/plugins/profiling/server/types.ts @@ -11,6 +11,7 @@ import type { ObservabilityPluginSetup } from '@kbn/observability-plugin/server' import { SpacesPluginStart, SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import { CloudSetup, CloudStart } from '@kbn/cloud-plugin/server'; import { FleetSetupContract, FleetStartContract } from '@kbn/fleet-plugin/server'; +import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; export interface ProfilingPluginSetupDeps { observability: ObservabilityPluginSetup; @@ -18,6 +19,7 @@ export interface ProfilingPluginSetupDeps { cloud: CloudSetup; fleet: FleetSetupContract; spaces?: SpacesPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface ProfilingPluginStartDeps { @@ -34,3 +36,5 @@ export interface ProfilingPluginSetup {} export interface ProfilingPluginStart {} export type ProfilingRequestHandlerContext = CustomRequestHandlerContext<{}>; + +export type TelemetryUsageCounter = ReturnType; diff --git a/x-pack/plugins/profiling/tsconfig.json b/x-pack/plugins/profiling/tsconfig.json index 5e505cca69037..107d916cfaf1a 100644 --- a/x-pack/plugins/profiling/tsconfig.json +++ b/x-pack/plugins/profiling/tsconfig.json @@ -46,6 +46,7 @@ "@kbn/observability-shared-plugin", "@kbn/licensing-plugin", "@kbn/utility-types", + "@kbn/usage-collection-plugin", // add references to other TypeScript projects the plugin depends on // requiredPlugins from ./kibana.json diff --git a/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_license.ts b/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_license.ts index 2d34e4b22a14e..714577d51b1d1 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_license.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_license.ts @@ -8,4 +8,4 @@ import { createLicenseServiceMock } from '../../../../common/license/mocks'; export const licenseService = createLicenseServiceMock(); -export const useLicense = () => licenseService; +export const useLicense = jest.fn(() => licenseService); diff --git a/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.test.ts b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.test.ts new file mode 100644 index 0000000000000..78cb1282f94a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.test.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery as _useQuery } from '@tanstack/react-query'; +import type { AppContextTestRender, ReactQueryHookRenderer } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { allFleetHttpMocks } from '../../mocks'; +import { FleetPackagePolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import { useFetchEndpointPolicy } from './use_fetch_endpoint_policy'; +import type { PolicyData } from '../../../../common/endpoint/types'; +import { + DefaultPolicyNotificationMessage, + DefaultPolicyRuleNotificationMessage, +} from '../../../../common/endpoint/models/policy_config'; +import { set } from 'lodash'; + +const useQueryMock = _useQuery as jest.Mock; + +jest.mock('@tanstack/react-query', () => { + const actualReactQueryModule = jest.requireActual('@tanstack/react-query'); + + return { + ...actualReactQueryModule, + useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)), + }; +}); + +describe('When using the `useGetFileInfo()` hook', () => { + type HookRenderer = ReactQueryHookRenderer< + Parameters, + ReturnType + >; + + let policy: PolicyData; + let queryOptions: NonNullable[1]>; + let http: AppContextTestRender['coreStart']['http']; + let apiMocks: ReturnType; + let renderHook: () => ReturnType; + + beforeEach(() => { + const testContext = createAppRootMockRenderer(); + + queryOptions = {}; + http = testContext.coreStart.http; + apiMocks = allFleetHttpMocks(http); + policy = new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy(); + renderHook = () => { + return (testContext.renderReactQueryHook as HookRenderer)(() => + useFetchEndpointPolicy(policy.id, queryOptions) + ); + }; + }); + + it('should call the correct api with expected value', async () => { + await renderHook(); + + expect(apiMocks.responseProvider.endpointPackagePolicy).toHaveBeenCalledWith({ + path: `/api/fleet/package_policies/${policy.id}`, + }); + }); + + it('should return the expected output', async () => { + apiMocks.responseProvider.endpointPackagePolicy.mockReturnValueOnce({ + item: policy, + }); + const { data } = await renderHook(); + + expect(data).toEqual({ + item: policy, + settings: policy.inputs[0].config.policy.value, + artifactManifest: policy.inputs[0].config.artifact_manifest.value, + }); + }); + + it('should apply defaults to the policy data if necessary', async () => { + policy.updated_at = expect.any(String); + policy.created_at = expect.any(String); + // Expected updates by the hook + const policySettings = policy.inputs[0].config.policy.value; + [ + 'windows.popup.malware.message', + 'mac.popup.malware.message', + 'linux.popup.malware.message', + 'windows.popup.ransomware.message', + ].forEach((keyPath) => { + set(policySettings, keyPath, DefaultPolicyNotificationMessage); + }); + [ + 'windows.popup.memory_protection.message', + 'windows.popup.behavior_protection.message', + 'mac.popup.behavior_protection.message', + 'linux.popup.behavior_protection.message', + ].forEach((keyPath) => { + set(policySettings, keyPath, DefaultPolicyRuleNotificationMessage); + }); + // These should not be updated by the hook since the API response has them already defined + set(policySettings, 'mac.popup.memory_protection.message', 'hello world for mac'); + set(policySettings, 'linux.popup.memory_protection.message', 'hello world for linux'); + + // Setup API response with two of the messages having a value defined. + const apiResponsePolicy = new FleetPackagePolicyGenerator( + 'seed' + ).generateEndpointPackagePolicy(); + set( + apiResponsePolicy.inputs[0].config.policy.value, + 'mac.popup.memory_protection.message', + 'hello world for mac' + ); + set( + apiResponsePolicy.inputs[0].config.policy.value, + 'linux.popup.memory_protection.message', + 'hello world for linux' + ); + apiMocks.responseProvider.endpointPackagePolicy.mockReturnValueOnce({ + item: apiResponsePolicy, + }); + + const { data } = await renderHook(); + + expect(data).toEqual({ + item: policy, + settings: policy.inputs[0].config.policy.value, + artifactManifest: policy.inputs[0].config.artifact_manifest.value, + }); + }); + + it('should apply default values to api returned data', async () => { + queryOptions.queryKey = ['a', 'b']; + queryOptions.retry = false; + queryOptions.refetchInterval = 5; + await renderHook(); + + expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining(queryOptions)); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.ts b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.ts index 78cf98c6b48bd..35d8a0f2ea255 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.ts +++ b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.ts @@ -27,7 +27,7 @@ interface ApiDataResponse { artifactManifest: ManifestSchema; } -type UseFetchEndpointPolicyResponse = UseQueryResult; +export type UseFetchEndpointPolicyResponse = UseQueryResult; /** * Retrieve a single endpoint integration policy (details) @@ -36,7 +36,7 @@ type UseFetchEndpointPolicyResponse = UseQueryResult = {} + options: Omit, 'queryFn'> = {} ): UseFetchEndpointPolicyResponse => { const http = useHttp(); diff --git a/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy_agent_summary.test.ts b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy_agent_summary.test.ts new file mode 100644 index 0000000000000..0513fee664dc1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy_agent_summary.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery as _useQuery } from '@tanstack/react-query'; +import type { AppContextTestRender, ReactQueryHookRenderer } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import type { PolicyData } from '../../../../common/endpoint/types'; +import { allFleetHttpMocks } from '../../mocks'; +import { FleetPackagePolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import { useFetchAgentByAgentPolicySummary } from './use_fetch_endpoint_policy_agent_summary'; +import { agentRouteService } from '@kbn/fleet-plugin/common'; + +const useQueryMock = _useQuery as jest.Mock; + +jest.mock('@tanstack/react-query', () => { + const actualReactQueryModule = jest.requireActual('@tanstack/react-query'); + + return { + ...actualReactQueryModule, + useQuery: jest.fn((...args) => actualReactQueryModule.useQuery(...args)), + }; +}); + +describe('When using the `useFetchEndpointPolicyAgentSummary()` hook', () => { + type HookRenderer = ReactQueryHookRenderer< + Parameters, + ReturnType + >; + + let policy: PolicyData; + let queryOptions: NonNullable[1]>; + let http: AppContextTestRender['coreStart']['http']; + let apiMocks: ReturnType; + let renderHook: () => ReturnType; + + beforeEach(() => { + const testContext = createAppRootMockRenderer(); + + queryOptions = {}; + http = testContext.coreStart.http; + apiMocks = allFleetHttpMocks(http); + policy = new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy(); + renderHook = () => { + return (testContext.renderReactQueryHook as HookRenderer)(() => + useFetchAgentByAgentPolicySummary(policy.policy_id, queryOptions) + ); + }; + }); + + it('should call the correct api with expected value', async () => { + const { data } = await renderHook(); + + expect(apiMocks.responseProvider.agentStatus).toHaveBeenCalledWith({ + path: agentRouteService.getStatusPath(), + query: { policyId: policy.policy_id }, + }); + expect(data).toEqual({ + total: 50, + inactive: 5, + online: 40, + error: 0, + offline: 5, + updating: 0, + other: 0, + events: 0, + }); + }); + + it('should apply default values to api returned data', async () => { + queryOptions.queryKey = ['a', 'b']; + queryOptions.retry = false; + queryOptions.refetchInterval = 5; + await renderHook(); + + expect(useQueryMock).toHaveBeenCalledWith(expect.objectContaining(queryOptions)); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy_agent_summary.ts b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy_agent_summary.ts index 929665ee1a290..cbe69a321d1f5 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy_agent_summary.ts +++ b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy_agent_summary.ts @@ -19,7 +19,7 @@ export const useFetchAgentByAgentPolicySummary = ( * The Fleet Agent Policy ID (NOT the endpoint policy id) */ agentPolicyId: string, - options: UseQueryOptions = {} + options: Omit, 'queryFn'> = {} ): UseQueryResult => { const http = useHttp(); diff --git a/x-pack/plugins/security_solution/public/management/hooks/policy/use_update_endpoint_policy.test.ts b/x-pack/plugins/security_solution/public/management/hooks/policy/use_update_endpoint_policy.test.ts new file mode 100644 index 0000000000000..1f9dc8e300f05 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/hooks/policy/use_update_endpoint_policy.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMutation as _useMutation } from '@tanstack/react-query'; +import type { AppContextTestRender } from '../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { allFleetHttpMocks } from '../../mocks'; +import type { + UseUpdateEndpointPolicyOptions, + UseUpdateEndpointPolicyResult, +} from './use_update_endpoint_policy'; +import type { RenderHookResult } from '@testing-library/react-hooks/src/types'; +import { useUpdateEndpointPolicy } from './use_update_endpoint_policy'; +import type { PolicyData } from '../../../../common/endpoint/types'; +import { FleetPackagePolicyGenerator } from '../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import { packagePolicyRouteService } from '@kbn/fleet-plugin/common'; +import { getPolicyDataForUpdate } from '../../../../common/endpoint/service/policy'; + +const useMutationMock = _useMutation as jest.Mock; + +jest.mock('@tanstack/react-query', () => { + const actualReactQueryModule = jest.requireActual('@tanstack/react-query'); + + return { + ...actualReactQueryModule, + useMutation: jest.fn((...args) => actualReactQueryModule.useMutation(...args)), + }; +}); + +describe('When using the `useFetchEndpointPolicyAgentSummary()` hook', () => { + let customOptions: UseUpdateEndpointPolicyOptions; + let http: AppContextTestRender['coreStart']['http']; + let apiMocks: ReturnType; + let policy: PolicyData; + let renderHook: () => RenderHookResult< + UseUpdateEndpointPolicyOptions, + UseUpdateEndpointPolicyResult + >; + + beforeEach(() => { + const testContext = createAppRootMockRenderer(); + + http = testContext.coreStart.http; + apiMocks = allFleetHttpMocks(http); + policy = new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy(); + customOptions = {}; + renderHook = () => { + return testContext.renderHook(() => useUpdateEndpointPolicy(customOptions)); + }; + }); + + it('should send expected update payload', async () => { + const { + result: { + current: { mutateAsync }, + }, + } = renderHook(); + const result = await mutateAsync({ policy }); + + expect(apiMocks.responseProvider.updateEndpointPolicy).toHaveBeenCalledWith({ + path: packagePolicyRouteService.getUpdatePath(policy.id), + body: JSON.stringify(getPolicyDataForUpdate(policy)), + }); + + expect(result).toEqual({ item: expect.any(Object) }); + }); + + it('should allow custom options to be passed to ReactQuery', async () => { + customOptions.mutationKey = ['abc-123']; + customOptions.cacheTime = 10; + renderHook(); + + expect(useMutationMock).toHaveBeenCalledWith(expect.any(Function), customOptions); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/hooks/policy/use_update_endpoint_policy.ts b/x-pack/plugins/security_solution/public/management/hooks/policy/use_update_endpoint_policy.ts index a81ca3cb4f30d..2e7f8b83ac19b 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/policy/use_update_endpoint_policy.ts +++ b/x-pack/plugins/security_solution/public/management/hooks/policy/use_update_endpoint_policy.ts @@ -18,13 +18,12 @@ interface UpdateParams { policy: PolicyData; } -type UseUpdateEndpointPolicyOptions = UseMutationOptions< - UpdatePolicyResponse, - IHttpFetchError, - UpdateParams +export type UseUpdateEndpointPolicyOptions = Omit< + UseMutationOptions, + 'mutationFn' >; -type UseUpdateEndpointPolicyResult = UseMutationResult< +export type UseUpdateEndpointPolicyResult = UseMutationResult< UpdatePolicyResponse, IHttpFetchError, UpdateParams diff --git a/x-pack/plugins/security_solution/public/management/mocks/fleet_mocks.ts b/x-pack/plugins/security_solution/public/management/mocks/fleet_mocks.ts index 9a5274e2cdfab..36e2e952b4031 100644 --- a/x-pack/plugins/security_solution/public/management/mocks/fleet_mocks.ts +++ b/x-pack/plugins/security_solution/public/management/mocks/fleet_mocks.ts @@ -25,9 +25,16 @@ import { PACKAGE_POLICY_API_ROUTES, } from '@kbn/fleet-plugin/common'; import type { ResponseProvidersInterface } from '../../common/mock/endpoint/http_handler_mock_factory'; -import { httpHandlerMockFactory } from '../../common/mock/endpoint/http_handler_mock_factory'; +import { + composeHttpHandlerMocks, + httpHandlerMockFactory, +} from '../../common/mock/endpoint/http_handler_mock_factory'; import { EndpointDocGenerator } from '../../../common/endpoint/generate_data'; -import type { GetPolicyListResponse, GetPolicyResponse } from '../pages/policy/types'; +import type { + GetPolicyListResponse, + GetPolicyResponse, + UpdatePolicyResponse, +} from '../pages/policy/types'; import { FleetAgentPolicyGenerator } from '../../../common/endpoint/data_generators/fleet_agent_policy_generator'; import { FleetPackagePolicyGenerator } from '../../../common/endpoint/data_generators/fleet_package_policy_generator'; @@ -169,7 +176,7 @@ export const fleetGetEndpointPackagePolicyHttpMock = method: 'get', handler: () => { const response: GetPolicyResponse = { - item: new EndpointDocGenerator('seed').generatePolicyPackagePolicy(), + item: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy(), }; return response; }, @@ -250,12 +257,12 @@ export const fleetGetAgentPolicyListHttpMock = ]); export type FleetBulkGetAgentPolicyListHttpMockInterface = ResponseProvidersInterface<{ - agentPolicy: () => BulkGetAgentPoliciesResponse; + bulkAgentPolicy: () => BulkGetAgentPoliciesResponse; }>; export const fleetBulkGetAgentPolicyListHttpMock = - httpHandlerMockFactory([ + httpHandlerMockFactory([ { - id: 'agentPolicy', + id: 'bulkAgentPolicy', path: AGENT_POLICY_API_ROUTES.BULK_GET_PATTERN, method: 'post', handler: ({ body }) => { @@ -288,12 +295,12 @@ export const fleetBulkGetAgentPolicyListHttpMock = ]); export type FleetBulkGetPackagePoliciesListHttpMockInterface = ResponseProvidersInterface<{ - packagePolicies: () => BulkGetPackagePoliciesResponse; + bulkPackagePolicies: () => BulkGetPackagePoliciesResponse; }>; export const fleetBulkGetPackagePoliciesListHttpMock = httpHandlerMockFactory([ { - id: 'packagePolicies', + id: 'bulkPackagePolicies', path: PACKAGE_POLICY_API_ROUTES.BULK_GET_PATTERN, method: 'post', handler: ({ body }) => { @@ -427,3 +434,49 @@ export const fleetGetAgentStatusHttpMock = }, }, ]); + +export type FleetPutEndpointPackagePolicyHttpMockInterface = ResponseProvidersInterface<{ + updateEndpointPolicy: () => UpdatePolicyResponse; +}>; +export const fleetPutEndpointPackagePolicyHttpMock = + httpHandlerMockFactory([ + { + id: 'updateEndpointPolicy', + path: PACKAGE_POLICY_API_ROUTES.UPDATE_PATTERN, + method: 'put', + handler: ({ body }) => { + const updatedPolicy = new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy( + JSON.parse(body as string) + ); + + return { + item: updatedPolicy, + }; + }, + }, + ]); + +export type AllFleetHttpMocksInterface = FleetGetAgentStatusHttpMockInterface & + FleetGetEndpointPackagePolicyHttpMockInterface & + FleetGetEndpointPackagePolicyListHttpMockInterface & + FleetGetPackageHttpMockInterface & + FleetBulkGetAgentPolicyListHttpMockInterface & + FleetGetAgentPolicyListHttpMockInterface & + FleetGetPackageListHttpMockInterface & + FleetGetPackagePoliciesListHttpMockInterface & + FleetBulkGetPackagePoliciesListHttpMockInterface & + FleetPutEndpointPackagePolicyHttpMockInterface; + +export const allFleetHttpMocks = composeHttpHandlerMocks([ + fleetGetAgentStatusHttpMock, + fleetGetEndpointPackagePolicyHttpMock, + fleetGetEndpointPackagePolicyListHttpMock, + fleetGetPackageHttpMock, + fleetBulkGetAgentPolicyListHttpMock, + fleetGetAgentPolicyListHttpMock, + fleetGetPackageListHttpMock, + fleetGetPackageListHttpMock, + fleetGetPackagePoliciesListHttpMock, + fleetBulkGetPackagePoliciesListHttpMock, + fleetPutEndpointPackagePolicyHttpMock, +]); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.test.tsx new file mode 100644 index 0000000000000..0eb65818b9347 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.test.tsx @@ -0,0 +1,181 @@ +/* + * 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 { expectIsViewOnly, getPolicySettingsFormTestSubjects, exactMatchText } from '../mocks'; +import type { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import { useLicense as _useLicense } from '../../../../../../common/hooks/use_license'; +import { createLicenseServiceMock } from '../../../../../../../common/license/mocks'; +import { licenseService as licenseServiceMocked } from '../../../../../../common/hooks/__mocks__/use_license'; +import type { AdvancedSectionProps } from './advanced_section'; +import { AdvancedSection } from './advanced_section'; +import userEvent from '@testing-library/user-event'; +import { AdvancedPolicySchema } from '../../../models/advanced_policy_schema'; +import { within } from '@testing-library/dom'; +import { set } from 'lodash'; + +jest.mock('../../../../../../common/hooks/use_license'); + +const useLicenseMock = _useLicense as jest.Mock; + +describe('Policy Advanced Settings section', () => { + const testSubj = getPolicySettingsFormTestSubjects('test').advancedSection; + + let formProps: AdvancedSectionProps; + let render: (expanded?: boolean) => ReturnType; + let renderResult: ReturnType; + + const clickShowHideButton = () => { + userEvent.click(renderResult.getByTestId(testSubj.showHideButton)); + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': testSubj.container, + }; + + render = (expanded = true) => { + renderResult = mockedContext.render(); + + if (expanded) { + clickShowHideButton(); + expect(renderResult.getByTestId(testSubj.settingsContainer)); + } + + return renderResult; + }; + }); + + it('should render initially collapsed', () => { + render(false); + + expect(renderResult.queryByTestId(testSubj.settingsContainer)).toBeNull(); + }); + + it('should expand and collapse section when button is clicked', () => { + render(false); + + expect(renderResult.queryByTestId(testSubj.settingsContainer)).toBeNull(); + + clickShowHideButton(); + + expect(renderResult.getByTestId(testSubj.settingsContainer)); + }); + + it('should show warning callout', () => { + const { getByTestId } = render(true); + + expect(getByTestId(testSubj.warningCallout)); + }); + + it('should render all advanced options', () => { + const fieldsWithDefaultValues = [ + 'mac.advanced.capture_env_vars', + 'linux.advanced.capture_env_vars', + ]; + + render(true); + + for (const advancedOption of AdvancedPolicySchema) { + const optionTestSubj = testSubj.settingRowTestSubjects(advancedOption.key); + const renderedRow = within(renderResult.getByTestId(optionTestSubj.container)); + + expect(renderedRow.getByTestId(optionTestSubj.textField)); + expect(renderedRow.getByTestId(optionTestSubj.label)).toHaveTextContent( + exactMatchText(advancedOption.key) + ); + expect(renderedRow.getByTestId(optionTestSubj.versionInfo)).toHaveTextContent( + advancedOption.first_supported_version + ); + + if (advancedOption.last_supported_version) { + expect(renderedRow.getByTestId(optionTestSubj.versionInfo)).toHaveTextContent( + advancedOption.last_supported_version + ); + } + + if (fieldsWithDefaultValues.includes(advancedOption.key)) { + expect(renderedRow.getByTestId(optionTestSubj.textField).value).not.toBe( + '' + ); + } else { + expect(renderedRow.getByTestId(optionTestSubj.textField).value).toBe(''); + } + } + }); + + describe('and when license is lower than Platinum', () => { + beforeEach(() => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + + useLicenseMock.mockReturnValue(licenseServiceMock); + }); + + afterEach(() => { + useLicenseMock.mockReturnValue(licenseServiceMocked); + }); + + it('should not render options that require platinum license', () => { + render(true); + + for (const advancedOption of AdvancedPolicySchema) { + if (advancedOption.license) { + if (advancedOption.license === 'platinum') { + expect( + renderResult.queryByTestId( + testSubj.settingRowTestSubjects(advancedOption.key).container + ) + ).toBeNull(); + } else { + throw new Error( + `${advancedOption.key}: Unknown license value: ${advancedOption.license}` + ); + } + } + } + }); + }); + + describe('and when rendered in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should render with no form fields', () => { + render(); + + expectIsViewOnly(renderResult.getByTestId(testSubj.settingsContainer)); + }); + + it('should render options in expected content', () => { + const option1 = AdvancedPolicySchema[0]; + const option2 = AdvancedPolicySchema[4]; + + set(formProps.policy, option1.key, 'foo'); + set(formProps.policy, option2.key, ''); // test empty value + + const { getByTestId } = render(); + + expectIsViewOnly(renderResult.getByTestId(testSubj.settingsContainer)); + expect(getByTestId(testSubj.settingRowTestSubjects(option1.key).container)).toHaveTextContent( + exactMatchText('linux.advanced.agent.connection_delayInfo 7.9+foo') + ); + expect(getByTestId(testSubj.settingRowTestSubjects(option2.key).container)).toHaveTextContent( + exactMatchText('linux.advanced.artifacts.global.intervalInfo 7.9+—') + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.tsx index 5e6a167b2ecac..063c3b830b7a1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.tsx @@ -197,18 +197,25 @@ export const AdvancedSection = memo( - {key} + + {key} + {documentation && ( - + )} } labelAppend={ - + {lastVersion ? `${firstVersion}-${lastVersion}` : `${firstVersion}+`} } @@ -220,10 +227,11 @@ export const AdvancedSection = memo( name={key} value={value as string} onChange={handleAdvancedSettingUpdate} - disabled={!isEditMode} /> ) : ( - {value || getEmptyValue()} + + {value || getEmptyValue()} + )} ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/antivirus_registration_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/antivirus_registration_card.test.tsx new file mode 100644 index 0000000000000..a4bc279938d5d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/antivirus_registration_card.test.tsx @@ -0,0 +1,125 @@ +/* + * 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 { expectIsViewOnly, getPolicySettingsFormTestSubjects } from '../../mocks'; +import type { AntivirusRegistrationCardProps } from './antivirus_registration_card'; +import { + NOT_REGISTERED_LABEL, + REGISTERED_LABEL, + AntivirusRegistrationCard, +} from './antivirus_registration_card'; +import type { AppContextTestRender } from '../../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import userEvent from '@testing-library/user-event'; +import { cloneDeep, set } from 'lodash'; + +describe('Policy Form Antivirus Registration Card', () => { + const antivirusTestSubj = getPolicySettingsFormTestSubjects('test').antivirusRegistration; + + let formProps: AntivirusRegistrationCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': antivirusTestSubj.card, + }; + + render = () => + (renderResult = mockedContext.render()); + }); + + it('should render in edit mode', () => { + render(); + + expect(renderResult.getByTestId(antivirusTestSubj.enableDisableSwitch)).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); + + it('should display for windows OS with restriction', () => { + render(); + + expect(renderResult.getByTestId(antivirusTestSubj.osValueContainer)).toHaveTextContent( + 'Windows RestrictionsInfo' + ); + }); + + it('should be able to enable the option', () => { + const expectedUpdate = cloneDeep(formProps.policy); + set(expectedUpdate, 'windows.antivirus_registration.enabled', true); + + render(); + + expect(renderResult.getByTestId(antivirusTestSubj.enableDisableSwitch)).toHaveAttribute( + 'aria-checked', + 'false' + ); + + userEvent.click(renderResult.getByTestId(antivirusTestSubj.enableDisableSwitch)); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdate, + }); + }); + + it('should be able to disable the option', async () => { + set(formProps.policy, 'windows.antivirus_registration.enabled', true); + + const expectedUpdate = cloneDeep(formProps.policy); + set(expectedUpdate, 'windows.antivirus_registration.enabled', false); + + render(); + + expect(renderResult.getByTestId(antivirusTestSubj.enableDisableSwitch)).toHaveAttribute( + 'aria-checked', + 'true' + ); + + userEvent.click(renderResult.getByTestId(antivirusTestSubj.enableDisableSwitch)); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdate, + }); + }); + + describe('And rendered in View only mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should render in view mode (option disabled)', () => { + render(); + + expectIsViewOnly(renderResult.getByTestId(antivirusTestSubj.card)); + expect(renderResult.getByTestId(antivirusTestSubj.viewOnlyValue)).toHaveTextContent( + NOT_REGISTERED_LABEL + ); + }); + + it('should render in view mode (option enabled)', () => { + formProps.policy.windows.antivirus_registration.enabled = true; + render(); + + expectIsViewOnly(renderResult.getByTestId(antivirusTestSubj.card)); + expect(renderResult.getByTestId(antivirusTestSubj.viewOnlyValue)).toHaveTextContent( + REGISTERED_LABEL + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/antivirus_registration_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/antivirus_registration_card.tsx index cc1ce6915ff50..3d3f6056210aa 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/antivirus_registration_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/antivirus_registration_card.tsx @@ -21,7 +21,7 @@ const CARD_TITLE = i18n.translate( } ); -const DESCRIPTON = i18n.translate( +const DESCRIPTION = i18n.translate( 'xpack.securitySolution.endpoint.policy.details.antivirusRegistration.explanation', { defaultMessage: @@ -30,21 +30,21 @@ const DESCRIPTON = i18n.translate( } ); -const REGISTERED_LABEL = i18n.translate( +export const REGISTERED_LABEL = i18n.translate( 'xpack.securitySolution.endpoint.policy.details.antivirusRegistration.type', { defaultMessage: 'Register as antivirus', } ); -const NOT_REGISTERED_LABEL = i18n.translate( +export const NOT_REGISTERED_LABEL = i18n.translate( 'xpack.securitySolution.endpoint.policy.details.antivirusRegistration.notRegisteredLabel', { defaultMessage: 'Do not register as antivirus', } ); -type AntivirusRegistrationCardProps = PolicyFormComponentCommonProps; +export type AntivirusRegistrationCardProps = PolicyFormComponentCommonProps; export const AntivirusRegistrationCard = memo( ({ policy, onChange, mode, 'data-test-subj': dataTestSubj }) => { @@ -76,14 +76,19 @@ export const AntivirusRegistrationCard = memo( } )} > - {isEditMode && {DESCRIPTON}} + {isEditMode && {DESCRIPTION}} {isEditMode ? ( - + ) : ( -
    {label}
    +
    {label}
    )} ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.test.tsx new file mode 100644 index 0000000000000..85300fc48c29d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.test.tsx @@ -0,0 +1,170 @@ +/* + * 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 { expectIsViewOnly, getPolicySettingsFormTestSubjects } from '../../mocks'; +import type { AppContextTestRender } from '../../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import type { AttackSurfaceReductionCardProps } from './attack_surface_reduction_card'; +import { + AttackSurfaceReductionCard, + LOCKED_CARD_ATTACK_SURFACE_REDUCTION, + SWITCH_DISABLED_LABEL, + SWITCH_ENABLED_LABEL, +} from './attack_surface_reduction_card'; +import { useLicense as _useLicense } from '../../../../../../../common/hooks/use_license'; +import { cloneDeep, set } from 'lodash'; +import userEvent from '@testing-library/user-event'; +import { createLicenseServiceMock } from '../../../../../../../../common/license/mocks'; +import { licenseService as licenseServiceMocked } from '../../../../../../../common/hooks/__mocks__/use_license'; + +jest.mock('../../../../../../../common/hooks/use_license'); + +const useLicenseMock = _useLicense as jest.Mock; + +describe('Policy Attack Surface Reduction Card', () => { + const testSubj = getPolicySettingsFormTestSubjects('test').attackSurface; + + let formProps: AttackSurfaceReductionCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': testSubj.card, + }; + + render = () => + (renderResult = mockedContext.render()); + }); + + it('should show card in edit mode', () => { + render(); + + expect(renderResult.getByTestId(testSubj.enableDisableSwitch)); + }); + + it('should show correct OS support', () => { + render(); + + expect(renderResult.getByTestId(testSubj.osValues)).toHaveTextContent('Windows'); + }); + + it('should show option enabled', () => { + render(); + + expect(renderResult.getByTestId(testSubj.enableDisableSwitch)).toHaveAttribute( + 'aria-checked', + 'true' + ); + }); + + it('should show option disabled', () => { + set(formProps.policy, 'windows.attack_surface_reduction.credential_hardening.enabled', false); + render(); + + expect(renderResult.getByTestId(testSubj.enableDisableSwitch)).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); + + it('should be able to toggle to disabled', () => { + const expectedUpdate = cloneDeep(formProps.policy); + set(expectedUpdate, 'windows.attack_surface_reduction.credential_hardening.enabled', false); + render(); + + expect(renderResult.getByTestId(testSubj.enableDisableSwitch)).toHaveAttribute( + 'aria-checked', + 'true' + ); + + userEvent.click(renderResult.getByTestId(testSubj.enableDisableSwitch)); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdate, + }); + }); + + it('should should be able to toggle to enabled', () => { + set(formProps.policy, 'windows.attack_surface_reduction.credential_hardening.enabled', false); + + const expectedUpdate = cloneDeep(formProps.policy); + set(expectedUpdate, 'windows.attack_surface_reduction.credential_hardening.enabled', true); + render(); + + expect(renderResult.getByTestId(testSubj.enableDisableSwitch)).toHaveAttribute( + 'aria-checked', + 'false' + ); + + userEvent.click(renderResult.getByTestId(testSubj.enableDisableSwitch)); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdate, + }); + }); + + describe('and license is lower than Platinum', () => { + beforeEach(() => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + + useLicenseMock.mockReturnValue(licenseServiceMock); + }); + + afterEach(() => { + useLicenseMock.mockReturnValue(licenseServiceMocked); + }); + + it('should show locked card if license not platinum+', () => { + render(); + + expect(renderResult.getByTestId(testSubj.lockedCardTitle)).toHaveTextContent( + LOCKED_CARD_ATTACK_SURFACE_REDUCTION + ); + }); + }); + + describe('and displayed in View Mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should render in view mode', () => { + render(); + + expectIsViewOnly(renderResult.getByTestId(testSubj.card)); + }); + + it('should show correct value when disabled', () => { + render(); + + expect(renderResult.getByTestId(testSubj.viewModeValue)).toHaveTextContent( + SWITCH_ENABLED_LABEL + ); + }); + + it('should show correct value when enabled', () => { + set(formProps.policy, 'windows.attack_surface_reduction.credential_hardening.enabled', false); + render(); + + expect(renderResult.getByTestId(testSubj.viewModeValue)).toHaveTextContent( + SWITCH_DISABLED_LABEL + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.tsx index c2356c248261b..203a4009910b3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.tsx @@ -18,7 +18,7 @@ import { SettingCard } from '../setting_card'; const ATTACK_SURFACE_OS_LIST = [OperatingSystem.WINDOWS]; -const LOCKED_CARD_ATTACK_SURFACE_REDUCTION = i18n.translate( +export const LOCKED_CARD_ATTACK_SURFACE_REDUCTION = i18n.translate( 'xpack.securitySolution.endpoint.policy.details.attack_surface_reduction', { defaultMessage: 'Attack Surface Reduction', @@ -32,21 +32,21 @@ const CARD_TITLE = i18n.translate( } ); -const SWITCH_ENABLED_LABEL = i18n.translate( +export const SWITCH_ENABLED_LABEL = i18n.translate( 'xpack.securitySolution.endpoint.policy.details.credentialHardening.toggleEnabled', { defaultMessage: 'Credential hardening enabled', } ); -const SWITCH_DISABLED_LABEL = i18n.translate( +export const SWITCH_DISABLED_LABEL = i18n.translate( 'xpack.securitySolution.endpoint.policy.details.credentialHardening.toggleDisabled', { defaultMessage: 'Credential hardening disabled', } ); -type AttackSurfaceReductionCardProps = PolicyFormComponentCommonProps; +export type AttackSurfaceReductionCardProps = PolicyFormComponentCommonProps; export const AttackSurfaceReductionCard = memo( ({ policy, onChange, mode, 'data-test-subj': dataTestSubj }) => { @@ -69,7 +69,12 @@ export const AttackSurfaceReductionCard = memo( ); if (!isPlatinumPlus) { - return ; + return ( + + ); } return ( @@ -86,7 +91,7 @@ export const AttackSurfaceReductionCard = memo( data-test-subj={getTestId('enableDisableSwitch')} /> ) : ( - <>{label} + {label} )} ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.test.tsx new file mode 100644 index 0000000000000..949c84324f7aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.test.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expectIsViewOnly, getPolicySettingsFormTestSubjects, exactMatchText } from '../../mocks'; +import type { AppContextTestRender } from '../../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import type { BehaviourProtectionCardProps } from './behaviour_protection_card'; +import { BehaviourProtectionCard, LOCKED_CARD_BEHAVIOR_TITLE } from './behaviour_protection_card'; +import { licenseService as licenseServiceMocked } from '../../../../../../../common/hooks/__mocks__/use_license'; +import { useLicense as _useLicense } from '../../../../../../../common/hooks/use_license'; +import { createLicenseServiceMock } from '../../../../../../../../common/license/mocks'; +import { set } from 'lodash'; +import { ProtectionModes } from '../../../../../../../../common/endpoint/types'; + +jest.mock('../../../../../../../common/hooks/use_license'); + +const useLicenseMock = _useLicense as jest.Mock; + +describe('Policy Behaviour Protection Card', () => { + const testSubj = getPolicySettingsFormTestSubjects('test').behaviour; + + let formProps: BehaviourProtectionCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': testSubj.card, + }; + + render = () => + (renderResult = mockedContext.render()); + }); + + it('should render the card with expected components', () => { + const { getByTestId } = render(); + + expect(getByTestId(testSubj.enableDisableSwitch)); + expect(getByTestId(testSubj.protectionPreventRadio)); + expect(getByTestId(testSubj.notifyUserCheckbox)); + expect(getByTestId(testSubj.rulesCallout)); + }); + + it('should show supported OS values', () => { + const { getByTestId } = render(); + + expect(getByTestId(testSubj.osValuesContainer)).toHaveTextContent('Windows, Mac, Linux'); + }); + + describe('and license is lower than Platinum', () => { + beforeEach(() => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + + useLicenseMock.mockReturnValue(licenseServiceMock); + }); + + afterEach(() => { + useLicenseMock.mockReturnValue(licenseServiceMocked); + }); + + it('should show locked card if license not platinum+', () => { + render(); + + expect(renderResult.getByTestId(testSubj.lockedCardTitle)).toHaveTextContent( + LOCKED_CARD_BEHAVIOR_TITLE + ); + }); + }); + + describe('and displayed in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should display correctly when overall card is enabled', () => { + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + 'Type' + + 'Malicious behavior' + + 'Operating system' + + 'Windows, Mac, Linux ' + + 'Malicious behavior protections enabled' + + 'Protection level' + + 'Prevent' + + 'User notification' + + 'Agent version 7.15+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules.' + ); + }); + + it('should display correctly when overall card is disabled', () => { + set(formProps.policy, 'windows.behavior_protection.mode', ProtectionModes.off); + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Malicious behavior' + + 'Operating system' + + 'Windows, Mac, Linux ' + + 'Malicious behavior protections disabled' + + 'Protection level' + + 'Prevent' + + 'User notification' + + 'Agent version 7.15+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + + it('should display user notification disabled', () => { + set(formProps.policy, 'windows.popup.behavior_protection.enabled', false); + + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Malicious behavior' + + 'Operating system' + + 'Windows, Mac, Linux ' + + 'Malicious behavior protections enabled' + + 'Protection level' + + 'Prevent' + + 'User notification' + + 'Agent version 7.15+' + + "Don't notify user" + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.tsx index 033fa855f6f5a..1a71e384146c4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.tsx @@ -8,8 +8,7 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { OperatingSystem } from '@kbn/securitysolution-utils'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiSpacer } from '@elastic/eui'; import { useTestIdGenerator } from '../../../../../../hooks/use_test_id_generator'; import { SettingCard } from '../setting_card'; import { NotifyUserOption } from '../notify_user_option'; @@ -18,13 +17,12 @@ import { ProtectionSettingCardSwitch } from '../protection_setting_card_switch'; import type { Immutable } from '../../../../../../../../common/endpoint/types'; import { PolicyOperatingSystem } from '../../../../../../../../common/endpoint/types'; import type { BehaviorProtectionOSes } from '../../../../types'; -import { LinkToApp } from '../../../../../../../common/components/endpoint/link_to_app'; -import { APP_UI_ID, SecurityPageName } from '../../../../../../../../common'; import { useLicense } from '../../../../../../../common/hooks/use_license'; import { SettingLockedCard } from '../setting_locked_card'; import type { PolicyFormComponentCommonProps } from '../../types'; +import { RelatedDetectionRulesCallout } from '../related_detection_rules_callout'; -const LOCKED_CARD_BEHAVIOR_TITLE = i18n.translate( +export const LOCKED_CARD_BEHAVIOR_TITLE = i18n.translate( 'xpack.securitySolution.endpoint.policy.details.behavior', { defaultMessage: 'Malicious Behavior', @@ -37,7 +35,7 @@ const BEHAVIOUR_OS_VALUES: Immutable = [ PolicyOperatingSystem.linux, ]; -type BehaviourProtectionCardProps = PolicyFormComponentCommonProps; +export type BehaviourProtectionCardProps = PolicyFormComponentCommonProps; export const BehaviourProtectionCard = memo( ({ policy, onChange, mode, 'data-test-subj': dataTestSubj }) => { @@ -52,7 +50,12 @@ export const BehaviourProtectionCard = memo( ); if (!isPlatinumPlus) { - return ; + return ( + + ); } return ( @@ -70,7 +73,7 @@ export const BehaviourProtectionCard = memo( protection={protection} protectionLabel={protectionLabel} osList={BEHAVIOUR_OS_VALUES} - data-test-subj={getTestId()} + data-test-subj={getTestId('enableDisableSwitch')} /> } > @@ -80,6 +83,7 @@ export const BehaviourProtectionCard = memo( mode={mode} protection={protection} osList={BEHAVIOUR_OS_VALUES} + data-test-subj={getTestId('protectionLevel')} /> ( mode={mode} protection={protection} osList={BEHAVIOUR_OS_VALUES} + data-test-subj={getTestId('notifyUser')} /> - - - - - ), - }} - /> - + ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.test.tsx new file mode 100644 index 0000000000000..c1bc1cbef34ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expectIsViewOnly, getPolicySettingsFormTestSubjects, exactMatchText } from '../../mocks'; +import type { AppContextTestRender } from '../../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import type { LinuxEventCollectionCardProps } from './linux_event_collection_card'; +import { LinuxEventCollectionCard } from './linux_event_collection_card'; +import { set } from 'lodash'; + +describe('Policy Linux Event Collection Card', () => { + const testSubj = getPolicySettingsFormTestSubjects('test').linuxEvents; + + let formProps: LinuxEventCollectionCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': testSubj.card, + }; + + render = () => + (renderResult = mockedContext.render()); + }); + + it('should render card with expected content', () => { + const { getByTestId } = render(); + + expect( + getByTestId(testSubj.optionsContainer).querySelectorAll('input[type="checkbox"]') + ).toHaveLength(3); + expect(getByTestId(testSubj.fileCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.networkCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.processCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.osValueContainer)).toHaveTextContent(exactMatchText('Linux')); + expect(getByTestId(testSubj.sessionDataCheckbox)).not.toBeChecked(); + expect(getByTestId(testSubj.captureTerminalCheckbox)).toBeDisabled(); + }); + + describe('and is displayed in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should render card with expected content when session data collection is disabled', () => { + render(); + const card = renderResult.getByTestId(testSubj.card); + + expectIsViewOnly(card); + expect(card).toHaveTextContent( + exactMatchText( + 'Type' + + 'Event collection' + + 'Operating system' + + 'Linux ' + + '3 / 3 event collections enabled' + + 'Events' + + 'File' + + 'Network' + + 'Process' + ) + ); + }); + + it('should render card with expected content when session data collection is enabled', () => { + set(formProps.policy, 'linux.events.session_data', true); + set(formProps.policy, 'linux.events.tty_io', true); + set(formProps.policy, 'linux.events.file', false); + render(); + + const card = renderResult.getByTestId(testSubj.card); + + expectIsViewOnly(card); + expect(card).toHaveTextContent( + exactMatchText( + 'Type' + + 'Event collection' + + 'Operating system' + + 'Linux ' + + '2 / 3 event collections enabled' + + 'Events' + + 'Network' + + 'Process' + + 'Session data' + + 'Collect session data' + + 'Capture terminal output' + + 'Info' + + 'beta' + ) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.tsx index 1bd1943718b29..b408d99c51a73 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.tsx @@ -10,8 +10,8 @@ import { OperatingSystem } from '@kbn/securitysolution-utils'; import { i18n } from '@kbn/i18n'; import type { PolicyFormComponentCommonProps } from '../../types'; import type { UIPolicyConfig } from '../../../../../../../../common/endpoint/types'; -import type { EventFormOption, SupplementalEventFormOption } from './event_collection_card'; -import { EventCollectionCard } from './event_collection_card'; +import type { EventFormOption, SupplementalEventFormOption } from '../event_collection_card'; +import { EventCollectionCard } from '../event_collection_card'; const OPTIONS: ReadonlyArray> = [ { @@ -102,7 +102,7 @@ const SUPPLEMENTAL_OPTIONS: ReadonlyArray((props) => { const supplementalOptions = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.test.tsx new file mode 100644 index 0000000000000..98ff3100770b0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.test.tsx @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expectIsViewOnly, getPolicySettingsFormTestSubjects, exactMatchText } from '../../mocks'; +import type { AppContextTestRender } from '../../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import { set } from 'lodash'; +import type { MacEventCollectionCardProps } from './mac_event_collection_card'; +import { MacEventCollectionCard } from './mac_event_collection_card'; + +describe('Policy Mac Event Collection Card', () => { + const testSubj = getPolicySettingsFormTestSubjects('test').macEvents; + + let formProps: MacEventCollectionCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': testSubj.card, + }; + + render = () => (renderResult = mockedContext.render()); + }); + + it('should render card with expected content', () => { + const { getByTestId } = render(); + + expect( + getByTestId(testSubj.optionsContainer).querySelectorAll('input[type="checkbox"]') + ).toHaveLength(3); + expect(getByTestId(testSubj.fileCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.networkCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.processCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.osValueContainer)).toHaveTextContent(exactMatchText('Mac')); + }); + + describe('and is displayed in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should render card with expected content when session data collection is disabled', () => { + render(); + const card = renderResult.getByTestId(testSubj.card); + + expectIsViewOnly(card); + expect(card).toHaveTextContent( + exactMatchText( + 'Type' + + 'Event collection' + + 'Operating system' + + 'Mac ' + + '3 / 3 event collections enabled' + + 'Events' + + 'File' + + 'Process' + + 'Network' + ) + ); + }); + + it('should render card with expected content when certain events are un-checked', () => { + set(formProps.policy, 'mac.events.file', false); + render(); + + const card = renderResult.getByTestId(testSubj.card); + + expectIsViewOnly(card); + expect(card).toHaveTextContent( + exactMatchText( + 'Type' + + 'Event collection' + + 'Operating system' + + 'Mac ' + + '2 / 3 event collections enabled' + + 'Events' + + 'Process' + + 'Network' + ) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.tsx index 215722635c420..1691aa6089449 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.tsx @@ -8,8 +8,8 @@ import React, { memo } from 'react'; import { OperatingSystem } from '@kbn/securitysolution-utils'; import { i18n } from '@kbn/i18n'; -import type { EventFormOption } from './event_collection_card'; -import { EventCollectionCard } from './event_collection_card'; +import type { EventFormOption } from '../event_collection_card'; +import { EventCollectionCard } from '../event_collection_card'; import type { PolicyFormComponentCommonProps } from '../../types'; const OPTIONS: ReadonlyArray> = [ @@ -33,7 +33,7 @@ const OPTIONS: ReadonlyArray> = [ }, ]; -type MacEventCollectionCardProps = PolicyFormComponentCommonProps; +export type MacEventCollectionCardProps = PolicyFormComponentCommonProps; export const MacEventCollectionCard = memo((props) => { return ( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx new file mode 100644 index 0000000000000..20bcc9282dbc4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx @@ -0,0 +1,191 @@ +/* + * 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 { + expectIsViewOnly, + getPolicySettingsFormTestSubjects, + exactMatchText, + setMalwareMode, +} from '../../mocks'; +import type { AppContextTestRender } from '../../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import type { MalwareProtectionsProps } from './malware_protections_card'; +import { MalwareProtectionsCard } from './malware_protections_card'; +import { ProtectionModes } from '../../../../../../../../common/endpoint/types'; +import { cloneDeep, set } from 'lodash'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../../../../../../../common/hooks/use_license'); + +describe('Policy Malware Protections Card', () => { + const testSubj = getPolicySettingsFormTestSubjects('test').malware; + + let formProps: MalwareProtectionsProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': testSubj.card, + }; + + render = () => (renderResult = mockedContext.render()); + }); + + it('should render the card with expected components', () => { + const { getByTestId } = render(); + + expect(getByTestId(testSubj.enableDisableSwitch)); + expect(getByTestId(testSubj.protectionPreventRadio)); + expect(getByTestId(testSubj.notifyUserCheckbox)); + expect(getByTestId(testSubj.rulesCallout)); + }); + + it('should show supported OS values', () => { + render(); + + expect(renderResult.getByTestId(testSubj.osValuesContainer)).toHaveTextContent( + 'Windows, Mac, Linux' + ); + }); + + it('should set Blocklist to disabled if malware is turned off', () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + setMalwareMode(expectedUpdatedPolicy, true); + render(); + userEvent.click(renderResult.getByTestId(testSubj.enableDisableSwitch)); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it('should allow blocklist to be disabled', () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + set(expectedUpdatedPolicy, 'windows.malware.blocklist', false); + set(expectedUpdatedPolicy, 'mac.malware.blocklist', false); + set(expectedUpdatedPolicy, 'linux.malware.blocklist', false); + render(); + userEvent.click(renderResult.getByTestId(testSubj.blocklistEnableDisableSwitch)); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it('should allow blocklist to be enabled', () => { + set(formProps.policy, 'windows.malware.blocklist', false); + set(formProps.policy, 'mac.malware.blocklist', false); + set(formProps.policy, 'linux.malware.blocklist', false); + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + set(expectedUpdatedPolicy, 'windows.malware.blocklist', true); + set(expectedUpdatedPolicy, 'mac.malware.blocklist', true); + set(expectedUpdatedPolicy, 'linux.malware.blocklist', true); + render(); + userEvent.click(renderResult.getByTestId(testSubj.blocklistEnableDisableSwitch)); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + describe('and displayed in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should display correctly when overall card is enabled', () => { + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Malware' + + 'Operating system' + + 'Windows, Mac, Linux ' + + 'Malware protections enabled' + + 'Protection level' + + 'Prevent' + + 'Blocklist enabled' + + 'Info' + + 'User notification' + + 'Agent version 7.11+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + + it('should display correctly when overall card is disabled', () => { + set(formProps.policy, 'windows.malware.mode', ProtectionModes.off); + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Malware' + + 'Operating system' + + 'Windows, Mac, Linux ' + + 'Malware protections disabled' + + 'Protection level' + + 'Prevent' + + 'Blocklist enabled' + + 'Info' + + 'User notification' + + 'Agent version 7.11+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + + it('should display user notification disabled', () => { + set(formProps.policy, 'windows.popup.malware.enabled', false); + + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Malware' + + 'Operating system' + + 'Windows, Mac, Linux ' + + 'Malware protections enabled' + + 'Protection level' + + 'Prevent' + + 'Blocklist enabled' + + 'Info' + + 'User notification' + + 'Agent version 7.11+' + + "Don't notify user" + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx index 74dbc65737f76..d9844839fce45 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.tsx @@ -8,25 +8,16 @@ import React, { memo, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiCallOut, - EuiSpacer, - EuiSwitch, - EuiFlexGroup, - EuiFlexItem, - EuiIconTip, -} from '@elastic/eui'; +import { EuiSpacer, EuiSwitch, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; import { OperatingSystem } from '@kbn/securitysolution-utils'; import { cloneDeep } from 'lodash'; +import { RelatedDetectionRulesCallout } from '../related_detection_rules_callout'; import { NotifyUserOption } from '../notify_user_option'; import { SettingCard } from '../setting_card'; import type { PolicyFormComponentCommonProps } from '../../types'; -import { APP_UI_ID } from '../../../../../../../../common'; -import { SecurityPageName } from '../../../../../../../app/types'; import type { Immutable } from '../../../../../../../../common/endpoint/types'; import { PolicyOperatingSystem } from '../../../../../../../../common/endpoint/types'; import type { MalwareProtectionOSes } from '../../../../types'; -import { LinkToApp } from '../../../../../../../common/components/endpoint/link_to_app'; import type { ProtectionSettingCardSwitchProps } from '../protection_setting_card_switch'; import { ProtectionSettingCardSwitch } from '../protection_setting_card_switch'; import { DetectPreventProtectionLevel } from '../detect_prevent_protection_level'; @@ -112,7 +103,7 @@ export const MalwareProtectionsCard = React.memo( policy={policy} onChange={onChange} mode={mode} - data-test-subj={getTestId('enableDisableBlocklist')} + data-test-subj={getTestId('blocklist')} /> ( /> - - - - - ), - }} - /> - + ); } @@ -150,56 +126,60 @@ MalwareProtectionsCard.displayName = 'MalwareProtectionsCard'; type EnableDisableBlocklistProps = PolicyFormComponentCommonProps; -const EnableDisableBlocklist = memo(({ policy, onChange, mode }) => { - const checked = policy.windows.malware.blocklist; - const isDisabled = policy.windows.malware.mode === 'off'; - const isEditMode = mode === 'edit'; - const label = checked ? BLOCKLIST_ENABLED_LABEL : BLOCKLIST_DISABLED_LABEL; - - const handleBlocklistSwitchChange = useCallback( - (event) => { - const value = event.target.checked; - const newPayload = cloneDeep(policy); - - adjustBlocklistSettingsOnProtectionSwitch({ - value, - policyConfigData: newPayload, - protectionOsList: MALWARE_OS_VALUES, - }); - - onChange({ isValid: true, updatedPolicy: newPayload }); - }, - [onChange, policy] - ); - - return ( - - - {isEditMode ? ( - ( + ({ policy, onChange, mode, 'data-test-subj': dataTestSubj }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + const checked = policy.windows.malware.blocklist; + const isDisabled = policy.windows.malware.mode === 'off'; + const isEditMode = mode === 'edit'; + const label = checked ? BLOCKLIST_ENABLED_LABEL : BLOCKLIST_DISABLED_LABEL; + + const handleBlocklistSwitchChange = useCallback( + (event) => { + const value = event.target.checked; + const newPayload = cloneDeep(policy); + + adjustBlocklistSettingsOnProtectionSwitch({ + value, + policyConfigData: newPayload, + protectionOsList: MALWARE_OS_VALUES, + }); + + onChange({ isValid: true, updatedPolicy: newPayload }); + }, + [onChange, policy] + ); + + return ( + + + {isEditMode ? ( + + ) : ( + <>{label} + )} + + + + + + } /> - ) : ( - <>{label} - )} - - - - - - } - /> - - - ); -}); + + + ); + } +); EnableDisableBlocklist.displayName = 'EnableDisableBlocklist'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.test.tsx new file mode 100644 index 0000000000000..943e53913b41d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.test.tsx @@ -0,0 +1,164 @@ +/* + * 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 { expectIsViewOnly, getPolicySettingsFormTestSubjects, exactMatchText } from '../../mocks'; +import type { AppContextTestRender } from '../../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import { ProtectionModes } from '../../../../../../../../common/endpoint/types'; +import { set } from 'lodash'; +import type { MemoryProtectionCardProps } from './memory_protection_card'; +import { LOCKED_CARD_MEMORY_TITLE, MemoryProtectionCard } from './memory_protection_card'; +import { createLicenseServiceMock } from '../../../../../../../../common/license/mocks'; +import { licenseService as licenseServiceMocked } from '../../../../../../../common/hooks/__mocks__/use_license'; +import { useLicense as _useLicense } from '../../../../../../../common/hooks/use_license'; + +jest.mock('../../../../../../../common/hooks/use_license'); + +const useLicenseMock = _useLicense as jest.Mock; + +describe('Policy Memory Protections Card', () => { + const testSubj = getPolicySettingsFormTestSubjects('test').memory; + + let formProps: MemoryProtectionCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': testSubj.card, + }; + + render = () => (renderResult = mockedContext.render()); + }); + + it('should render the card with expected components', () => { + const { getByTestId } = render(); + + expect(getByTestId(testSubj.enableDisableSwitch)); + expect(getByTestId(testSubj.protectionPreventRadio)); + expect(getByTestId(testSubj.notifyUserCheckbox)); + expect(getByTestId(testSubj.rulesCallout)); + }); + + it('should show supported OS values', () => { + render(); + + expect(renderResult.getByTestId(testSubj.osValuesContainer)).toHaveTextContent( + 'Windows, Mac, Linux' + ); + }); + + describe('and license is lower than Platinum', () => { + beforeEach(() => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + + useLicenseMock.mockReturnValue(licenseServiceMock); + }); + + afterEach(() => { + useLicenseMock.mockReturnValue(licenseServiceMocked); + }); + + it('should show locked card if license not platinum+', () => { + render(); + + expect(renderResult.getByTestId(testSubj.lockedCardTitle)).toHaveTextContent( + exactMatchText(LOCKED_CARD_MEMORY_TITLE) + ); + }); + }); + + describe('and displayed in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should display correctly when overall card is enabled', () => { + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Memory threat' + + 'Operating system' + + 'Windows, Mac, Linux ' + + 'Memory threat protections enabled' + + 'Protection level' + + 'Prevent' + + 'User notification' + + 'Agent version 7.15+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + + it('should display correctly when overall card is disabled', () => { + set(formProps.policy, 'windows.malware.mode', ProtectionModes.off); + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Memory threat' + + 'Operating system' + + 'Windows, Mac, Linux ' + + 'Memory threat protections enabled' + + 'Protection level' + + 'Prevent' + + 'User notification' + + 'Agent version 7.15+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + + it('should display user notification disabled', () => { + set(formProps.policy, 'windows.popup.malware.enabled', false); + + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Memory threat' + + 'Operating system' + + 'Windows, Mac, Linux ' + + 'Memory threat protections enabled' + + 'Protection level' + + 'Prevent' + + 'User notification' + + 'Agent version 7.15+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.tsx index 3af9a422fc1a6..b4bdac42f2d0d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.tsx @@ -8,8 +8,7 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { OperatingSystem } from '@kbn/securitysolution-utils'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiSpacer } from '@elastic/eui'; import { useTestIdGenerator } from '../../../../../../hooks/use_test_id_generator'; import { NotifyUserOption } from '../notify_user_option'; import { DetectPreventProtectionLevel } from '../detect_prevent_protection_level'; @@ -18,13 +17,12 @@ import { SettingLockedCard } from '../setting_locked_card'; import type { Immutable } from '../../../../../../../../common/endpoint/types'; import { PolicyOperatingSystem } from '../../../../../../../../common/endpoint/types'; import type { MemoryProtectionOSes } from '../../../../types'; -import { LinkToApp } from '../../../../../../../common/components/endpoint/link_to_app'; -import { APP_UI_ID, SecurityPageName } from '../../../../../../../../common'; import { useLicense } from '../../../../../../../common/hooks/use_license'; import type { PolicyFormComponentCommonProps } from '../../types'; import { SettingCard } from '../setting_card'; +import { RelatedDetectionRulesCallout } from '../related_detection_rules_callout'; -const LOCKED_CARD_MEMORY_TITLE = i18n.translate( +export const LOCKED_CARD_MEMORY_TITLE = i18n.translate( 'xpack.securitySolution.endpoint.policy.details.memory', { defaultMessage: 'Memory Threat', @@ -37,7 +35,7 @@ const MEMORY_PROTECTION_OS_VALUES: Immutable = [ PolicyOperatingSystem.linux, ]; -type MemoryProtectionCardProps = PolicyFormComponentCommonProps; +export type MemoryProtectionCardProps = PolicyFormComponentCommonProps; export const MemoryProtectionCard = memo( ({ policy, onChange, mode, 'data-test-subj': dataTestSubj }) => { @@ -52,7 +50,9 @@ export const MemoryProtectionCard = memo( ); if (!isPlatinumPlus) { - return ; + return ( + + ); } return ( @@ -93,22 +93,7 @@ export const MemoryProtectionCard = memo( /> - - - - - ), - }} - /> - + ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.test.tsx new file mode 100644 index 0000000000000..9f400e9634a35 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.test.tsx @@ -0,0 +1,168 @@ +/* + * 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 { expectIsViewOnly, getPolicySettingsFormTestSubjects, exactMatchText } from '../../mocks'; +import type { AppContextTestRender } from '../../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import { ProtectionModes } from '../../../../../../../../common/endpoint/types'; +import { set } from 'lodash'; +import { createLicenseServiceMock } from '../../../../../../../../common/license/mocks'; +import { licenseService as licenseServiceMocked } from '../../../../../../../common/hooks/__mocks__/use_license'; +import { useLicense as _useLicense } from '../../../../../../../common/hooks/use_license'; +import type { RansomwareProtectionCardProps } from './ransomware_protection_card'; +import { + LOCKED_CARD_RAMSOMWARE_TITLE, + RansomwareProtectionCard, +} from './ransomware_protection_card'; + +jest.mock('../../../../../../../common/hooks/use_license'); + +const useLicenseMock = _useLicense as jest.Mock; + +describe('Policy Ransomware Protections Card', () => { + const testSubj = getPolicySettingsFormTestSubjects('test').ransomware; + + let formProps: RansomwareProtectionCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': testSubj.card, + }; + + render = () => + (renderResult = mockedContext.render()); + }); + + it('should render the card with expected components', () => { + const { getByTestId } = render(); + + expect(getByTestId(testSubj.enableDisableSwitch)); + expect(getByTestId(testSubj.protectionPreventRadio)); + expect(getByTestId(testSubj.notifyUserCheckbox)); + expect(getByTestId(testSubj.rulesCallout)); + }); + + it('should show supported OS values', () => { + render(); + + expect(renderResult.getByTestId(testSubj.osValuesContainer)).toHaveTextContent( + exactMatchText('Windows') + ); + }); + + describe('and license is lower than Platinum', () => { + beforeEach(() => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + + useLicenseMock.mockReturnValue(licenseServiceMock); + }); + + afterEach(() => { + useLicenseMock.mockReturnValue(licenseServiceMocked); + }); + + it('should show locked card if license not platinum+', () => { + render(); + + expect(renderResult.getByTestId(testSubj.lockedCardTitle)).toHaveTextContent( + exactMatchText(LOCKED_CARD_RAMSOMWARE_TITLE) + ); + }); + }); + + describe('and displayed in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should display correctly when overall card is enabled', () => { + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Ransomware' + + 'Operating system' + + 'Windows ' + + 'Ransomware protections enabled' + + 'Protection level' + + 'Prevent' + + 'User notification' + + 'Agent version 7.12+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + + it('should display correctly when overall card is disabled', () => { + set(formProps.policy, 'windows.malware.mode', ProtectionModes.off); + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Ransomware' + + 'Operating system' + + 'Windows ' + + 'Ransomware protections enabled' + + 'Protection level' + + 'Prevent' + + 'User notification' + + 'Agent version 7.12+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + + it('should display user notification disabled', () => { + set(formProps.policy, 'windows.popup.malware.enabled', false); + + const { getByTestId } = render(); + + expectIsViewOnly(getByTestId(testSubj.card)); + + expect(getByTestId(testSubj.card)).toHaveTextContent( + exactMatchText( + 'Type' + + 'Ransomware' + + 'Operating system' + + 'Windows ' + + 'Ransomware protections enabled' + + 'Protection level' + + 'Prevent' + + 'User notification' + + 'Agent version 7.12+' + + 'Notify user' + + 'Notification message' + + '—' + + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.tsx index b1988ab53c482..69bdf46d5c705 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.tsx @@ -8,8 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { OperatingSystem } from '@kbn/securitysolution-utils'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiSpacer } from '@elastic/eui'; import { useTestIdGenerator } from '../../../../../../hooks/use_test_id_generator'; import { ProtectionSettingCardSwitch } from '../protection_setting_card_switch'; import { NotifyUserOption } from '../notify_user_option'; @@ -19,23 +18,22 @@ import type { PolicyFormComponentCommonProps } from '../../types'; import type { Immutable } from '../../../../../../../../common/endpoint/types'; import { PolicyOperatingSystem } from '../../../../../../../../common/endpoint/types'; import type { RansomwareProtectionOSes } from '../../../../types'; -import { LinkToApp } from '../../../../../../../common/components/endpoint/link_to_app'; -import { APP_UI_ID, SecurityPageName } from '../../../../../../../../common'; import { useLicense } from '../../../../../../../common/hooks/use_license'; import { SettingLockedCard } from '../setting_locked_card'; +import { RelatedDetectionRulesCallout } from '../related_detection_rules_callout'; const RANSOMEWARE_OS_VALUES: Immutable = [ PolicyOperatingSystem.windows, ]; -const LOCKED_CARD_RAMSOMWARE_TITLE = i18n.translate( +export const LOCKED_CARD_RAMSOMWARE_TITLE = i18n.translate( 'xpack.securitySolution.endpoint.policy.details.ransomware', { defaultMessage: 'Ransomware', } ); -type RansomwareProtectionCardProps = PolicyFormComponentCommonProps; +export type RansomwareProtectionCardProps = PolicyFormComponentCommonProps; export const RansomwareProtectionCard = React.memo( ({ policy, onChange, mode, 'data-test-subj': dataTestSubj }) => { @@ -50,7 +48,12 @@ export const RansomwareProtectionCard = React.memo; + return ( + + ); } return ( @@ -91,22 +94,7 @@ export const RansomwareProtectionCard = React.memo - - - - - ), - }} - /> - + ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx new file mode 100644 index 0000000000000..16c84436684f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { expectIsViewOnly, getPolicySettingsFormTestSubjects, exactMatchText } from '../../mocks'; +import type { AppContextTestRender } from '../../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import { set } from 'lodash'; +import type { WindowsEventCollectionCardProps } from './windows_event_collection_card'; +import { WindowsEventCollectionCard } from './windows_event_collection_card'; + +describe('Policy Windows Event Collection Card', () => { + const testSubj = getPolicySettingsFormTestSubjects('test').windowsEvents; + + let formProps: WindowsEventCollectionCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': testSubj.card, + }; + + render = () => + (renderResult = mockedContext.render()); + }); + + it('should render card with expected content', () => { + const { getByTestId } = render(); + + expect( + getByTestId(testSubj.optionsContainer).querySelectorAll('input[type="checkbox"]') + ).toHaveLength(8); + expect(getByTestId(testSubj.credentialsCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.dllCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.dnsCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.fileCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.networkCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.processCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.registryCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.securityCheckbox)).toBeChecked(); + expect(getByTestId(testSubj.osValueContainer)).toHaveTextContent(exactMatchText('Windows')); + }); + + describe('and is displayed in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should render card with expected content when session data collection is disabled', () => { + render(); + const card = renderResult.getByTestId(testSubj.card); + + expectIsViewOnly(card); + expect(card).toHaveTextContent( + exactMatchText( + 'Type' + + 'Event collection' + + 'Operating system' + + 'Windows 8 / 8 event collections enabled' + + 'Events' + + 'Credential Access' + + 'DLL and Driver Load' + + 'DNS' + + 'File' + + 'Network' + + 'Process' + + 'Registry' + + 'Security' + ) + ); + }); + + it('should render card with expected content when some events are un-checked', () => { + set(formProps.policy, 'windows.events.file', false); + set(formProps.policy, 'windows.events.dns', false); + render(); + + const card = renderResult.getByTestId(testSubj.card); + + expectIsViewOnly(card); + expect(card).toHaveTextContent( + exactMatchText( + 'Type' + + 'Event collection' + + 'Operating system' + + 'Windows ' + + '6 / 8 event collections enabled' + + 'Events' + + 'Credential Access' + + 'DLL and Driver Load' + + 'Network' + + 'Process' + + 'Registry' + + 'Security' + ) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.tsx index 33653e2f603a7..3fadf3665d9fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.tsx @@ -8,8 +8,8 @@ import React, { memo } from 'react'; import { i18n } from '@kbn/i18n'; import { OperatingSystem } from '@kbn/securitysolution-utils'; -import type { EventFormOption } from './event_collection_card'; -import { EventCollectionCard } from './event_collection_card'; +import type { EventFormOption } from '../event_collection_card'; +import { EventCollectionCard } from '../event_collection_card'; import type { PolicyFormComponentCommonProps } from '../../types'; const OPTIONS: ReadonlyArray> = [ @@ -84,7 +84,7 @@ const OPTIONS: ReadonlyArray> = [ }, ]; -type WindowsEventCollectionCardProps = PolicyFormComponentCommonProps; +export type WindowsEventCollectionCardProps = PolicyFormComponentCommonProps; export const WindowsEventCollectionCard = memo((props) => { return ( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.test.tsx new file mode 100644 index 0000000000000..8302a91d7dc89 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.test.tsx @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import type { DetectPreventProtectionLevelProps } from './detect_prevent_protection_level'; +import { DetectPreventProtectionLevel } from './detect_prevent_protection_level'; +import userEvent from '@testing-library/user-event'; +import { cloneDeep, set } from 'lodash'; +import { ProtectionModes } from '../../../../../../../common/endpoint/types'; +import { expectIsViewOnly, exactMatchText } from '../mocks'; +import { createLicenseServiceMock } from '../../../../../../../common/license/mocks'; +import { licenseService as licenseServiceMocked } from '../../../../../../common/hooks/__mocks__/use_license'; +import { useLicense as _useLicense } from '../../../../../../common/hooks/use_license'; + +jest.mock('../../../../../../common/hooks/use_license'); + +const useLicenseMock = _useLicense as jest.Mock; + +describe('Policy form Detect Prevent Protection level component', () => { + let formProps: DetectPreventProtectionLevelProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + const clickProtection = (level: 'detect' | 'prevent') => { + userEvent.click(renderResult.getByTestId(`test-${level}Radio`).querySelector('label')!); + }; + + const isProtectionChecked = (level: 'detect' | 'prevent'): boolean => { + return renderResult.getByTestId(`test-${level}Radio`)!.querySelector('input')!.checked ?? false; + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': 'test', + protection: 'malware', + osList: ['windows', 'mac', 'linux'], + }; + + render = () => { + renderResult = mockedContext.render(); + return renderResult; + }; + }); + + it('should render expected options', () => { + const { getByTestId } = render(); + + expect(getByTestId('test-detectRadio')); + expect(getByTestId('test-preventRadio')); + }); + + it('should allow detect mode to be selected', () => { + const expectedPolicyUpdate = cloneDeep(formProps.policy); + set(expectedPolicyUpdate, 'windows.malware.mode', ProtectionModes.detect); + set(expectedPolicyUpdate, 'mac.malware.mode', ProtectionModes.detect); + set(expectedPolicyUpdate, 'linux.malware.mode', ProtectionModes.detect); + set(expectedPolicyUpdate, 'windows.popup.malware.enabled', false); + set(expectedPolicyUpdate, 'mac.popup.malware.enabled', false); + set(expectedPolicyUpdate, 'linux.popup.malware.enabled', false); + render(); + + expect(isProtectionChecked('prevent')).toBe(true); + + clickProtection('detect'); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedPolicyUpdate, + }); + }); + + it('should allow prevent mode to be selected', () => { + formProps.osList = ['windows']; + set(formProps.policy, 'windows.malware.mode', ProtectionModes.detect); + const expectedPolicyUpdate = cloneDeep(formProps.policy); + set(expectedPolicyUpdate, 'windows.malware.mode', ProtectionModes.prevent); + render(); + + expect(isProtectionChecked('detect')).toBe(true); + + clickProtection('prevent'); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedPolicyUpdate, + }); + }); + + describe('and license is lower than platinum', () => { + beforeEach(() => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + + useLicenseMock.mockReturnValue(licenseServiceMock); + }); + + afterEach(() => { + useLicenseMock.mockReturnValue(licenseServiceMocked); + }); + + it('should NOT update user notification options', () => { + const expectedPolicyUpdate = cloneDeep(formProps.policy); + set(expectedPolicyUpdate, 'windows.malware.mode', ProtectionModes.detect); + set(expectedPolicyUpdate, 'mac.malware.mode', ProtectionModes.detect); + set(expectedPolicyUpdate, 'linux.malware.mode', ProtectionModes.detect); + render(); + + expect(isProtectionChecked('prevent')).toBe(true); + + clickProtection('detect'); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedPolicyUpdate, + }); + }); + }); + + describe('and rendered in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should display prevent', () => { + render(); + + expectIsViewOnly(renderResult.getByTestId('test')); + expect(renderResult.getByTestId('test')).toHaveTextContent( + exactMatchText('Protection levelPrevent') + ); + }); + + it('should display detect', () => { + set(formProps.policy, 'windows.malware.mode', ProtectionModes.detect); + render(); + + expectIsViewOnly(renderResult.getByTestId('test')); + expect(renderResult.getByTestId('test')).toHaveTextContent( + exactMatchText('Protection levelDetect') + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.tsx index a141234ded60e..86d155ca53017 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.tsx @@ -8,7 +8,7 @@ import React, { memo, useCallback, useMemo } from 'react'; import { cloneDeep } from 'lodash'; import type { EuiFlexItemProps } from '@elastic/eui'; -import { EuiRadio, EuiSpacer, EuiFlexGroup, EuiFlexItem, useGeneratedHtmlId } from '@elastic/eui'; +import { EuiRadio, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useTestIdGenerator } from '../../../../../hooks/use_test_id_generator'; @@ -31,12 +31,12 @@ const PREVENT_LABEL = i18n.translate('xpack.securitySolution.endpoint.policy.det defaultMessage: 'Prevent', }); -export type DetectPreventProtectionLavelProps = PolicyFormComponentCommonProps & { +export type DetectPreventProtectionLevelProps = PolicyFormComponentCommonProps & { protection: PolicyProtection; osList: ImmutableArray>; }; -export const DetectPreventProtectionLevel = memo( +export const DetectPreventProtectionLevel = memo( ({ policy, protection, osList, mode, onChange, 'data-test-subj': dataTestSubj }) => { const isEditMode = mode === 'edit'; const getTestId = useTestIdGenerator(dataTestSubj); @@ -127,11 +127,14 @@ const ProtectionRadio = React.memo( mode, 'data-test-subj': dataTestSubj, }: ProtectionRadioProps) => { - const radioButtonId = useGeneratedHtmlId(); const selected = policy.windows[protection].mode; const isPlatinumPlus = useLicense().isPlatinumPlus(); const showEditableFormFields = mode === 'edit'; + const radioId = useMemo(() => { + return `${osList.join('-')}-${protection}-${protectionMode}`; + }, [osList, protection, protectionMode]); + const handleRadioChange = useCallback(() => { const newPayload = cloneDeep(policy); @@ -172,7 +175,7 @@ const ProtectionRadio = React.memo( return ( { + let formProps: EventCollectionCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + const isChecked = (selector: string): boolean => { + return (renderResult.getByTestId(selector) as HTMLInputElement).checked; + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + const policy = new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value; + + formProps = { + policy, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': 'test', + os: OperatingSystem.WINDOWS, + selection: { + file: policy.windows.events.file, + network: policy.windows.events.network, + // For testing purposes, limit the number of events to only 2 + } as typeof policy.windows.events, + options: [ + { + name: 'File', + protectionField: 'file', + }, + { + name: 'Network', + protectionField: 'network', + }, + ], + }; + + render = () => { + renderResult = mockedContext.render( + {...formProps} /> + ); + return renderResult; + }; + }); + + it('should render card with expected content', () => { + const { getByTestId } = render(); + + expect(getByTestId('test-selectedCount')).toHaveTextContent( + exactMatchText('2 / 2 event collections enabled') + ); + expect(getByTestId('test-osValues')).toHaveTextContent(exactMatchText('Windows')); + expect(isChecked('test-file')).toBe(true); + expect(isChecked('test-network')).toBe(true); + }); + + it('should allow items to be unchecked', () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + set(expectedUpdatedPolicy, 'windows.events.file', false); + render(); + userEvent.click(renderResult.getByTestId('test-file')); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it('should allow items to be checked', () => { + set(formProps.policy, 'windows.events.file', false); + formProps.selection.file = false; + + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + set(expectedUpdatedPolicy, 'windows.events.file', true); + + const { getByTestId } = render(); + + expect(getByTestId('test-selectedCount')).toHaveTextContent( + exactMatchText('1 / 2 event collections enabled') + ); + expect(isChecked('test-file')).toBe(false); + + userEvent.click(getByTestId('test-file')); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + describe('and supplementalOptions are used', () => { + let supplementalEntry: SupplementalEventFormOption; + + beforeEach(() => { + formProps.selection.dns = true; + supplementalEntry = { + protectionField: 'dns', + name: 'Collect DNS', + id: 'dns', + title: 'DNS collection', + uncheckedName: 'Do not collect DNS', + description: 'This collects info about DNS', + tooltipText: 'This is a tooltip', + }; + formProps.supplementalOptions = [supplementalEntry]; + }); + + it('should render supplemental option', () => { + const { getByTestId } = render(); + + expect(getByTestId('test-selectedCount')).toHaveTextContent( + exactMatchText('2 / 2 event collections enabled') + ); + expect(isChecked('test-dns')).toBe(true); + + const optionContainer = within(getByTestId('test-dnsContainer')); + + expect(optionContainer.getByTestId('test-dnsTitle')).toHaveTextContent( + exactMatchText(supplementalEntry.title!) + ); + expect(optionContainer.getByTestId('test-dnsDescription')).toHaveTextContent( + exactMatchText(supplementalEntry.description!) + ); + expect(optionContainer.getAllByLabelText(supplementalEntry.name)); + expect(optionContainer.getByTestId('test-dnsTooltipIcon')); + }); + + it('should render with minimum set of options defined', () => { + supplementalEntry = { + name: supplementalEntry.name, + protectionField: supplementalEntry.protectionField, + }; + formProps.supplementalOptions = [supplementalEntry]; + render(); + + expect(renderResult.getByTestId('test-dnsContainer')).toHaveTextContent( + exactMatchText(supplementalEntry.name) + ); + }); + + it('should include BETA badge', () => { + supplementalEntry.beta = true; + render(); + + expect(renderResult.getByTestId('test-dnsBadge')).toHaveTextContent(exactMatchText('beta')); + }); + + it('should indent entry', () => { + supplementalEntry.indented = true; + render(); + + expect(renderResult.getByTestId('test-dnsContainer').getAttribute('style')).toMatch( + /padding-left/ + ); + }); + + it('should should render it disabled', () => { + supplementalEntry.isDisabled = () => true; + render(); + + expect(renderResult.getByTestId('test-dns')).toBeDisabled(); + }); + }); + + describe('and when rendered in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should render with expected content', () => { + render(); + + expectIsViewOnly(renderResult.getByTestId('test')); + expect(renderResult.getByTestId('test-selectedCount')).toHaveTextContent( + exactMatchText('2 / 2 event collections enabled') + ); + expect(renderResult.getByTestId('test-options')).toHaveTextContent( + exactMatchText('FileNetwork') + ); + }); + + it('should only display events that were checked', () => { + set(formProps.policy, 'windows.events.file', false); + formProps.selection.file = false; + render(); + + expect(renderResult.getByTestId('test-selectedCount')).toHaveTextContent( + exactMatchText('1 / 2 event collections enabled') + ); + expect(renderResult.getByTestId('test-options')).toHaveTextContent(exactMatchText('Network')); + }); + + it('should show empty value if no events are selected', () => { + set(formProps.policy, 'windows.events.file', false); + set(formProps.policy, 'windows.events.network', false); + formProps.selection.file = false; + formProps.selection.network = false; + render(); + + expect(renderResult.getByTestId('test-selectedCount')).toHaveTextContent( + exactMatchText('0 / 2 event collections enabled') + ); + expect(renderResult.getByTestId('test-options')).toHaveTextContent(exactMatchText('—')); + }); + + describe('and supplemental options are used', () => { + let supplementalEntry: SupplementalEventFormOption; + + beforeEach(() => { + formProps.selection.dns = true; + supplementalEntry = { + protectionField: 'dns', + name: 'Collect DNS', + id: 'dns', + title: 'DNS collection', + uncheckedName: 'Do not collect DNS', + description: 'This collects info about DNS', + tooltipText: 'This is a tooltip', + }; + formProps.supplementalOptions = [supplementalEntry]; + }); + + it('should render expected content when option is checked', () => { + render(); + const dnsOption = renderResult.getByTestId('test-dnsContainer'); + + expectIsViewOnly(dnsOption); + expect(dnsOption).toHaveTextContent( + exactMatchText('DNS collectionThis collects info about DNSCollect DNSInfo') + ); + }); + + it('should not render option if un-checked', () => { + formProps.policy.windows.events.dns = false; + formProps.selection.dns = false; + render(); + + expect(renderResult.queryByTestId('test-dnsContainer')).toBeNull(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/event_collection_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.tsx similarity index 76% rename from x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/event_collection_card.tsx rename to x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.tsx index 0b59cf42c2307..35ec4e5795335 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/event_collection_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.tsx @@ -18,16 +18,15 @@ import { EuiIconTip, EuiSpacer, EuiText, - useGeneratedHtmlId, } from '@elastic/eui'; import { cloneDeep, get, set } from 'lodash'; import type { EuiCheckboxProps } from '@elastic/eui/src/components/form/checkbox/checkbox'; -import { getEmptyValue } from '../../../../../../../common/components/empty_value'; -import { useTestIdGenerator } from '../../../../../../hooks/use_test_id_generator'; -import type { PolicyFormComponentCommonProps } from '../../types'; -import { SettingCard, SettingCardHeader } from '../setting_card'; -import { PolicyOperatingSystem } from '../../../../../../../../common/endpoint/types'; -import type { UIPolicyConfig } from '../../../../../../../../common/endpoint/types'; +import { getEmptyValue } from '../../../../../../common/components/empty_value'; +import { useTestIdGenerator } from '../../../../../hooks/use_test_id_generator'; +import type { PolicyFormComponentCommonProps } from '../types'; +import { SettingCard, SettingCardHeader } from './setting_card'; +import { PolicyOperatingSystem } from '../../../../../../../common/endpoint/types'; +import type { UIPolicyConfig } from '../../../../../../../common/endpoint/types'; const mapOperatingSystemToPolicyOsKey = { [OperatingSystem.WINDOWS]: PolicyOperatingSystem.windows, @@ -48,15 +47,15 @@ export interface EventFormOption { } export interface SupplementalEventFormOption { + name: string; + protectionField: ProtectionField; + indented?: boolean; id?: string; title?: string; description?: string; - name: string; uncheckedName?: string; - protectionField: ProtectionField; tooltipText?: string; beta?: boolean; - indented?: boolean; isDisabled?(policyConfig: UIPolicyConfig): boolean; } @@ -112,7 +111,7 @@ export const EventCollectionCard = memo( })} supportedOss={[os]} rightCorner={ - + {i18n.translate( 'xpack.securitySolution.endpoint.policy.details.eventCollectionsEnabled', { @@ -134,23 +133,25 @@ export const EventCollectionCard = memo( - {options.map(({ name, protectionField }) => { - const keyPath = `${policyOs}.events.${protectionField}`; +
    + {options.map(({ name, protectionField }) => { + const keyPath = `${policyOs}.events.${protectionField}`; - return ( - - ); - })} + return ( + + ); + })} - {selectedCount === 0 && !isEditMode &&
    {getEmptyValue()}
    } + {selectedCount === 0 && !isEditMode &&
    {getEmptyValue()}
    } +
    {supplementalOptions && supplementalOptions.map( @@ -167,6 +168,7 @@ export const EventCollectionCard = memo( }) => { const keyPath = `${policyOs}.events.${protectionField}`; const isChecked = get(policy, keyPath); + const fieldString = protectionField as string; if (!isEditMode && !isChecked) { return null; @@ -176,18 +178,25 @@ export const EventCollectionCard = memo(
    {title && ( <> - {title} + + {title} + )} {description && ( <> - + {description} @@ -206,19 +215,27 @@ export const EventCollectionCard = memo( onChange={onChange} mode={mode} disabled={isDisabled ? isDisabled(policy) : false} - data-test-subj={getTestId(protectionField as string)} + data-test-subj={getTestId(fieldString)} /> {tooltipText && ( - + )} {beta && ( - + )} @@ -250,7 +267,6 @@ const EventCheckbox = memo( disabled, 'data-test-subj': dataTestSubj, }) => { - const checkboxId = useGeneratedHtmlId(); const isChecked: boolean = get(policy, keyPath); const isEditMode = mode === 'edit'; const displayLabel = isChecked ? label : unCheckedLabel ? unCheckedLabel : label; @@ -268,7 +284,7 @@ const EventCheckbox = memo( return isEditMode ? ( ( disabled={disabled} /> ) : isChecked ? ( -
    {displayLabel}
    +
    {displayLabel}
    ) : null; } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.test.tsx new file mode 100644 index 0000000000000..5a6e591e8e1b5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.test.tsx @@ -0,0 +1,198 @@ +/* + * 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 { useLicense as _useLicense } from '../../../../../../common/hooks/use_license'; +import type { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import { createLicenseServiceMock } from '../../../../../../../common/license/mocks'; +import { licenseService as licenseServiceMocked } from '../../../../../../common/hooks/__mocks__/use_license'; +import type { NotifyUserOptionProps } from './notify_user_option'; +import { + CUSTOMIZE_NOTIFICATION_MESSAGE_LABEL, + NOTIFY_USER_CHECKBOX_LABEL, + NOTIFY_USER_SECTION_TITLE, + NotifyUserOption, +} from './notify_user_option'; +import { expectIsViewOnly, exactMatchText } from '../mocks'; +import { cloneDeep, set } from 'lodash'; +import { ProtectionModes } from '../../../../../../../common/endpoint/types'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../../../../../../common/hooks/use_license'); + +const useLicenseMock = _useLicense as jest.Mock; + +describe('Policy form Detect Prevent Protection level component', () => { + let formProps: NotifyUserOptionProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + const isChecked = (selector: string): boolean => { + return (renderResult.getByTestId(selector) as HTMLInputElement).checked; + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': 'test', + protection: 'malware', + osList: ['windows', 'mac', 'linux'], + }; + + render = () => { + renderResult = mockedContext.render(); + return renderResult; + }; + }); + + it('should render with expected content', () => { + set(formProps.policy, 'windows.popup.malware.message', 'hello world'); + const { getByTestId } = render(); + + expect(getByTestId('test-title')).toHaveTextContent(exactMatchText(NOTIFY_USER_SECTION_TITLE)); + expect(getByTestId('test-supportedVersion')).toHaveTextContent( + exactMatchText('Agent version 7.11+') + ); + expect(isChecked('test-checkbox')).toBe(true); + expect(renderResult.getByLabelText(NOTIFY_USER_CHECKBOX_LABEL)); + expect(getByTestId('test-customMessageTitle')).toHaveTextContent( + exactMatchText(CUSTOMIZE_NOTIFICATION_MESSAGE_LABEL) + ); + expect(getByTestId('test-customMessage')).toHaveValue('hello world'); + }); + + it('should render with options un-checked', () => { + set(formProps.policy, 'windows.popup.malware.enabled', false); + render(); + + expect(isChecked('test-checkbox')).toBe(false); + expect(renderResult.queryByTestId('test-customMessage')).toBeNull(); + }); + + it('should render checked disabled if protection mode is OFF', () => { + set(formProps.policy, 'windows.popup.malware.enabled', false); + set(formProps.policy, 'windows.malware.mode', ProtectionModes.off); + render(); + + expect(renderResult.getByTestId('test-checkbox')).toBeDisabled(); + }); + + it('should be able to un-check the option', () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + set(expectedUpdatedPolicy, 'windows.popup.malware.enabled', false); + set(expectedUpdatedPolicy, 'mac.popup.malware.enabled', false); + set(expectedUpdatedPolicy, 'linux.popup.malware.enabled', false); + render(); + userEvent.click(renderResult.getByTestId('test-checkbox')); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it('should be able to check the option', () => { + set(formProps.policy, 'windows.popup.malware.enabled', false); + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + set(expectedUpdatedPolicy, 'windows.popup.malware.enabled', true); + set(expectedUpdatedPolicy, 'mac.popup.malware.enabled', true); + set(expectedUpdatedPolicy, 'linux.popup.malware.enabled', true); + render(); + userEvent.click(renderResult.getByTestId('test-checkbox')); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it('should be able to change the notification message', () => { + const msg = 'a'; + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + set(expectedUpdatedPolicy, 'windows.popup.malware.message', msg); + set(expectedUpdatedPolicy, 'mac.popup.malware.message', msg); + set(expectedUpdatedPolicy, 'linux.popup.malware.message', msg); + render(); + userEvent.type(renderResult.getByTestId('test-customMessage'), msg); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + describe('and license is lower than platinum', () => { + beforeEach(() => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + + useLicenseMock.mockReturnValue(licenseServiceMock); + }); + + afterEach(() => { + useLicenseMock.mockReturnValue(licenseServiceMocked); + }); + + it('should NOT render the component', () => { + render(); + + expect(renderResult.queryByTestId('test')).toBeNull(); + }); + }); + + describe('and rendered in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + set(formProps.policy, 'windows.popup.malware.message', 'you got owned'); + }); + + it('should render with no form elements', () => { + render(); + + expectIsViewOnly(renderResult.getByTestId('test')); + }); + + it('should render with expected output when checked', () => { + render(); + + expect(renderResult.getByTestId('test')).toHaveTextContent( + exactMatchText( + 'User notification' + + 'Agent version 7.11+' + + 'Notify user' + + 'Notification message' + + 'you got owned' + ) + ); + }); + + it('should render with expected output when checked with empty message', () => { + set(formProps.policy, 'windows.popup.malware.message', ''); + render(); + + expect(renderResult.getByTestId('test')).toHaveTextContent( + exactMatchText('User notificationAgent version 7.11+Notify userNotification message—') + ); + }); + + it('should render with expected output when un-checked', () => { + set(formProps.policy, 'windows.popup.malware.enabled', false); + render(); + + expect(renderResult.getByTestId('test')).toHaveTextContent( + exactMatchText("User notificationAgent version 7.11+Don't notify user") + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.tsx index f7afe7bd186d6..76bdde0d3e465 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.tsx @@ -28,14 +28,19 @@ import type { ImmutableArray, UIPolicyConfig } from '../../../../../../../common import { ProtectionModes } from '../../../../../../../common/endpoint/types'; import type { PolicyProtection, MacPolicyProtection, LinuxPolicyProtection } from '../../../types'; -const NOTIFY_USER_CHECKBOX_LABEL = i18n.translate( +export const NOTIFY_USER_SECTION_TITLE = i18n.translate( + 'xpack.securitySolution.endpoint.policyDetailsConfig.userNotification', + { defaultMessage: 'User notification' } +); + +export const NOTIFY_USER_CHECKBOX_LABEL = i18n.translate( 'xpack.securitySolution.endpoint.policyDetail.notifyUser', { defaultMessage: 'Notify user', } ); -const DO_NOT_NOTIFY_USER_CHECKBOX_LABEL = i18n.translate( +export const DO_NOT_NOTIFY_USER_CHECKBOX_LABEL = i18n.translate( 'xpack.securitySolution.endpoint.policyDetail.doNotNotifyUser', { defaultMessage: "Don't notify user", @@ -49,14 +54,14 @@ const NOTIFICATION_MESSAGE_LABEL = i18n.translate( } ); -const CUSTOMIZE_NOTIFICATION_MESSAGE_LABEL = i18n.translate( +export const CUSTOMIZE_NOTIFICATION_MESSAGE_LABEL = i18n.translate( 'xpack.securitySolution.endpoint.policyDetailsConfig.customizeUserNotification', { defaultMessage: 'Customize notification message', } ); -interface NotifyUserOptionProps extends PolicyFormComponentCommonProps { +export interface NotifyUserOptionProps extends PolicyFormComponentCommonProps { protection: PolicyProtection; osList: ImmutableArray>; } @@ -161,11 +166,8 @@ export const NotifyUserOption = React.memo( return (
    - - + + {NOTIFY_USER_SECTION_TITLE} - +

    {CUSTOMIZE_NOTIFICATION_MESSAGE_LABEL}

    diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.test.tsx new file mode 100644 index 0000000000000..493434b1605d5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.test.tsx @@ -0,0 +1,182 @@ +/* + * 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 { useLicense as _useLicense } from '../../../../../../common/hooks/use_license'; +import type { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoint'; +import { FleetPackagePolicyGenerator } from '../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import React from 'react'; +import { createLicenseServiceMock } from '../../../../../../../common/license/mocks'; +import { licenseService as licenseServiceMocked } from '../../../../../../common/hooks/__mocks__/use_license'; +import type { ProtectionSettingCardSwitchProps } from './protection_setting_card_switch'; +import { ProtectionSettingCardSwitch } from './protection_setting_card_switch'; +import { exactMatchText, expectIsViewOnly, setMalwareMode } from '../mocks'; +import { ProtectionModes } from '../../../../../../../common/endpoint/types'; +import { cloneDeep, set } from 'lodash'; +import userEvent from '@testing-library/user-event'; + +jest.mock('../../../../../../common/hooks/use_license'); + +const useLicenseMock = _useLicense as jest.Mock; + +describe('Policy form ProtectionSettingCardSwitch component', () => { + let formProps: ProtectionSettingCardSwitchProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': 'test', + protection: 'malware', + protectionLabel: 'Malware', + osList: ['windows', 'mac', 'linux'], + }; + + render = () => { + renderResult = mockedContext.render(); + return renderResult; + }; + }); + + it('should render expected output when enabled', () => { + const { getByTestId } = render(); + + expect(getByTestId('test')).toHaveAttribute('aria-checked', 'true'); + expect(getByTestId('test-label')).toHaveTextContent(exactMatchText('Malware enabled')); + }); + + it('should render expected output when disabled', () => { + set(formProps.policy, 'windows.malware.mode', ProtectionModes.off); + const { getByTestId } = render(); + + expect(getByTestId('test')).toHaveAttribute('aria-checked', 'false'); + expect(getByTestId('test-label')).toHaveTextContent(exactMatchText('Malware disabled')); + }); + + it('should be able to disable it', () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + setMalwareMode(expectedUpdatedPolicy, true, true, false); + render(); + userEvent.click(renderResult.getByTestId('test')); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it('should be able to enable it', () => { + setMalwareMode(formProps.policy, true, true, false); + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + setMalwareMode(expectedUpdatedPolicy, false, true, false); + render(); + userEvent.click(renderResult.getByTestId('test')); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it('should invoke `additionalOnSwitchChange` callback if one was defined', () => { + formProps.additionalOnSwitchChange = jest.fn(({ policyConfigData }) => { + const updated = cloneDeep(policyConfigData); + updated.windows.popup.malware.message = 'foo'; + return updated; + }); + + const expectedPolicyDataBeforeAdditionalCallback = cloneDeep(formProps.policy); + setMalwareMode(expectedPolicyDataBeforeAdditionalCallback, true, true, false); + + const expectedUpdatedPolicy = cloneDeep(expectedPolicyDataBeforeAdditionalCallback); + expectedUpdatedPolicy.windows.popup.malware.message = 'foo'; + + render(); + userEvent.click(renderResult.getByTestId('test')); + + expect(formProps.additionalOnSwitchChange).toHaveBeenCalledWith({ + value: false, + policyConfigData: expectedPolicyDataBeforeAdditionalCallback, + protectionOsList: formProps.osList, + }); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + describe('and license is lower than platinum', () => { + beforeEach(() => { + const licenseServiceMock = createLicenseServiceMock(); + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + + useLicenseMock.mockReturnValue(licenseServiceMock); + }); + + afterEach(() => { + useLicenseMock.mockReturnValue(licenseServiceMocked); + }); + + it('should NOT update notification settings when disabling', () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + setMalwareMode(expectedUpdatedPolicy, true, false, false); + render(); + userEvent.click(renderResult.getByTestId('test')); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + + it('should NOT update notification settings when enabling', () => { + const expectedUpdatedPolicy = cloneDeep(formProps.policy); + setMalwareMode(formProps.policy, true, false, false); + render(); + userEvent.click(renderResult.getByTestId('test')); + + expect(formProps.onChange).toHaveBeenCalledWith({ + isValid: true, + updatedPolicy: expectedUpdatedPolicy, + }); + }); + }); + + describe('and rendered in View mode', () => { + beforeEach(() => { + formProps.mode = 'view'; + }); + + it('should not include any form elements', () => { + render(); + + expectIsViewOnly(renderResult.getByTestId('test')); + }); + + it('should show option enabled', () => { + render(); + + expect(renderResult.getByTestId('test')).toHaveTextContent(exactMatchText('Malware enabled')); + }); + + it('should show option disabled', () => { + setMalwareMode(formProps.policy, true, true, false); + render(); + + expect(renderResult.getByTestId('test')).toHaveTextContent( + exactMatchText('Malware disabled') + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.tsx index cdd636b9c4baa..b55fbeba172ba 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.tsx @@ -5,19 +5,20 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSwitch } from '@elastic/eui'; import { cloneDeep } from 'lodash'; +import { useTestIdGenerator } from '../../../../../hooks/use_test_id_generator'; import type { PolicyFormComponentCommonProps } from '../types'; import { useLicense } from '../../../../../../common/hooks/use_license'; import type { ImmutableArray, - UIPolicyConfig, PolicyConfig, + UIPolicyConfig, } from '../../../../../../../common/endpoint/types'; import { ProtectionModes } from '../../../../../../../common/endpoint/types'; -import type { PolicyProtection, MacPolicyProtection, LinuxPolicyProtection } from '../../../types'; +import type { LinuxPolicyProtection, MacPolicyProtection, PolicyProtection } from '../../../types'; export interface ProtectionSettingCardSwitchProps extends PolicyFormComponentCommonProps { protection: PolicyProtection; @@ -45,19 +46,20 @@ export const ProtectionSettingCardSwitch = React.memo( mode, 'data-test-subj': dataTestSubj, }: ProtectionSettingCardSwitchProps) => { + const getTestId = useTestIdGenerator(dataTestSubj); const isPlatinumPlus = useLicense().isPlatinumPlus(); const isEditMode = mode === 'edit'; - const selected = policy && policy.windows[protection].mode; - const switchLabel = i18n.translate( - 'xpack.securitySolution.endpoint.policy.details.protectionsEnabled', - { + const selected = (policy && policy.windows[protection].mode) !== ProtectionModes.off; + + const switchLabel = useMemo(() => { + return i18n.translate('xpack.securitySolution.endpoint.policy.details.protectionsEnabled', { defaultMessage: '{protectionLabel} {mode, select, true {enabled} false {disabled}}', values: { protectionLabel, - mode: selected !== ProtectionModes.off, + mode: selected, }, - } - ); + }); + }, [protectionLabel, selected]); const handleSwitchChange = useCallback( (event) => { @@ -122,16 +124,16 @@ export const ProtectionSettingCardSwitch = React.memo( ); if (!isEditMode) { - return <>{switchLabel}; + return {switchLabel}; } return ( ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/related_detection_rules_callout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/related_detection_rules_callout.test.tsx new file mode 100644 index 0000000000000..1c945d989a94f --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/related_detection_rules_callout.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoint'; +import React from 'react'; +import { RelatedDetectionRulesCallout } from './related_detection_rules_callout'; +import { exactMatchText } from '../mocks'; +import userEvent from '@testing-library/user-event'; + +describe('Policy form RelatedDetectionRulesCallout component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + history = mockedContext.history; + render = () => { + renderResult = mockedContext.render(); + return renderResult; + }; + }); + + it('should render with expected content', () => { + render(); + + expect(renderResult.getByTestId('test')).toHaveTextContent( + exactMatchText( + 'View related detection rules. Prebuilt rules are tagged “Elastic” on the Detection Rules page.' + ) + ); + }); + + it('should navigate to Detection Rules when link is clicked', () => { + render(); + userEvent.click(renderResult.getByTestId('test-link')); + + expect(history.location.pathname).toEqual('/rules'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/related_detection_rules_callout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/related_detection_rules_callout.tsx new file mode 100644 index 0000000000000..df1ca8bcdea45 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/related_detection_rules_callout.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiCallOut } from '@elastic/eui'; +import { useTestIdGenerator } from '../../../../../hooks/use_test_id_generator'; +import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; +import { APP_UI_ID, SecurityPageName } from '../../../../../../../common'; + +export const RelatedDetectionRulesCallout = memo<{ 'data-test-subj'?: string }>( + ({ 'data-test-subj': dataTestSubj }) => { + const getTestId = useTestIdGenerator(dataTestSubj); + + return ( + + + + + ), + }} + /> + + ); + } +); +RelatedDetectionRulesCallout.displayName = 'RelatedDetectionRulesCallout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_card.test.tsx new file mode 100644 index 0000000000000..e342cbd388ad6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_card.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoint'; +import React from 'react'; +import type { SettingCardProps } from './setting_card'; +import { SettingCard } from './setting_card'; +import { OperatingSystem } from '@kbn/securitysolution-utils'; +import { exactMatchText } from '../mocks'; + +describe('Policy form SettingCard component', () => { + let formProps: SettingCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + type: 'Malware', + supportedOss: [OperatingSystem.WINDOWS, OperatingSystem.MAC, OperatingSystem.LINUX], + osRestriction: undefined, + rightCorner: undefined, + dataTestSubj: 'test', + children:
    {'body content here'}
    , + }; + + render = () => { + renderResult = mockedContext.render(); + return renderResult; + }; + }); + + it('should render with expected content', () => { + const { getByTestId } = render(); + + expect(getByTestId('test-osValues')).toHaveTextContent(exactMatchText('Windows, Mac, Linux')); + expect(getByTestId('test-type')).toHaveTextContent(exactMatchText('Malware')); + expect(getByTestId('test-rightCornerContainer')).toBeEmptyDOMElement(); + expect(getByTestId('test-bodyContent')); + }); + + it('should show OS restriction info', () => { + formProps.osRestriction = <>{'some content here'}; + render(); + + expect(renderResult.getByTestId('test-osRestriction')).toHaveTextContent( + exactMatchText('RestrictionsInfo') + ); + }); + + it('should show right corner content', () => { + formProps.rightCorner =
    {'foo'}
    ; + render(); + + expect(renderResult.getByTestId('test-rightCornerContainer')).not.toBeEmptyDOMElement(); + expect(renderResult.getByTestId('test-rightContent')); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_card.tsx index ebae19d960b69..4e1e2991a50c0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_card.tsx @@ -35,7 +35,16 @@ const TITLES = { }), }; -interface SettingCardProps { +export const SettingCardHeader = memo<{ children: React.ReactNode; 'data-test-subj'?: string }>( + ({ children, 'data-test-subj': dataTestSubj }) => ( + +
    {children}
    +
    + ) +); +SettingCardHeader.displayName = 'SettingCardHeader'; + +export type SettingCardProps = React.PropsWithChildren<{ /** * A subtitle for this component. **/ @@ -48,16 +57,7 @@ interface SettingCardProps { dataTestSubj?: string; /** React Node to be put on the right corner of the card */ rightCorner?: ReactNode; -} - -export const SettingCardHeader = memo<{ children: React.ReactNode; 'data-test-subj'?: string }>( - ({ children, 'data-test-subj': dataTestSubj }) => ( - -
    {children}
    -
    - ) -); -SettingCardHeader.displayName = 'SettingCardHeader'; +}>; export const SettingCard: FC = memo( ({ type, supportedOss, osRestriction, dataTestSubj, rightCorner, children }) => { @@ -73,19 +73,26 @@ export const SettingCard: FC = memo( style={{ padding: `${paddingSize} ${paddingSize} 0 ${paddingSize}` }} > - {TITLES.type} - {type} + {TITLES.type} + + {type} + - {TITLES.os} - + {TITLES.os} + {supportedOss.map((os) => OS_TITLES[os]).join(', ')}{' '} {osRestriction && ( - + @@ -96,7 +103,12 @@ export const SettingCard: FC = memo( - + @@ -106,12 +118,16 @@ export const SettingCard: FC = memo( - {rightCorner} + + {rightCorner} + - {rightCorner} + + {rightCorner} + diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_locked_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_locked_card.test.tsx new file mode 100644 index 0000000000000..37327121e5a4b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_locked_card.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AppContextTestRender } from '../../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../../common/mock/endpoint'; +import React from 'react'; +import { exactMatchText } from '../mocks'; +import type { SettingLockedCardProps } from './setting_locked_card'; +import { SettingLockedCard } from './setting_locked_card'; + +describe('Policy form SettingLockedCard component', () => { + let formProps: SettingLockedCardProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + title: 'Malware locked', + 'data-test-subj': 'test', + }; + + render = () => { + renderResult = mockedContext.render(); + return renderResult; + }; + }); + + it('should render with expected content', () => { + const { getByTestId } = render(); + + expect(getByTestId('test')).toHaveTextContent( + exactMatchText( + 'Malware locked' + + 'Upgrade to Elastic Platinum' + + 'To turn on this protection, you must upgrade your license to Platinum, start a free 30-day ' + + 'trial, or spin up a ' + + 'cloud deployment' + + 'External link(opens in a new tab or window) ' + + 'on AWS, GCP, or Azure.Platinum' + ) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_locked_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_locked_card.tsx index bb2faa6f8abe2..1adf53fe3c8b1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_locked_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/setting_locked_card.tsx @@ -31,8 +31,13 @@ const LockedPolicyDiv = styled.div` } `; +export interface SettingLockedCardProps { + title: string; + 'data-test-subj'?: string; +} + export const SettingLockedCard = memo( - ({ title, 'data-test-subj': dataTestSubj }: { title: string; 'data-test-subj'?: string }) => { + ({ title, 'data-test-subj': dataTestSubj }: SettingLockedCardProps) => { const getTestId = useTestIdGenerator(dataTestSubj); return ( @@ -40,6 +45,7 @@ export const SettingLockedCard = memo( } title={ -

    +

    {title}

    } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts index c351bea4fdf93..c14e728a5735b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { set } from 'lodash'; +import type { PolicyConfig } from '../../../../../../common/endpoint/types'; +import { ProtectionModes } from '../../../../../../common/endpoint/types'; + interface TestSubjGenerator { (suffix?: string): string; withPrefix: (prefix: string) => TestSubjGenerator; @@ -37,6 +41,8 @@ export const getPolicySettingsFormTestSubjects = ( const windowsEventsTestSubj = genTestSubj.withPrefix('windowsEvents'); const macEventsTestSubj = genTestSubj.withPrefix('macEvents'); const linuxEventsTestSubj = genTestSubj.withPrefix('linuxEvents'); + const antivirusTestSubj = genTestSubj.withPrefix('antivirusRegistration'); + const attackSurfaceTestSubj = genTestSubj.withPrefix('attackSurface'); const testSubj = { form: genTestSubj(), @@ -52,9 +58,14 @@ export const getPolicySettingsFormTestSubjects = ( notifyCustomMessageTooltipIcon: malwareTestSubj('notifyUser-tooltipIcon'), notifyCustomMessageTooltipInfo: malwareTestSubj('notifyUser-tooltipInfo'), osValuesContainer: malwareTestSubj('osValues'), + rulesCallout: malwareTestSubj('rulesCallout'), + blocklistContainer: malwareTestSubj('blocklist'), + blocklistEnableDisableSwitch: malwareTestSubj('blocklist-enableDisableSwitch'), }, ransomware: { card: ransomwareTestSubj(), + lockedCard: ransomwareTestSubj('locked'), + lockedCardTitle: ransomwareTestSubj('locked-title'), enableDisableSwitch: ransomwareTestSubj('enableDisableSwitch'), protectionPreventRadio: ransomwareTestSubj('protectionLevel-preventRadio'), protectionDetectRadio: ransomwareTestSubj('protectionLevel-detectRadio'), @@ -64,53 +75,147 @@ export const getPolicySettingsFormTestSubjects = ( notifyCustomMessageTooltipIcon: ransomwareTestSubj('notifyUser-tooltipIcon'), notifyCustomMessageTooltipInfo: ransomwareTestSubj('notifyUser-tooltipInfo'), osValuesContainer: ransomwareTestSubj('osValues'), + rulesCallout: ransomwareTestSubj('rulesCallout'), }, memory: { card: memoryTestSubj(), + lockedCard: memoryTestSubj('locked'), + lockedCardTitle: memoryTestSubj('locked-title'), enableDisableSwitch: memoryTestSubj('enableDisableSwitch'), protectionPreventRadio: memoryTestSubj('protectionLevel-preventRadio'), protectionDetectRadio: memoryTestSubj('protectionLevel-detectRadio'), notifyUserCheckbox: memoryTestSubj('notifyUser-checkbox'), osValuesContainer: memoryTestSubj('osValues'), + rulesCallout: memoryTestSubj('rulesCallout'), }, behaviour: { card: behaviourTestSubj(), + lockedCard: behaviourTestSubj('locked'), + lockedCardTitle: behaviourTestSubj('locked-title'), enableDisableSwitch: behaviourTestSubj('enableDisableSwitch'), protectionPreventRadio: behaviourTestSubj('protectionLevel-preventRadio'), protectionDetectRadio: behaviourTestSubj('protectionLevel-detectRadio'), notifyUserCheckbox: behaviourTestSubj('notifyUser-checkbox'), osValuesContainer: behaviourTestSubj('osValues'), + rulesCallout: behaviourTestSubj('rulesCallout'), }, - attachSurface: { - card: genTestSubj('attackSurface'), - enableDisableSwitch: genTestSubj('attachSurface-enableDisableSwitch'), - osValuesContainer: genTestSubj('attackSurface-osValues'), + attackSurface: { + card: attackSurfaceTestSubj(), + lockedCard: attackSurfaceTestSubj('locked'), + lockedCardTitle: attackSurfaceTestSubj('locked-title'), + enableDisableSwitch: attackSurfaceTestSubj('enableDisableSwitch'), + osValues: attackSurfaceTestSubj('osValues'), + viewModeValue: attackSurfaceTestSubj('valueLabel'), }, windowsEvents: { card: windowsEventsTestSubj(), + osValueContainer: windowsEventsTestSubj('osValueContainer'), + optionsContainer: windowsEventsTestSubj('options'), + credentialsCheckbox: windowsEventsTestSubj('credential_access'), + dllCheckbox: windowsEventsTestSubj('dll_and_driver_load'), dnsCheckbox: windowsEventsTestSubj('dns'), - processCheckbox: windowsEventsTestSubj('process'), fileCheckbox: windowsEventsTestSubj('file'), + networkCheckbox: windowsEventsTestSubj('network'), + processCheckbox: windowsEventsTestSubj('process'), + registryCheckbox: windowsEventsTestSubj('registry'), + securityCheckbox: windowsEventsTestSubj('security'), }, macEvents: { card: macEventsTestSubj(), + osValueContainer: macEventsTestSubj('osValueContainer'), + optionsContainer: macEventsTestSubj('options'), fileCheckbox: macEventsTestSubj('file'), + networkCheckbox: macEventsTestSubj('network'), + processCheckbox: macEventsTestSubj('process'), }, linuxEvents: { card: linuxEventsTestSubj(), + osValueContainer: linuxEventsTestSubj('osValueContainer'), + optionsContainer: linuxEventsTestSubj('options'), fileCheckbox: linuxEventsTestSubj('file'), + networkCheckbox: linuxEventsTestSubj('network'), + processCheckbox: linuxEventsTestSubj('process'), + sessionDataCheckbox: linuxEventsTestSubj('session_data'), + captureTerminalCheckbox: linuxEventsTestSubj('tty_io'), }, antivirusRegistration: { - card: genTestSubj('antivirusRegistration'), + card: antivirusTestSubj(), + enableDisableSwitch: antivirusTestSubj('switch'), + osValueContainer: antivirusTestSubj('osValueContainer'), + viewOnlyValue: antivirusTestSubj('value'), }, advancedSection: { container: advancedSectionTestSubj(''), showHideButton: advancedSectionTestSubj('showButton'), settingsContainer: advancedSectionTestSubj('settings'), warningCallout: advancedSectionTestSubj('warning'), + settingRowTestSubjects: (settingKeyPath: string) => { + const testSubjForSetting = advancedSectionTestSubj.withPrefix(settingKeyPath); + + return { + container: testSubjForSetting('container'), + label: testSubjForSetting('label'), + tooltipIcon: testSubjForSetting('tooltipIcon'), + versionInfo: testSubjForSetting('versionInfo'), + textField: settingKeyPath, + viewValue: testSubjForSetting('viewValue'), + }; + }, }, }; return testSubj; }; + +export const expectIsViewOnly = (ele: HTMLElement): void => { + expect( + ele.querySelectorAll( + 'button:not(.euiLink, [data-test-subj*="advancedSection-showButton"]),input,select,textarea' + ) + ).toHaveLength(0); +}; + +/** + * Create a regular expression with the provided text that ensure it matches the entire string. + * @param text + */ +export const exactMatchText = (text: string): RegExp => { + // RegExp below taken from: https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js + return new RegExp(`^${text.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')}$`); +}; + +/** + * Sets malware off or on (to prevent protection level) in the given policy settings + * + * NOTE: this utiliy MUTATES `policy` provided on input + * + * @param policy + * @param turnOff + * @param includePopup + */ +export const setMalwareMode = ( + policy: PolicyConfig, + turnOff: boolean = false, + includePopup: boolean = true, + includeBlocklist: boolean = true +) => { + const mode = turnOff ? ProtectionModes.off : ProtectionModes.prevent; + const enableValue = mode !== ProtectionModes.off; + + set(policy, 'windows.malware.mode', mode); + set(policy, 'mac.malware.mode', mode); + set(policy, 'linux.malware.mode', mode); + + if (includeBlocklist) { + set(policy, 'windows.malware.blocklist', enableValue); + set(policy, 'mac.malware.blocklist', enableValue); + set(policy, 'linux.malware.blocklist', enableValue); + } + + if (includePopup) { + set(policy, 'windows.popup.malware.enabled', enableValue); + set(policy, 'mac.popup.malware.enabled', enableValue); + set(policy, 'linux.popup.malware.enabled', enableValue); + } +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/policy_settings_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/policy_settings_form.test.tsx new file mode 100644 index 0000000000000..5aed812e9f3c9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/policy_settings_form.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { expectIsViewOnly, getPolicySettingsFormTestSubjects } from './mocks'; +import type { AppContextTestRender } from '../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../common/mock/endpoint'; +import type { PolicySettingsFormProps } from './policy_settings_form'; +import { PolicySettingsForm } from './policy_settings_form'; +import { FleetPackagePolicyGenerator } from '../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; + +jest.mock('../../../../../common/hooks/use_license'); + +describe('Endpoint Policy Settings Form', () => { + const testSubj = getPolicySettingsFormTestSubjects('test'); + + let formProps: PolicySettingsFormProps; + let render: () => ReturnType; + let renderResult: ReturnType; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + formProps = { + policy: new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy().inputs[0] + .config.policy.value, + onChange: jest.fn(), + mode: 'edit', + 'data-test-subj': 'test', + }; + + render = () => (renderResult = mockedContext.render()); + }); + + it.each([ + ['malware', testSubj.malware.card], + ['ransomware', testSubj.ransomware.card], + ['memory', testSubj.memory.card], + ['behaviour', testSubj.behaviour.card], + ['attack surface', testSubj.attackSurface.card], + ['windows events', testSubj.windowsEvents.card], + ['mac events', testSubj.macEvents.card], + ['linux events', testSubj.linuxEvents.card], + ['antivirus registration', testSubj.antivirusRegistration.card], + ['advanced settings', testSubj.advancedSection.container], + ])('should include %s card', (_, testSubjSelector) => { + render(); + + expect(renderResult.getByTestId(testSubjSelector)); + }); + + it('should render in View mode', () => { + formProps.mode = 'view'; + render(); + + expectIsViewOnly(renderResult.getByTestId('test')); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx new file mode 100644 index 0000000000000..64188ce296c6e --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx @@ -0,0 +1,217 @@ +/* + * 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 { FleetPackagePolicyGenerator } from '../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; +import type { PolicyData } from '../../../../../../common/endpoint/types'; +import type { AppContextTestRender } from '../../../../../common/mock/endpoint'; +import { createAppRootMockRenderer } from '../../../../../common/mock/endpoint'; +import { PolicySettingsLayout } from './policy_settings_layout'; +import { useUserPrivileges as _useUserPrivileges } from '../../../../../common/components/user_privileges'; +import { getUserPrivilegesMockDefaultValue } from '../../../../../common/components/user_privileges/__mocks__'; +import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/components/user_privileges/endpoint/mocks'; +import { allFleetHttpMocks } from '../../../../mocks'; +import userEvent from '@testing-library/user-event'; +import { expectIsViewOnly, getPolicySettingsFormTestSubjects } from '../policy_settings_form/mocks'; +import { cloneDeep, set } from 'lodash'; +import { ProtectionModes } from '../../../../../../common/endpoint/types'; +import { waitFor, cleanup } from '@testing-library/react'; +import { packagePolicyRouteService } from '@kbn/fleet-plugin/common'; +import { getPolicyDataForUpdate } from '../../../../../../common/endpoint/service/policy'; +import { getDeferred } from '../../../../mocks/utils'; + +jest.mock('../../../../../common/hooks/use_license'); +jest.mock('../../../../../common/components/user_privileges'); + +const useUserPrivilegesMock = _useUserPrivileges as jest.Mock; + +describe('When rendering PolicySettingsLayout', () => { + jest.setTimeout(15000); + + const testSubj = getPolicySettingsFormTestSubjects(); + + let policyData: PolicyData; + let render: () => ReturnType; + let renderResult: ReturnType; + let apiMocks: ReturnType; + let toasts: AppContextTestRender['coreStart']['notifications']['toasts']; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + toasts = mockedContext.coreStart.notifications.toasts; + apiMocks = allFleetHttpMocks(mockedContext.coreStart.http); + policyData = new FleetPackagePolicyGenerator('seed').generateEndpointPackagePolicy(); + render = () => { + renderResult = mockedContext.render(); + return renderResult; + }; + }); + + afterEach(() => { + cleanup(); + }); + + describe('and user has Edit permissions', () => { + const clickSave = async (andConfirm: boolean = true, ensureApiIsCalled: boolean = true) => { + const { getByTestId } = renderResult; + + userEvent.click(getByTestId('policyDetailsSaveButton')); + await waitFor(() => { + expect(getByTestId('confirmModalConfirmButton')); + }); + + if (andConfirm) { + userEvent.click(getByTestId('confirmModalConfirmButton')); + + if (ensureApiIsCalled) { + await waitFor(() => { + expect(apiMocks.responseProvider.updateEndpointPolicy).toHaveBeenCalled(); + }); + } + } + }; + + /** + * Makes updates to the policy form on the UI and return back a new (cloned) `PolicyData` + * with the updates reflected in it + */ + const makeUpdates = () => { + const { getByTestId } = renderResult; + const expectedUpdates = cloneDeep(policyData); + const policySettings = expectedUpdates.inputs[0].config.policy.value; + + // Turn off malware + userEvent.click(getByTestId(testSubj.malware.enableDisableSwitch)); + set(policySettings, 'windows.malware.mode', ProtectionModes.off); + set(policySettings, 'mac.malware.mode', ProtectionModes.off); + set(policySettings, 'linux.malware.mode', ProtectionModes.off); + set(policySettings, 'windows.malware.blocklist', false); + set(policySettings, 'mac.malware.blocklist', false); + set(policySettings, 'linux.malware.blocklist', false); + set(policySettings, 'windows.popup.malware.enabled', false); + set(policySettings, 'mac.popup.malware.enabled', false); + set(policySettings, 'linux.popup.malware.enabled', false); + + // Turn off Behaviour Protection + userEvent.click(getByTestId(testSubj.behaviour.enableDisableSwitch)); + set(policySettings, 'windows.behavior_protection.mode', ProtectionModes.off); + set(policySettings, 'mac.behavior_protection.mode', ProtectionModes.off); + set(policySettings, 'linux.behavior_protection.mode', ProtectionModes.off); + set(policySettings, 'windows.popup.behavior_protection.enabled', false); + set(policySettings, 'mac.popup.behavior_protection.enabled', false); + set(policySettings, 'linux.popup.behavior_protection.enabled', false); + + // Set Ransomware User Notification message + userEvent.type(getByTestId(testSubj.ransomware.notifyCustomMessage), 'foo message'); + set(policySettings, 'windows.popup.ransomware.message', 'foo message'); + + userEvent.click(getByTestId(testSubj.advancedSection.showHideButton)); + userEvent.type(getByTestId('linux.advanced.agent.connection_delay'), '1000'); + set(policySettings, 'linux.advanced.agent.connection_delay', '1000'); + + return expectedUpdates; + }; + + it('should render layout with expected content', () => { + const { getByTestId } = render(); + + expect(getByTestId('endpointPolicyForm')); + expect(getByTestId('policyDetailsCancelButton')).not.toBeDisabled(); + expect(getByTestId('policyDetailsSaveButton')).not.toBeDisabled(); + }); + + it('should allow updates to be made', async () => { + render(); + const expectedUpdatedPolicy = makeUpdates(); + await clickSave(); + + expect(apiMocks.responseProvider.updateEndpointPolicy).toHaveBeenCalledWith({ + path: packagePolicyRouteService.getUpdatePath(policyData.id), + body: JSON.stringify(getPolicyDataForUpdate(expectedUpdatedPolicy)), + }); + }); + + it('should show buttons disabled while update is in flight', async () => { + const deferred = getDeferred(); + apiMocks.responseProvider.updateEndpointPolicy.mockDelay.mockReturnValue(deferred.promise); + const { getByTestId } = render(); + await clickSave(true, false); + + await waitFor(() => { + expect(getByTestId('policyDetailsCancelButton')).toBeDisabled(); + }); + + expect(getByTestId('policyDetailsSaveButton')).toBeDisabled(); + expect( + getByTestId('policyDetailsSaveButton').querySelector('.euiLoadingSpinner') + ).not.toBeNull(); + + deferred.resolve(); + }); + + it('should show success toast on update success', async () => { + render(); + await clickSave(); + + await waitFor(() => { + expect(renderResult.getByTestId('policyDetailsSaveButton')).not.toBeDisabled(); + }); + + expect(toasts.addSuccess).toHaveBeenCalledWith({ + 'data-test-subj': 'policyDetailsSuccessMessage', + text: 'Integration Endpoint Policy {ku5j) has been updated.', + title: 'Success!', + }); + }); + + it('should show Danger toast on update failure', async () => { + apiMocks.responseProvider.updateEndpointPolicy.mockImplementation(() => { + throw new Error('oh oh!'); + }); + render(); + await clickSave(); + + await waitFor(() => { + expect(renderResult.getByTestId('policyDetailsSaveButton')).not.toBeDisabled(); + }); + + expect(toasts.addDanger).toHaveBeenCalledWith({ + 'data-test-subj': 'policyDetailsFailureMessage', + text: 'oh oh!', + title: 'Failed!', + }); + }); + }); + + describe('and user has View Only permissions', () => { + beforeEach(() => { + const privileges = getUserPrivilegesMockDefaultValue(); + privileges.endpointPrivileges = getEndpointPrivilegesInitialStateMock({ + canWritePolicyManagement: false, + }); + useUserPrivilegesMock.mockReturnValue(privileges); + }); + + afterEach(() => { + useUserPrivilegesMock.mockReset(); + useUserPrivilegesMock.mockImplementation(getUserPrivilegesMockDefaultValue); + }); + + it('should render form in view mode', () => { + render(); + + expectIsViewOnly(renderResult.getByTestId(testSubj.form)); + }); + + it('should not include the Save button', () => { + render(); + + expect(renderResult.queryByTestId('policyDetailsSaveButton')).toBeNull(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.tsx index bd7ecc6529c65..3ee9506e6ad44 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.tsx @@ -165,6 +165,7 @@ export const PolicySettingsLayout = memo(({ policy: _ color="text" onClick={handleCancelOnClick} data-test-subj="policyDetailsCancelButton" + disabled={isUpdating} > { ], "params": Array [ Object { - "description": "The number of hits to retrieve for each query.", + "description": "The number of documents to pass to the configured actions when the threshold condition is met.", "name": "size", }, Object { - "description": "An array of values to use as the threshold. 'between' and 'notBetween' require two values.", + "description": "An array of rule threshold values. For between and notBetween thresholds, there are two values.", "name": "threshold", }, Object { - "description": "A function to determine if the threshold was met.", + "description": "The comparison function for the threshold.", "name": "thresholdComparator", }, Object { - "description": "Serialized search source fields used to fetch the documents from Elasticsearch.", + "description": "The query definition, which uses KQL or Lucene to fetch the documents from Elasticsearch.", "name": "searchConfiguration", }, Object { @@ -92,7 +92,7 @@ describe('ruleType', () => { "name": "esQuery", }, Object { - "description": "The index the query was run against.", + "description": "The indices the rule queries.", "name": "index", }, ], diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts index 214d2ee4b764e..ef1008f360c8c 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts @@ -78,7 +78,7 @@ export function getRuleType( const actionVariableContextIndexLabel = i18n.translate( 'xpack.stackAlerts.esQuery.actionVariableContextIndexLabel', { - defaultMessage: 'The index the query was run against.', + defaultMessage: 'The indices the rule queries.', } ); @@ -92,7 +92,8 @@ export function getRuleType( const actionVariableContextSizeLabel = i18n.translate( 'xpack.stackAlerts.esQuery.actionVariableContextSizeLabel', { - defaultMessage: 'The number of hits to retrieve for each query.', + defaultMessage: + 'The number of documents to pass to the configured actions when the threshold condition is met.', } ); @@ -100,14 +101,14 @@ export function getRuleType( 'xpack.stackAlerts.esQuery.actionVariableContextThresholdLabel', { defaultMessage: - "An array of values to use as the threshold. 'between' and 'notBetween' require two values.", + 'An array of rule threshold values. For between and notBetween thresholds, there are two values.', } ); const actionVariableContextThresholdComparatorLabel = i18n.translate( 'xpack.stackAlerts.esQuery.actionVariableContextThresholdComparatorLabel', { - defaultMessage: 'A function to determine if the threshold was met.', + defaultMessage: 'The comparison function for the threshold.', } ); @@ -122,7 +123,7 @@ export function getRuleType( 'xpack.stackAlerts.esQuery.actionVariableContextSearchConfigurationLabel', { defaultMessage: - 'Serialized search source fields used to fetch the documents from Elasticsearch.', + 'The query definition, which uses KQL or Lucene to fetch the documents from Elasticsearch.', } );