AWS - Security Hub v2

Use the AWS Security Hub V2 integration to import, manage, and retrieve unified security and compliance findings across your cloud environments.

IT Services · AWS - Security Hub

Details

IDAWS - Security Hub v2
ProviderAmazon
CategoryIT Services
From Version6.10.0
Docker Imagedemisto/boto3py3:1.0.0.11314142
Supported ModulesAgentix XSIAM

README

Unified security and compliance findings management using the AWS Security Hub V2 API.
This integration was integrated and tested with the AWS Security Hub V2 API.

Prerequisites

  • AWS Security Hub V2 must be enabled in the target AWS account and region. You can enable it from the AWS console or with the aws-securityhub-v2-security-hub-enable command.
  • AWS credentials (an access key/secret key pair or an assumable IAM role) with the required Security Hub V2 permissions:
    • securityhub:EnableSecurityHubV2
    • securityhub:DisableSecurityHubV2
    • securityhub:GetFindingsV2
    • securityhub:BatchUpdateFindingsV2

Configure AWS - Security Hub v2 in Cortex

Parameter Description Required
AWS Default Region   True
Access Key The AWS Access Key ID (username) and Secret Access Key (password) paired together. If a ‘Role Arn’ is also provided, these credentials will be used to call AWS STS AssumeRole to obtain temporary credentials. False
Role Arn The full ARN of the role to assume via AWS STS, for example ‘arn:aws:iam::123456789012:role/MyRole’. False
Role Session Name The role session name to use for authentication. False
Role Session Duration The maximum role session duration, in seconds. False
Timeout The time in seconds till a timeout exception is reached. You can specify just the read timeout (for example 60) or also the connect timeout followed after a comma (for example 60,10). If a connect timeout is not specified, a default of 10 seconds will be used. False
Retries The maximum number of retry attempts when connection or throttling errors are encountered. Set to 0 to disable retries. Note: Increasing the number of retries will increase the execution time. False
PrivateLink service URL.   False
STS PrivateLink URL.   False
Trust any certificate (not secure)   False
Use system proxy settings   False
Fetch incidents   False
Incident type   False
First fetch time The time range to consider for the initial data fetch, in the format <number> <unit> (for example, 3 days, 12 hours, 7 minutes). False
Maximum number of incidents per fetch The maximum number of findings to fetch per cycle. The maximum is 100. False
Minimum severity to fetch The minimum severity of findings to fetch, based on the OCSF severity_id. Findings with this severity or higher are fetched. Leave empty to fetch all severities. False
Additional fetch filters The extra string filters used to narrow the fetch, in the same format as the string_filters command argument: “field_name=<OCSF field>,value=<value>,comparison=<comparison>”, multiple entries separated by “;”. All entries are combined with the time and severity filters using AND. Defaults to excluding closed findings (status Resolved or Suppressed): “field_name=status,value=Resolved,comparison=NOT_EQUALS;field_name=status,value=Suppressed,comparison=NOT_EQUALS”; clear or edit this value to fetch closed findings. False
Incident Mirroring Direction The direction to mirror the finding: Incoming (from AWS - Security Hub to Cortex), Outgoing (from Cortex to AWS - Security Hub), or Incoming And Outgoing (from/to Cortex and AWS - Security Hub). False
Resolve finding of closed incident from Cortex XSOAR in AWS Security Hub Whether closing an incident in Cortex sets the corresponding finding’s status to Resolved in AWS Security Hub (applies to outgoing mirroring). False

Commands

You can execute these commands from the CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.

aws-securityhub-v2-security-hub-enable


Enables AWS Security Hub V2 for the configured account and region. Required IAM Permission: securityhub:EnableSecurityHubV2.

Base Command

aws-securityhub-v2-security-hub-enable

Input

Argument Name Description Required
tags The tags to assign to the Security Hub V2 resource, in the format: key=key1,value=value1;key=key2,value=value2. Optional

Context Output

Path Type Description
AWS.SecurityHubV2.EnableHubV2.HubV2Arn String The ARN of the enabled Security Hub V2 resource.

Command example

!aws-securityhub-v2-security-hub-enable tags=key=env,value=prod

Context Example

{
    "AWS": {
        "SecurityHubV2": {
            "EnableHubV2": {
                "HubV2Arn": "arn:aws:securityhub:us-east-1:123456789012:hub/v2/default"
            }
        }
    }
}

Human Readable Output

AWS Security Hub V2 successfully enabled.

aws-securityhub-v2-security-hub-disable


Disables AWS Security Hub V2 for the configured account and region. Required IAM Permission: securityhub:DisableSecurityHubV2.

Base Command

aws-securityhub-v2-security-hub-disable

Input

There are no input arguments for this command.

Command example


#### Human Readable Output

>AWS Security Hub V2 successfully disabled.

### aws-securityhub-v2-findings-get

***
Retrieves a list of OCSF-formatted findings from AWS Security Hub V2. Required IAM Permission: securityhub:GetFindingsV2.

#### Base Command

`aws-securityhub-v2-findings-get`

#### Input

| **Argument Name** | **Description** | **Required** |
| --- | --- | --- |
| string_filters | The string field filters. Each entry: "field_name=&lt;OCSF field&gt;,value=&lt;value&gt;,comparison=&lt;EQUALS\|PREFIX\|NOT_EQUALS\|PREFIX_NOT_EQUALS\|CONTAINS_WORD&gt;", multiple entries separated by ";". Comparison defaults to EQUALS. For substring matching use CONTAINS_WORD (CONTAINS/NOT_CONTAINS are not supported by this API). Example: field_name=severity,value=High,comparison=EQUALS;field_name=finding_info.title,value=root,comparison=CONTAINS_WORD. | Optional |
| date_filters | The date field filters. Each entry must use EITHER an absolute range ("field_name=&lt;OCSF field&gt;,start=&lt;ISO8601&gt;,end=&lt;ISO8601&gt;" - both start and end are required) OR a relative DateRange ("field_name=&lt;OCSF field&gt;,value=&lt;number&gt;,unit=&lt;unit&gt;,comparison=&lt;comparison&gt;" - value is required, unit defaults to DAYS, comparison is optional). "days=&lt;number&gt;" is accepted as a shorthand for "value=&lt;number&gt;,unit=DAYS". Multiple entries separated by ";". Examples: field_name=finding_info.created_time_dt,start=2024-01-01T00:00:00Z,end=2024-02-01T00:00:00Z OR field_name=finding_info.modified_time_dt,value=7,unit=DAYS OR field_name=finding_info.modified_time_dt,days=7. | Optional |
| boolean_filters | The boolean field filters. Each entry: "field_name=&lt;OCSF field&gt;,value=&lt;true\|false&gt;", multiple entries separated by ";". | Optional |
| number_filters | The number field filters. Each entry: "field_name=&lt;OCSF field&gt;,&lt;operator&gt;=&lt;number&gt;" where operator is one of eq/gt/gte/lt/lte. Multiple operators may be combined in a single entry, and multiple entries are separated by ";". Examples: field_name=severity_id,gte=4 OR field_name=severity_id,gte=4,lte=6. | Optional |
| map_filters | The map field filters. Each entry: "field_name=&lt;OCSF field&gt;,key=&lt;key&gt;,value=&lt;value&gt;,comparison=&lt;EQUALS\|NOT_EQUALS&gt;", multiple entries separated by ";". Comparison defaults to EQUALS. | Optional |
| ip_filters | The IP field filters. Each entry: "field_name=&lt;field&gt;,cidr=&lt;IP address&gt;", multiple entries separated by ";". Allowed field_name values: evidences.src_endpoint.ip, evidences.dst_endpoint.ip. The cidr value must be a plain IPv4 or IPv6 address (CIDR ranges like 10.0.0.0/8 are not accepted). Example: field_name=evidences.src_endpoint.ip,cidr=10.0.0.1. | Optional |
| filter_operator | The logical operator used to combine the filter conditions within the composite filter. Possible values are: AND, OR. Default is AND. | Optional |
| sort_field | The finding field to sort the results by. | Optional |
| sort_order | The order to sort the results by. Possible values are: asc, desc. | Optional |
| limit | The maximum number of findings to return. Default is 50. | Optional |
| next_token | The pagination token returned from a previous request, used to retrieve the next set of results. | Optional |

#### Context Output

| **Path** | **Type** | **Description** |
| --- | --- | --- |
| AWS.SecurityHubV2.Findings | Unknown | The list of OCSF-formatted findings returned by Security Hub V2. Each finding is a free-form OCSF object containing fields such as metadata, finding_info, severity, status, cloud, resources, and time. |
| AWS.SecurityHubV2.FindingsNextToken | String | The pagination token to use when requesting the next set of findings. |

#### Command example

```!aws-securityhub-v2-findings-get string_filters="field_name=severity,value=High,comparison=EQUALS" limit=1```

#### Context Example

```json
{
    "AWS": {
        "SecurityHubV2": {
            "Findings": [
                {
                    "metadata": {
                        "uid": "uid"
                    },
                    "class_name": "Compliance Finding",
                    "severity": "High",
                    "status": "New",
                    "resources": [
                        {
                            "uid": "arn:aws:s3:::my-example-bucket"
                        }
                    ]
                }
            ],
            "FindingsNextToken": "eyJuZXh0IjoxfQ=="
        }
    }
}

Human Readable Output

AWS Security Hub V2 Findings

uid severity status class_name resource_uid
uid High New Compliance Finding arn:aws:s3:::my-example-bucket

aws-securityhub-v2-findings-batch-update


Updates one or more AWS Security Hub V2 findings in a single batch request. Findings are targeted by metadata_uids and/or finding_identifiers. Required IAM Permission: securityhub:BatchUpdateFindingsV2.

Base Command

aws-securityhub-v2-findings-batch-update

Input

Argument Name Description Required
metadata_uids A comma-separated list of OCSF finding metadata UIDs to update. Each UID must be a 64-character lowercase hexadecimal string (pattern ^[0-9a-z]{64}$), exactly as returned in the metadata.uid field by aws-securityhub-v2-findings-get. Optional
finding_identifiers The composite finding identifiers to update. Each entry: “cloud_account_uid=<id>,finding_info_uid=<id>,metadata_product_uid=<id>”, multiple entries separated by “;”. Optional
comment The reason for updating the findings. Optional
severity_id The new OCSF severity ID to assign to the findings (1=Informational, 2=Low, 3=Medium, 4=High, 5=Critical, 6=Fatal). Possible values are: 1, 2, 3, 4, 5, 6. Optional
status_id The new OCSF status ID to assign to the findings (1=New, 2=In Progress, 3=Suppressed, 4=Resolved). Possible values are: 1, 2, 3, 4. Optional

Context Output

Path Type Description
AWS.SecurityHubV2.BatchUpdateFindings.ProcessedFindings Unknown The list of findings that were successfully updated.
AWS.SecurityHubV2.BatchUpdateFindings.UnprocessedFindings Unknown The list of findings that could not be updated, including the error for each.

get-remote-data


Returns the updated data of a single mirrored AWS Security Hub V2 finding. This command is used for mirroring and is not intended to be run manually.

Base Command

get-remote-data

Input

Argument Name Description Required
id The finding metadata UID to retrieve. Required
lastUpdate The date string in local time representing the last time the incident was updated. Optional

get-mapping-fields


Returns the list of fields available for outgoing mirroring. This command is used for mirroring and is not intended to be run manually.

Base Command

get-mapping-fields

Input

There are no input arguments for this command.

update-remote-system


Pushes local (Cortex XSOAR) incident changes to the corresponding AWS Security Hub V2 finding. This command is used for mirroring and is not intended to be run manually.

Base Command

update-remote-system

Input

Argument Name Description Required
remoteId The remote finding metadata UID to update. Optional

Incident Mirroring

You can enable incident mirroring between Cortex incidents and AWS - Security Hub v2 corresponding findings.
To set up the mirroring:

  1. Enable Fetching incidents in your instance configuration.
  2. In the Incident Mirroring Direction integration parameter, select in which direction the incidents should be mirrored:

    Option Description
    None Turns off incident mirroring.
    Incoming Any changes in AWS - Security Hub v2 findings (mirroring incoming fields) will be reflected in Cortex incidents.
    Outgoing Any changes in Cortex incidents will be reflected in AWS - Security Hub v2 findings (outgoing mirrored fields).
    Incoming And Outgoing Changes in Cortex incidents and AWS - Security Hub v2 findings will be reflected in both directions.

Newly fetched incidents will be mirrored in the chosen direction. However, this selection does not affect existing incidents.
Important Note: To ensure the mirroring works as expected, mappers are required, both for incoming and outgoing, to map the expected fields in Cortex and AWS - Security Hub v2.

Close synchronization

The integration syncs incident/finding closing in both directions:

Action Result Requires
Close an incident in Cortex XSOAR The finding is set to Resolved (status_id 4) in AWS Security Hub. Outgoing mirroring and the Resolve finding of closed incident from Cortex XSOAR in AWS Security Hub parameter enabled.
Resolve or Suppress a finding in AWS Security Hub The corresponding Cortex XSOAR incident is closed. Incoming mirroring.

Note: Reopening is not supported in either direction. Reopening a closed incident in Cortex XSOAR does not reopen the finding in AWS Security Hub, and reopening a resolved finding in AWS Security Hub does not reopen the corresponding Cortex XSOAR incident.

Configuration parameters

  • region — AWS Default Region (required)
  • credentials — Access Key
  • role_arn — Role Arn
  • role_session_name — Role Session Name
  • session_duration — Role Session Duration
  • timeout — Timeout
  • retries — Retries
  • endpoint_url — PrivateLink service URL.
  • sts_endpoint_url — STS PrivateLink URL.
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • isFetch — Fetch incidents
  • incidentFetchInterval — Incidents Fetch Interval
  • incidentType — Incident type
  • first_fetch — First fetch time
  • max_fetch — Maximum number of incidents per fetch
  • min_severity — Minimum severity to fetch
  • fetch_filters — Additional fetch filters
  • mirror_direction — Incident Mirroring Direction
  • resolve_finding — Resolve finding of closed incident from Cortex XSOAR in AWS Security Hub

Commands (7)

  • aws-securityhub-v2-findings-batch-update

    Updates one or more AWS Security Hub V2 findings in a single batch request. Findings are targeted by metadata_uids and/or finding_identifiers. Required IAM Permission: securityhub:BatchUpdateFindingsV2.

  • aws-securityhub-v2-findings-get

    Retrieves a list of OCSF-formatted findings from AWS Security Hub V2. Required IAM Permission: securityhub:GetFindingsV2.

  • aws-securityhub-v2-security-hub-disable

    Disables AWS Security Hub V2 for the configured account and region. Required IAM Permission: securityhub:DisableSecurityHubV2.

  • aws-securityhub-v2-security-hub-enable

    Enables AWS Security Hub V2 for the configured account and region. Required IAM Permission: securityhub:EnableSecurityHubV2.

  • get-mapping-fields

    Returns the list of fields available for outgoing mirroring. This command is used for mirroring and is not intended to be run manually.

  • get-remote-data

    Returns the updated data of a single mirrored AWS Security Hub V2 finding. This command is used for mirroring and is not intended to be run manually.

  • update-remote-system

    Pushes local (Cortex XSOAR) incident changes to the corresponding AWS Security Hub V2 finding. This command is used for mirroring and is not intended to be run manually.

from datetime import datetime, UTC

import demistomock as demisto
import pytest
from AWS_SecurityHub_V2 import (
    _compute_fetch_boundary,
    _query_findings_page,
    build_close_entries,
    build_fetch_filters,
    handle_client_error,
    disable_security_hub_command,
    enable_security_hub_command,
    fetch_incidents,
    filter_new_findings,
    test_module as run_test_module,
    findings_batch_update_command,
    findings_get_command,
    findings_to_incidents,
    generate_filters_for_get_findings,
    get_mapping_fields_command,
    get_remote_data_command,
    parse_date_filters,
    parse_filter_entries,
    parse_filters,
    parse_finding_identifiers,
    parse_tags,
    update_remote_system_command,
)
from CommonServerPython import DemistoException, IncidentStatus


def test_parse_tags():
    """
    Given: A string of key/value tag pairs separated by ';'.
    When: parse_tags is called.
    Then: It returns a flat {key: value} mapping as required by the Security Hub V2 API.
    """
    result = parse_tags("key=env,value=prod;key=team,value=security")
    assert result == {"env": "prod", "team": "security"}


def test_parse_tags_empty():
    """
    Given: An empty tags string.
    When: parse_tags is called.
    Then: It returns an empty dict.
    """
    assert parse_tags("") == {}


def test_enable_security_hub_command_success(mocker):
    """
    Given: A mocked securityhub client and a tags argument.
    When: enable_security_hub_command is called.
    Then: It calls enable_security_hub_v2 with a flat Tags mapping and returns the V2 ARN.
    """
    mock_client = mocker.Mock()
    mock_client.enable_security_hub_v2.return_value = {
        "HubV2Arn": "dummy_arn",
        "ResponseMetadata": {"HTTPStatusCode": 200},
    }
    args = {"tags": "key=env,value=prod"}

    result = enable_security_hub_command(mock_client, args)

    call_kwargs = mock_client.enable_security_hub_v2.call_args[1]
    assert call_kwargs["Tags"] == {"env": "prod"}
    assert result.outputs_prefix == "AWS.SecurityHubV2.EnableHubV2"
    assert result.outputs == {"HubV2Arn": "dummy_arn"}
    assert "AWS Security Hub V2 successfully enabled." in result.readable_output


def test_enable_security_hub_command_no_tags(mocker):
    """
    Given: A mocked securityhub client and no tags argument.
    When: enable_security_hub_command is called.
    Then: It calls enable_security_hub_v2 without a Tags parameter (empty kwargs).
    """
    mock_client = mocker.Mock()
    mock_client.enable_security_hub_v2.return_value = {
        "HubV2Arn": "dummy_arn",
        "ResponseMetadata": {"HTTPStatusCode": 200},
    }

    result = enable_security_hub_command(mock_client, {})

    call_kwargs = mock_client.enable_security_hub_v2.call_args[1]
    assert "Tags" not in call_kwargs
    assert result.outputs["HubV2Arn"] == "dummy_arn"


def test_enable_security_hub_command_error(mocker):
    """
    Given: A mocked securityhub client whose enable_security_hub_v2 raises an exception.
    When: enable_security_hub_command is called.
    Then: The exception propagates to be handled in main().
    """
    mock_client = mocker.Mock()
    mock_client.enable_security_hub_v2.side_effect = Exception("AccessDenied")

    with pytest.raises(Exception, match="AccessDenied"):
        enable_security_hub_command(mock_client, {})


def test_disable_security_hub_command_success(mocker):
    """
    Given: A mocked securityhub client.
    When: disable_security_hub_command is called.
    Then: It calls disable_security_hub_v2 and returns a confirmation message.
    """
    mock_client = mocker.Mock()
    mock_client.disable_security_hub_v2.return_value = {"ResponseMetadata": {"HTTPStatusCode": 200}}

    result = disable_security_hub_command(mock_client, {})

    mock_client.disable_security_hub_v2.assert_called_once()
    assert "successfully disabled" in result.readable_output


def test_disable_security_hub_command_error(mocker):
    """
    Given: A mocked securityhub client whose disable_security_hub_v2 raises an exception.
    When: disable_security_hub_command is called.
    Then: The exception propagates to be handled in main().
    """
    mock_client = mocker.Mock()
    mock_client.disable_security_hub_v2.side_effect = Exception("AccessDenied")

    with pytest.raises(Exception, match="AccessDenied"):
        disable_security_hub_command(mock_client, {})


def test_parse_filters_string():
    """
    Given: A string_filters argument with two entries and a custom comparison.
    When: parse_filters is called for the "string" category.
    Then: It returns the StringFilters API structure with EQUALS as the default comparison.
    """
    result = parse_filters(
        "field_name=severity,value=High;field_name=finding_info.title,value=root,comparison=CONTAINS_WORD", "string"
    )
    assert result == [
        {"FieldName": "severity", "Filter": {"Value": "High", "Comparison": "EQUALS"}},
        {"FieldName": "finding_info.title", "Filter": {"Value": "root", "Comparison": "CONTAINS_WORD"}},
    ]


def test_parse_filters_number():
    """
    Given: A number_filters argument where the operator is the entry key.
    When: parse_filters is called for the "number" category.
    Then: It maps the operator key to the API key and converts the value to a number.
    """
    result = parse_filters("field_name=severity_id,gte=3", "number")
    assert result == [{"FieldName": "severity_id", "Filter": {"Gte": 3}}]


def test_parse_filters_number_multiple_operators():
    """
    Given: A single number_filters entry that specifies multiple operators (gte and lt).
    When: parse_filters is called for the "number" category.
    Then: All operators are mapped to their API keys within one Filter, with numeric values.
    """
    result = parse_filters("field_name=severity_id,gte=2,lt=5", "number")
    assert result == [{"FieldName": "severity_id", "Filter": {"Gte": 2, "Lt": 5}}]


def test_parse_filters_boolean():
    """
    Given: A boolean_filters argument.
    When: parse_filters is called for the "boolean" category.
    Then: It returns the BooleanFilters API structure with the value coerced to a bool.
    """
    result = parse_filters("field_name=compliance.assessments.meets_criteria,value=false", "boolean")
    assert result == [{"FieldName": "compliance.assessments.meets_criteria", "Filter": {"Value": False}}]


def test_parse_filters_map():
    """
    Given: A map_filters argument with key/value and no explicit comparison.
    When: parse_filters is called for the "map" category.
    Then: It returns the MapFilters API structure with EQUALS as the default comparison.
    """
    result = parse_filters("field_name=resources.tags,key=env,value=prod", "map")
    assert result == [{"FieldName": "resources.tags", "Filter": {"Key": "env", "Value": "prod", "Comparison": "EQUALS"}}]


def test_parse_filters_ip():
    """
    Given: An ip_filters argument.
    When: parse_filters is called for the "ip" category.
    Then: It returns the IpFilters API structure.
    """
    result = parse_filters("field_name=evidences.src_endpoint.ip,cidr=10.0.0.1", "ip")
    assert result == [{"FieldName": "evidences.src_endpoint.ip", "Filter": {"Cidr": "10.0.0.1"}}]


@pytest.mark.parametrize(
    "filters_str, category, missing_field",
    [
        ("value=High", "string", "field_name"),  # missing field_name
        ("field_name=severity", "string", "value"),  # missing required value
        ("field_name=severity_id", "number", "one of"),  # number entry without any operator
    ],
)
def test_parse_filters_raises_on_missing_fields(mocker, filters_str, category, missing_field):
    """
    Given: A filter entry missing field_name, a required key, or all require_any keys.
    When: parse_filters is called.
    Then: A DemistoException is raised naming the missing field(s).
    """
    mocker.patch.object(demisto, "error")
    with pytest.raises(DemistoException, match=missing_field):
        parse_filters(filters_str, category)


def test_parse_filter_entries_multiple():
    """
    Given: A ";"-separated string of two entries, one with an extra comparison key.
    When: parse_filter_entries is called.
    Then: Each entry is parsed into a lower-cased key/value dict.
    """
    result = parse_filter_entries("field_name=severity,value=High;field_name=status,value=New,comparison=NOT_EQUALS")
    assert result == [
        {"field_name": "severity", "value": "High"},
        {"field_name": "status", "value": "New", "comparison": "NOT_EQUALS"},
    ]


def test_parse_filter_entries_empty_and_malformed():
    """
    Given: An empty string, blank entries, and pairs without '='.
    When: parse_filter_entries is called.
    Then: Empty/blank entries yield nothing and pairs without '=' are ignored.
    """
    assert parse_filter_entries("") == []
    assert parse_filter_entries("  ;  ") == []
    # "noequals" has no '=' and is dropped; the valid pair is still parsed.
    assert parse_filter_entries("field_name=severity,noequals") == [{"field_name": "severity"}]


def test_generate_filters_for_get_findings_empty():
    """
    Given: Args with no filter conditions.
    When: generate_filters_for_get_findings is called.
    Then: It returns None (no filter to apply).
    """
    assert generate_filters_for_get_findings({}) is None


def test_generate_filters_for_get_findings_composite():
    """
    Given: Args with string, number, and date filters.
    When: generate_filters_for_get_findings is called.
    Then: It builds a single composite Filters entry with the conditions and its filter operator
          (no top-level CompositeOperator, since only one composite is built).
    """
    args = {
        "string_filters": "field_name=severity,value=High",
        "number_filters": "field_name=severity_id,gte=4",
        "date_filters": "field_name=finding_info.created_time_dt,start=2024-01-01T00:00:00Z,end=2024-02-01T00:00:00Z",
    }
    result = generate_filters_for_get_findings(args)
    assert "CompositeOperator" not in result
    composite = result["CompositeFilters"][0]
    assert composite["Operator"] == "AND"
    assert composite["StringFilters"] == [{"FieldName": "severity", "Filter": {"Value": "High", "Comparison": "EQUALS"}}]
    assert composite["NumberFilters"] == [{"FieldName": "severity_id", "Filter": {"Gte": 4}}]
    assert composite["DateFilters"] == [
        {"FieldName": "finding_info.created_time_dt", "Filter": {"Start": "2024-01-01T00:00:00Z", "End": "2024-02-01T00:00:00Z"}}
    ]


def test_findings_get_command_success(mocker):
    """
    Given: A mocked securityhub client returning findings and filter/sort/limit args.
    When: findings_get_command is called.
    Then: It passes the built Filters, SortCriteria, and MaxResults and returns findings + next token.
    """
    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {
        "Findings": [
            {
                "metadata": {"uid": "f-1"},
                "severity": "High",
                "status": "New",
                "class_name": "Compliance Finding",
                "resources": [{"uid": "res-1"}, {"uid": "res-2"}, {"name": "no-uid"}],
            }
        ],
        "NextToken": "tok-123",
    }
    args = {"string_filters": "field_name=severity,value=High", "sort_field": "time", "sort_order": "desc", "limit": "1"}

    result = findings_get_command(mock_client, args)

    call_kwargs = mock_client.get_findings_v2.call_args[1]
    assert call_kwargs["MaxResults"] == 1
    assert call_kwargs["SortCriteria"] == [{"Field": "time", "SortOrder": "desc"}]
    assert call_kwargs["Filters"]["CompositeFilters"][0]["StringFilters"][0]["FieldName"] == "severity"
    findings_output = result.outputs["AWS.SecurityHubV2.Findings(val.metadata.uid && val.metadata.uid == obj.metadata.uid)"]
    assert findings_output[0]["metadata"]["uid"] == "f-1"
    assert result.outputs["AWS.SecurityHubV2(true)"] == {"FindingsNextToken": "tok-123"}
    # Readable table surfaces uid/severity/status/class_name and joins only resource entries that have a uid.
    assert "res-1, res-2" in result.readable_output
    assert "f-1" in result.readable_output


def test_findings_get_command_no_results(mocker):
    """
    Given: A mocked securityhub client returning no findings.
    When: findings_get_command is called.
    Then: It returns a 'No findings were found.' readable output.
    """
    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {"Findings": []}

    result = findings_get_command(mock_client, {})

    assert result.readable_output == "No findings were found."


def test_findings_get_command_error(mocker):
    """
    Given: A mocked securityhub client whose get_findings_v2 raises an exception.
    When: findings_get_command is called.
    Then: The exception propagates to be handled in main().
    """
    mock_client = mocker.Mock()
    mock_client.get_findings_v2.side_effect = Exception("AccessDenied")

    with pytest.raises(Exception, match="AccessDenied"):
        findings_get_command(mock_client, {})


def test_parse_finding_identifiers():
    """
    Given: A finding_identifiers argument with one complete triple.
    When: parse_finding_identifiers is called.
    Then: It returns the FindingIdentifiers API structure.
    """
    result = parse_finding_identifiers("cloud_account_uid=123456789012,finding_info_uid=f-1,metadata_product_uid=p-1")
    assert result == [{"CloudAccountUid": "123456789012", "FindingInfoUid": "f-1", "MetadataProductUid": "p-1"}]


def test_parse_finding_identifiers_incomplete(mocker):
    """
    Given: A finding_identifiers entry missing a required key.
    When: parse_finding_identifiers is called.
    Then: A DemistoException is raised naming the missing field.
    """
    mocker.patch.object(demisto, "error")
    with pytest.raises(DemistoException, match="metadata_product_uid"):
        parse_finding_identifiers("cloud_account_uid=123,finding_info_uid=f-1")


def _finding(uid, created_time, severity_id=3, title=None):
    return {
        "metadata": {"uid": uid},
        "finding_info": {"created_time_dt": created_time, "title": title},
        "severity_id": severity_id,
    }


def test_filter_new_findings_drops_stale_and_seen():
    """
    Given: Findings created before the boundary (stale), exactly at it (one already seen), and after it.
    When: filter_new_findings is called with the boundary and the already-seen uid.
    Then: Only the not-yet-ingested findings (the unseen boundary one and the newer one) are returned.
    """
    last_fetch = "2024-01-02T00:00:00Z"
    findings = [
        _finding("stale", "2024-01-01T00:00:00Z"),  # before boundary -> dropped
        _finding("seen", last_fetch),  # at boundary, already seen -> dropped
        _finding("boundary-new", last_fetch),  # at boundary, not seen -> kept
        _finding("newer", "2024-01-03T00:00:00Z"),  # after boundary -> kept
    ]

    result = filter_new_findings(findings, last_fetch, fetched_ids=["seen"])

    assert [f["metadata"]["uid"] for f in result] == ["boundary-new", "newer"]


def test_filter_new_findings_no_boundary_keeps_all():
    """
    Given: An empty last_fetch (first run) and two findings.
    When: filter_new_findings is called.
    Then: All findings are kept (no boundary to dedup against).
    """
    findings = [_finding("a", "2024-01-01T00:00:00Z"), _finding("b", "2024-01-02T00:00:00Z")]

    result = filter_new_findings(findings, last_fetch="", fetched_ids=[])

    assert [f["metadata"]["uid"] for f in result] == ["a", "b"]


def test_findings_to_incidents_builds_and_tags(mocker):
    """
    Given: A finding and a mirror direction.
    When: findings_to_incidents is called.
    Then: An incident is built with the mapped XSOAR severity, and the finding is stamped for mirroring.
    """
    from AWS_SecurityHub_V2 import IncidentSeverity

    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    findings = [_finding("uid-1", "2024-01-01T00:00:00Z", severity_id=3, title="My Finding")]

    incidents = findings_to_incidents(findings, mirror_direction="Both")

    assert len(incidents) == 1
    incident = incidents[0]
    assert incident["name"] == "My Finding"
    assert incident["occurred"] == "2024-01-01T00:00:00Z"
    assert incident["severity"] == IncidentSeverity.MEDIUM  # OCSF severity_id 3 -> XSOAR Medium
    # The finding was stamped with mirroring metadata that the rawJSON carries to the mapper.
    assert findings[0]["mirror_direction"] == "Both"
    assert findings[0]["mirror_instance"] == "instance-1"


def test_findings_to_incidents_no_mirror_leaves_finding_untagged():
    """
    Given: A finding and no mirror direction.
    When: findings_to_incidents is called.
    Then: An incident is built but no mirroring metadata is stamped, and the name falls back to the uid.
    """
    findings = [_finding("uid-1", "2024-01-01T00:00:00Z", title=None)]

    incidents = findings_to_incidents(findings, mirror_direction=None)

    assert incidents[0]["name"] == "uid-1"  # no title -> falls back to uid
    assert "mirror_direction" not in findings[0]
    assert "mirror_instance" not in findings[0]


def test_findings_batch_update_command_success(mocker):
    """
    Given: A mocked securityhub client and metadata_uids with update fields.
    When: findings_batch_update_command is called.
    Then: It passes MetadataUids/Comment/SeverityId/StatusId and returns processed/unprocessed.
    """
    mock_client = mocker.Mock()
    mock_client.batch_update_findings_v2.return_value = {
        "ProcessedFindings": [{"MetadataUid": "u-1", "metadata": {"uid": "u-1"}}],
        "UnprocessedFindings": [{"MetadataUid": "u-9", "ErrorCode": "AccessDenied"}],
    }
    args = {"metadata_uids": "u-1,u-2", "comment": "triage", "severity_id": "4", "status_id": "2"}

    result = findings_batch_update_command(mock_client, args)

    call_kwargs = mock_client.batch_update_findings_v2.call_args[1]
    assert call_kwargs["MetadataUids"] == ["u-1", "u-2"]
    assert call_kwargs["Comment"] == "triage"
    assert call_kwargs["SeverityId"] == 4
    assert call_kwargs["StatusId"] == 2
    assert result.outputs_prefix == "AWS.SecurityHubV2.BatchUpdateFindings"
    assert result.outputs["ProcessedFindings"][0]["metadata"]["uid"] == "u-1"
    # Readable output lists the processed and unprocessed metadata UIDs (not just counts).
    assert "u-1" in result.readable_output
    assert "u-9" in result.readable_output


def test_findings_batch_update_command_with_identifiers(mocker):
    """
    Given: A mocked securityhub client and finding_identifiers targeting.
    When: findings_batch_update_command is called.
    Then: It passes the FindingIdentifiers structure to the API.
    """
    mock_client = mocker.Mock()
    mock_client.batch_update_findings_v2.return_value = {"ProcessedFindings": [], "UnprocessedFindings": []}
    args = {
        "finding_identifiers": "cloud_account_uid=123,finding_info_uid=f-1,metadata_product_uid=p-1",
        "status_id": "4",
    }

    findings_batch_update_command(mock_client, args)

    call_kwargs = mock_client.batch_update_findings_v2.call_args[1]
    assert call_kwargs["FindingIdentifiers"] == [{"CloudAccountUid": "123", "FindingInfoUid": "f-1", "MetadataProductUid": "p-1"}]


def test_findings_batch_update_command_no_target():
    """
    Given: Args with neither metadata_uids nor finding_identifiers.
    When: findings_batch_update_command is called.
    Then: It raises a DemistoException requiring a targeting argument.
    """
    with pytest.raises(DemistoException, match="metadata_uids.*finding_identifiers"):
        findings_batch_update_command(None, {"comment": "x"})


def test_findings_batch_update_command_error(mocker):
    """
    Given: A mocked securityhub client whose batch_update_findings_v2 raises an exception.
    When: findings_batch_update_command is called.
    Then: The exception propagates to be handled in main().
    """
    mock_client = mocker.Mock()
    mock_client.batch_update_findings_v2.side_effect = Exception("AccessDenied")

    with pytest.raises(Exception, match="AccessDenied"):
        findings_batch_update_command(mock_client, {"metadata_uids": "u-1"})


@pytest.mark.parametrize(
    "filters_str,expected_filter",
    [
        # Absolute form: both start and end.
        (
            "field_name=finding_info.created_time_dt,start=2024-01-01T00:00:00Z,end=2024-02-01T00:00:00Z",
            {"Start": "2024-01-01T00:00:00Z", "End": "2024-02-01T00:00:00Z"},
        ),
        # Relative "days" shorthand -> DateRange with Unit defaulting to DAYS.
        ("field_name=finding_info.created_time_dt,days=7", {"DateRange": {"Value": 7, "Unit": "DAYS"}}),
        # Relative explicit value/unit.
        ("field_name=finding_info.created_time_dt,value=14,unit=DAYS", {"DateRange": {"Value": 14, "Unit": "DAYS"}}),
        # Relative with an explicit comparison.
        (
            "field_name=finding_info.created_time_dt,value=7,unit=DAYS,comparison=GREATER_THAN",
            {"DateRange": {"Value": 7, "Unit": "DAYS", "Comparison": "GREATER_THAN"}},
        ),
    ],
)
def test_parse_date_filters_builds_absolute_and_relative(filters_str, expected_filter):
    """
    Given: A date_filters entry in the absolute ({Start,End}) or relative (DateRange) form.
    When: parse_date_filters is called.
    Then: It builds the matching {FieldName, Filter} structure.
    """
    assert parse_date_filters(filters_str) == [{"FieldName": "finding_info.created_time_dt", "Filter": expected_filter}]


@pytest.mark.parametrize(
    "filters_str,match",
    [
        # start without end is invalid (oneOf requires both, or a DateRange).
        ("field_name=finding_info.created_time_dt,start=2024-01-01T00:00:00Z", "requires either the relative 'DateRange' form"),
        # mixing the relative and absolute forms is invalid.
        ("field_name=finding_info.created_time_dt,days=7,start=2024-01-01T00:00:00Z", "not both"),
    ],
)
def test_parse_date_filters_invalid_combinations_raise(filters_str, match):
    """
    Given: A date_filters entry that is incomplete or mixes the two mutually exclusive forms.
    When: parse_date_filters is called.
    Then: It raises a DemistoException explaining the valid forms.
    """
    with pytest.raises(DemistoException, match=match):
        parse_date_filters(filters_str)


def test_parse_date_filters_skips_entry_without_field_name(mocker):
    """
    Given: A date_filters string whose entry has no field_name.
    When: parse_date_filters is called.
    Then: The entry is skipped and an empty list is returned (no exception raised).
    """
    mocker.patch.object(demisto, "error")
    assert parse_date_filters("days=7") == []


def test_build_fetch_filters_time_only():
    """
    Given: A bounded fetch window (start and end) with no severity or additional filters.
    When: build_fetch_filters is called.
    Then: It builds a single composite with only a bounded created_time_dt DateFilter (Start and End)
          and an AND filter operator (no top-level CompositeOperator, since only one composite is built).
    """
    result = build_fetch_filters("2024-01-01T00:00:00.000Z", "2024-01-02T00:00:00.000Z", None, None)
    composite = result["CompositeFilters"][0]
    assert "CompositeOperator" not in result
    assert composite["Operator"] == "AND"
    assert composite["DateFilters"] == [
        {
            "FieldName": "finding_info.created_time_dt",
            "Filter": {"Start": "2024-01-01T00:00:00.000Z", "End": "2024-01-02T00:00:00.000Z"},
        }
    ]
    assert "NumberFilters" not in composite
    assert "StringFilters" not in composite


def test_build_fetch_filters_with_severity_and_additional():
    """
    Given: A fetch window with a minimum severity and additional string filters.
    When: build_fetch_filters is called.
    Then: It adds a severity_id Gte NumberFilter and the parsed StringFilters.
    """
    result = build_fetch_filters(
        "2024-01-01T00:00:00.000Z",
        "2024-01-02T00:00:00.000Z",
        "High",
        "field_name=cloud.region,value=us-east-1",
    )
    composite = result["CompositeFilters"][0]
    assert composite["NumberFilters"] == [{"FieldName": "severity_id", "Filter": {"Gte": 4}}]
    assert composite["StringFilters"] == [{"FieldName": "cloud.region", "Filter": {"Value": "us-east-1", "Comparison": "EQUALS"}}]


def test_query_findings_page_fresh_query(mocker):
    """
    Given: Filters and a max_results with no next_token (a fresh page query).
    When: _query_findings_page is called.
    Then: It calls get_findings_v2 without a NextToken, passing MaxResults/Filters/SortCriteria, and
          returns the page findings and next token.
    """
    from AWS_SecurityHub_V2 import FETCH_SORT_CRITERIA

    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {"Findings": [{"metadata": {"uid": "uid-1"}}], "NextToken": "tok-2"}

    findings, next_token = _query_findings_page(mock_client, {"CompositeFilters": []}, 25, None)

    assert findings == [{"metadata": {"uid": "uid-1"}}]
    assert next_token == "tok-2"
    call_kwargs = mock_client.get_findings_v2.call_args[1]
    assert call_kwargs["MaxResults"] == 25
    assert call_kwargs["Filters"] == {"CompositeFilters": []}
    assert call_kwargs["SortCriteria"] == FETCH_SORT_CRITERIA
    assert "NextToken" not in call_kwargs


def test_query_findings_page_with_token(mocker):
    """
    Given: Filters, a max_results, and a next_token (continuing a previous page).
    When: _query_findings_page is called.
    Then: It forwards the NextToken and returns the page findings with a null next token when exhausted.
    """
    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {"Findings": [], "NextToken": None}

    findings, next_token = _query_findings_page(mock_client, {"CompositeFilters": []}, 10, "tok-prev")

    assert findings == []
    assert next_token is None
    assert mock_client.get_findings_v2.call_args[1]["NextToken"] == "tok-prev"


def test_query_findings_page_client_error_raises(mocker):
    """
    Given: A client whose get_findings_v2 raises a ClientError.
    When: _query_findings_page is called.
    Then: It surfaces the error message as a DemistoException.
    """

    class ClientError(Exception):
        def __init__(self, response):
            super().__init__(response.get("Error", {}).get("Message", ""))
            self.response = response

    mock_client = mocker.Mock()
    mock_client.exceptions.ClientError = ClientError
    mock_client.get_findings_v2.side_effect = ClientError({"Error": {"Code": "ValidationException", "Message": "bad filters"}})

    with pytest.raises(DemistoException, match="bad filters"):
        _query_findings_page(mock_client, {"CompositeFilters": []}, 10, None)


def test_handle_client_error_logs_and_raises(mocker):
    """
    Given: A ClientError carrying an AWS error code and message.
    When: handle_client_error is called with a caller context.
    Then: It logs the context/code/message via debug and re-raises the message as a DemistoException.
    """
    debug_mock = mocker.patch.object(demisto, "debug")

    class ClientError(Exception):
        def __init__(self, response):
            super().__init__(response.get("Error", {}).get("Message", ""))
            self.response = response

    error = ClientError({"Error": {"Code": "AccessDeniedException", "Message": "not authorized"}})

    with pytest.raises(DemistoException, match="not authorized"):
        handle_client_error(error, "Mirror-out: batch_update_findings_v2")

    logged = debug_mock.call_args[0][0]
    assert "Mirror-out: batch_update_findings_v2" in logged
    assert "AccessDeniedException" in logged
    assert "not authorized" in logged


def test_compute_fetch_boundary_advances_and_collects_tied_ids():
    """
    Given: New findings whose latest created_time_dt is shared by two findings.
    When: _compute_fetch_boundary is called.
    Then: last_fetch advances to that latest timestamp and fetched_ids contains every uid at it.
    """
    new_findings = [
        {"metadata": {"uid": "a"}, "finding_info": {"created_time_dt": "2024-01-01T00:00:00Z"}},
        {"metadata": {"uid": "b"}, "finding_info": {"created_time_dt": "2024-01-03T00:00:00Z"}},
        {"metadata": {"uid": "c"}, "finding_info": {"created_time_dt": "2024-01-03T00:00:00Z"}},
    ]

    last_fetch, fetched_ids = _compute_fetch_boundary(new_findings, "2024-01-01T00:00:00Z", ["old"])

    assert last_fetch == "2024-01-03T00:00:00Z"
    assert sorted(fetched_ids) == ["b", "c"]


def test_compute_fetch_boundary_no_findings_returns_unchanged():
    """
    Given: No new findings.
    When: _compute_fetch_boundary is called.
    Then: The current boundary and fetched_ids are returned unchanged.
    """
    last_fetch, fetched_ids = _compute_fetch_boundary([], "2024-01-01T00:00:00Z", ["keep"])

    assert last_fetch == "2024-01-01T00:00:00Z"
    assert fetched_ids == ["keep"]


def test_fetch_incidents_with_existing_last_fetch(mocker):
    """
    Given: A previous last_fetch window and a client returning two OCSF findings newer than it, with the
           window exhausted (no next token).
    When: fetch_incidents is called.
    Then: It builds incidents with mapped severity, sets last_fetch to the latest created time (no 1ms
          bump), records the boundary finding id in fetched_ids, and persists no next_token.
    """
    from AWS_SecurityHub_V2 import IncidentSeverity

    mocker.patch.object(demisto, "getLastRun", return_value={"last_fetch": "2024-01-01T00:00:00.000Z"})
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    set_last_run = mocker.patch.object(demisto, "setLastRun")
    incidents_mock = mocker.patch.object(demisto, "incidents")

    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {
        "Findings": [
            {
                "metadata": {"uid": "uid-1"},
                "severity_id": 4,
                "finding_info": {"title": "Finding One", "created_time_dt": "2024-01-01T10:00:00.000Z"},
            },
            {
                "metadata": {"uid": "uid-2"},
                "severity_id": 5,
                "finding_info": {"title": "Finding Two", "created_time_dt": "2024-01-01T12:00:00.000Z"},
            },
        ],
        "NextToken": None,
    }

    fetch_incidents(mock_client, {"max_fetch": 50, "min_severity": "High"})

    # Two incidents created with correct names and mapped severities.
    incidents = incidents_mock.call_args[0][0]
    assert len(incidents) == 2
    assert incidents[0]["name"] == "Finding One"
    assert incidents[0]["severity"] == IncidentSeverity.HIGH
    assert incidents[1]["severity"] == IncidentSeverity.CRITICAL
    assert incidents[1]["occurred"] == "2024-01-01T12:00:00.000Z"

    # last_fetch = latest created time (no bump); only the boundary id is stored; no token persisted (window done).
    last_run = set_last_run.call_args[0][0]
    assert last_run["last_fetch"] == "2024-01-01T12:00:00.000Z"
    assert last_run["fetched_ids"] == ["uid-2"]
    assert last_run["next_token"] is None

    # A single page is fetched (window exhausted), using Filters (not a NextToken) with a bounded window.
    assert mock_client.get_findings_v2.call_count == 1
    call_kwargs = mock_client.get_findings_v2.call_args[1]
    assert call_kwargs["MaxResults"] == 50
    assert "NextToken" not in call_kwargs
    composite = call_kwargs["Filters"]["CompositeFilters"][0]
    date_filter = composite["DateFilters"][0]["Filter"]
    assert date_filter["Start"] == "2024-01-01T00:00:00.000Z"
    assert "End" in date_filter
    # min_severity=High is translated to an OCSF severity_id >= 4 NumberFilter in the query.
    assert composite["NumberFilters"] == [{"FieldName": "severity_id", "Filter": {"Gte": 4}}]


def test_fetch_incidents_first_run_builds_filters_from_first_fetch(mocker):
    """
    Given: No existing last run (first run), a first_fetch of "3 days", a min_severity, and an
           additional string fetch filter.
    When: fetch_incidents is called.
    Then: The window Start is derived from first_fetch (not a saved boundary), the query has a bounded
          created_time_dt DateFilter (Start+End), the severity and additional string filters are applied,
          no NextToken is sent, and the new boundary is persisted from the returned finding.
    """

    # First run: getLastRun returns an empty object (no last_fetch/next_token/fetched_ids).
    mocker.patch.object(demisto, "getLastRun", return_value={})
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    set_last_run = mocker.patch.object(demisto, "setLastRun")
    incidents_mock = mocker.patch.object(demisto, "incidents")

    # Pin the first_fetch parse so the window Start is deterministic.
    fixed_start = datetime(2024, 3, 1, 0, 0, 0, tzinfo=UTC)
    mocker.patch("AWS_SecurityHub_V2.parse", return_value=fixed_start)

    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {
        "Findings": [
            {
                "metadata": {"uid": "uid-1"},
                "severity_id": 4,
                "finding_info": {"title": "First Run Finding", "created_time_dt": "2024-03-02T09:00:00.000Z"},
            }
        ],
        "NextToken": None,
    }

    fetch_incidents(
        mock_client,
        {
            "max_fetch": 50,
            "first_fetch": "3 days",
            "min_severity": "High",
            "fetch_filters": "field_name=status,value=Resolved,comparison=NOT_EQUALS",
        },
    )

    # One page fetched via Filters (not a NextToken), requesting max_fetch.
    assert mock_client.get_findings_v2.call_count == 1
    call_kwargs = mock_client.get_findings_v2.call_args[1]
    assert call_kwargs["MaxResults"] == 50
    assert "NextToken" not in call_kwargs

    # The window Start comes from the parsed first_fetch, and the window is bounded (Start+End).
    composite = call_kwargs["Filters"]["CompositeFilters"][0]
    date_filter = composite["DateFilters"][0]["Filter"]
    assert composite["DateFilters"][0]["FieldName"] == "finding_info.created_time_dt"
    assert date_filter["Start"] == fixed_start.isoformat()
    assert "End" in date_filter
    # min_severity=High -> severity_id >= 4, and the additional string filter is applied.
    assert composite["NumberFilters"] == [{"FieldName": "severity_id", "Filter": {"Gte": 4}}]
    assert composite["StringFilters"] == [{"FieldName": "status", "Filter": {"Value": "Resolved", "Comparison": "NOT_EQUALS"}}]

    # One incident created and the boundary is persisted from the returned finding.
    incidents = incidents_mock.call_args[0][0]
    assert len(incidents) == 1
    assert incidents[0]["name"] == "First Run Finding"
    last_run = set_last_run.call_args[0][0]
    assert last_run["last_fetch"] == "2024-03-02T09:00:00.000Z"
    assert last_run["fetched_ids"] == ["uid-1"]
    assert last_run["next_token"] is None


def test_fetch_incidents_continues_with_next_token(mocker):
    """
    Given: A previous run that left a next_token and the persisted filters used for that page.
    When: fetch_incidents is called.
    Then: It sends the NextToken together with the persisted Filters and advances last_fetch from the
          token page findings.
    """
    mocker.patch.object(
        demisto,
        "getLastRun",
        return_value={
            "last_fetch": "2024-01-01T00:00:00.000Z",
            "next_token": "tok-prev",
            "filters": {"CompositeFilters": []},
        },
    )
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    set_last_run = mocker.patch.object(demisto, "setLastRun")
    mocker.patch.object(demisto, "incidents")

    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {
        "Findings": [
            {
                "metadata": {"uid": "uid-3"},
                "severity_id": 2,
                "finding_info": {"title": "Finding Three", "created_time_dt": "2024-01-05T10:00:00.000Z"},
            }
        ],
        "NextToken": None,
    }

    fetch_incidents(mock_client, {"max_fetch": 10})

    call_kwargs = mock_client.get_findings_v2.call_args[1]
    assert call_kwargs["NextToken"] == "tok-prev"
    # The persisted filters are re-sent alongside the token to keep the page valid.
    assert call_kwargs["Filters"] == {"CompositeFilters": []}

    # The token page returns newer findings, so the boundary advances to that finding's created time.
    last_run = set_last_run.call_args[0][0]
    assert last_run["last_fetch"] == "2024-01-05T10:00:00.000Z"
    assert last_run["fetched_ids"] == ["uid-3"]
    assert last_run["next_token"] is None


def test_fetch_incidents_skips_already_fetched_ids(mocker):
    """
    Given: A previous run whose fetched_ids contains a finding at the boundary timestamp, and the API
           returns that same finding again (inclusive Start) plus a new one.
    When: fetch_incidents is called.
    Then: The already-fetched finding is skipped and only the new finding becomes an incident.
    """
    mocker.patch.object(
        demisto,
        "getLastRun",
        return_value={"last_fetch": "2024-01-01T10:00:00.000Z", "fetched_ids": ["uid-1"]},
    )
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    set_last_run = mocker.patch.object(demisto, "setLastRun")
    incidents_mock = mocker.patch.object(demisto, "incidents")

    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {
        "Findings": [
            {
                "metadata": {"uid": "uid-1"},  # already fetched at the boundary - must be skipped
                "severity_id": 4,
                "finding_info": {"title": "Old Finding", "created_time_dt": "2024-01-01T10:00:00.000Z"},
            },
            {
                "metadata": {"uid": "uid-2"},  # new finding
                "severity_id": 3,
                "finding_info": {"title": "New Finding", "created_time_dt": "2024-01-02T09:00:00.000Z"},
            },
        ],
        "NextToken": None,
    }

    fetch_incidents(mock_client, {"max_fetch": 50})

    incidents = incidents_mock.call_args[0][0]
    assert len(incidents) == 1
    assert incidents[0]["name"] == "New Finding"

    last_run = set_last_run.call_args[0][0]
    assert last_run["last_fetch"] == "2024-01-02T09:00:00.000Z"
    assert last_run["fetched_ids"] == ["uid-2"]


def test_fetch_incidents_invalid_next_token_raises(mocker):
    """
    Given: A stored next_token that the API rejects with a token-related ClientError.
    When: fetch_incidents is called.
    Then: It surfaces the error as a DemistoException (a single API call is made, no silent fallback).
    """

    class ClientError(Exception):
        def __init__(self, response):
            super().__init__(response.get("Error", {}).get("Message", ""))
            self.response = response

    mocker.patch.object(demisto, "getLastRun", return_value={"last_fetch": "2024-01-01T00:00:00.000Z", "next_token": "stale"})
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")

    mock_client = mocker.Mock()
    mock_client.exceptions.ClientError = ClientError
    token_error = ClientError({"Error": {"Code": "ValidationException", "Message": "The provided next token is invalid."}})
    mock_client.get_findings_v2.side_effect = token_error

    with pytest.raises(DemistoException, match="The provided next token is invalid."):
        fetch_incidents(mock_client, {"max_fetch": 50})

    # Only the token call is made; there is no silent fallback query.
    assert mock_client.get_findings_v2.call_count == 1
    assert "NextToken" in mock_client.get_findings_v2.call_args_list[0][1]


def test_fetch_incidents_no_results(mocker):
    """
    Given: A client returning no findings.
    When: fetch_incidents is called.
    Then: No incidents are created and last_fetch and fetched_ids are preserved.
    """
    mocker.patch.object(
        demisto,
        "getLastRun",
        return_value={"last_fetch": "2024-01-01T00:00:00.000Z", "fetched_ids": ["uid-x"]},
    )
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    set_last_run = mocker.patch.object(demisto, "setLastRun")
    incidents_mock = mocker.patch.object(demisto, "incidents")

    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {"Findings": []}

    fetch_incidents(mock_client, {"max_fetch": 50})

    assert incidents_mock.call_args[0][0] == []
    last_run = set_last_run.call_args[0][0]
    assert last_run["last_fetch"] == "2024-01-01T00:00:00.000Z"
    assert last_run["fetched_ids"] == ["uid-x"]
    assert last_run["next_token"] is None


@pytest.mark.parametrize(
    "mirror_direction,expected_dbot_direction",
    [
        ("Incoming", "In"),  # enrolled: rawJSON carries mirror metadata
        ("None", None),  # disabled: rawJSON carries no mirror metadata
    ],
)
def test_fetch_incidents_mirror_tagging(mocker, mirror_direction, expected_dbot_direction):
    """
    Given: A client returning one finding and a mirror_direction param (Incoming or None).
    When: fetch_incidents is called.
    Then: The incident rawJSON carries the mirror metadata only when mirroring is enabled.
    """
    import json

    mocker.patch.object(demisto, "getLastRun", return_value={"last_fetch": "2024-01-01T00:00:00.000Z"})
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    mocker.patch.object(demisto, "setLastRun")
    incidents_mock = mocker.patch.object(demisto, "incidents")

    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {
        "Findings": [
            {
                "metadata": {"uid": "uid-1"},
                "severity_id": 4,
                "finding_info": {"title": "t", "created_time_dt": "2024-01-02T00:00:00.000Z"},
            }
        ]
    }

    fetch_incidents(mock_client, {"max_fetch": 50, "mirror_direction": mirror_direction})

    raw = json.loads(incidents_mock.call_args[0][0][0]["rawJSON"])
    if expected_dbot_direction:
        assert raw["mirror_direction"] == expected_dbot_direction
        assert raw["mirror_instance"] == "instance-1"
    else:
        assert "mirror_direction" not in raw
        assert "mirror_instance" not in raw


def test_fetch_incidents_page_fills_after_deduped_page(mocker):
    """
    Given: A first page whose only finding is deduped away (already fetched at the boundary) but that
           still returns a next token, followed by a second page with a genuinely new finding.
    When: fetch_incidents is called with max_fetch larger than the yield.
    Then: The loop pulls the second page to backfill the gap, and the new finding becomes an incident.
    """
    mocker.patch.object(
        demisto,
        "getLastRun",
        return_value={"last_fetch": "2024-01-01T10:00:00.000Z", "fetched_ids": ["uid-1"]},
    )
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    set_last_run = mocker.patch.object(demisto, "setLastRun")
    incidents_mock = mocker.patch.object(demisto, "incidents")

    mock_client = mocker.Mock()
    mock_client.get_findings_v2.side_effect = [
        {
            # Entire page is deduped away (uid-1 already fetched at the boundary), but a token remains.
            "Findings": [
                {
                    "metadata": {"uid": "uid-1"},
                    "severity_id": 4,
                    "finding_info": {"title": "Already Seen", "created_time_dt": "2024-01-01T10:00:00.000Z"},
                }
            ],
            "NextToken": "tok-page2",
        },
        {
            # Second page carries a genuinely new finding.
            "Findings": [
                {
                    "metadata": {"uid": "uid-2"},
                    "severity_id": 3,
                    "finding_info": {"title": "New Finding", "created_time_dt": "2024-01-02T09:00:00.000Z"},
                }
            ],
            "NextToken": None,
        },
    ]

    fetch_incidents(mock_client, {"max_fetch": 50})

    # Both pages were pulled to backfill the deduped-away first page.
    assert mock_client.get_findings_v2.call_count == 2
    incidents = incidents_mock.call_args[0][0]
    assert len(incidents) == 1
    assert incidents[0]["name"] == "New Finding"

    # Window exhausted (no token on the second page): boundary advances to the new finding, no token persisted.
    last_run = set_last_run.call_args[0][0]
    assert last_run["last_fetch"] == "2024-01-02T09:00:00.000Z"
    assert last_run["fetched_ids"] == ["uid-2"]
    assert last_run["next_token"] is None


def test_fetch_incidents_stops_on_max_fetch_and_persists_token(mocker):
    """
    Given: max_fetch=2 and pages that each yield one new finding while a next token still remains.
    When: fetch_incidents is called.
    Then: The loop stops once max_fetch new findings are collected, persists the remaining token and the
          filters it is valid against, requests only the still-needed count per page, and advances the
          boundary using all accumulated findings.
    """
    mocker.patch.object(demisto, "getLastRun", return_value={"last_fetch": "2024-01-01T00:00:00.000Z"})
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    set_last_run = mocker.patch.object(demisto, "setLastRun")
    incidents_mock = mocker.patch.object(demisto, "incidents")

    mock_client = mocker.Mock()
    mock_client.get_findings_v2.side_effect = [
        {
            "Findings": [
                {
                    "metadata": {"uid": "uid-1"},
                    "severity_id": 3,
                    "finding_info": {"title": "One", "created_time_dt": "2024-01-02T00:00:00.000Z"},
                }
            ],
            "NextToken": "tok-2",
        },
        {
            "Findings": [
                {
                    "metadata": {"uid": "uid-2"},
                    "severity_id": 3,
                    "finding_info": {"title": "Two", "created_time_dt": "2024-01-03T00:00:00.000Z"},
                }
            ],
            "NextToken": "tok-3",
        },
    ]

    fetch_incidents(mock_client, {"max_fetch": 2})

    # Exactly two pages pulled to reach max_fetch=2; the loop stops even though tok-3 remains.
    assert mock_client.get_findings_v2.call_count == 2
    incidents = incidents_mock.call_args[0][0]
    assert len(incidents) == 2

    # First page requests max_fetch (2); second page requests only the still-needed count (1).
    first_call, second_call = mock_client.get_findings_v2.call_args_list
    assert first_call[1]["MaxResults"] == 2
    assert "NextToken" not in first_call[1]
    assert second_call[1]["MaxResults"] == 1
    assert second_call[1]["NextToken"] == "tok-2"

    # Stopped mid-window on max_fetch: the remaining token and its filters are persisted for the next cycle.
    last_run = set_last_run.call_args[0][0]
    assert last_run["next_token"] == "tok-3"
    assert isinstance(last_run["filters"], str)  # filters are JSON-serialized when a token is persisted
    # Boundary advances to the latest created time across all accumulated findings.
    assert last_run["last_fetch"] == "2024-01-03T00:00:00.000Z"
    assert last_run["fetched_ids"] == ["uid-2"]


def test_fetch_incidents_page_loop_is_bounded(mocker):
    """
    Given: An API that always returns a page fully deduped away (uid-1 already fetched) while a next
           token is present on every page, which would loop forever with an unbounded while True.
    When: fetch_incidents is called with a small max_fetch.
    Then: The loop is bounded to at most max_fetch page pulls and returns without hanging.
    """
    mocker.patch.object(
        demisto,
        "getLastRun",
        return_value={"last_fetch": "2024-01-01T10:00:00.000Z", "fetched_ids": ["uid-1"]},
    )
    mocker.patch.object(demisto, "integrationInstance", return_value="instance-1")
    set_last_run = mocker.patch.object(demisto, "setLastRun")
    incidents_mock = mocker.patch.object(demisto, "incidents")

    mock_client = mocker.Mock()
    # Every page is the same deduped-away finding and always carries a next token (would loop forever).
    mock_client.get_findings_v2.return_value = {
        "Findings": [
            {
                "metadata": {"uid": "uid-1"},
                "severity_id": 4,
                "finding_info": {"title": "Already Seen", "created_time_dt": "2024-01-01T10:00:00.000Z"},
            }
        ],
        "NextToken": "tok-forever",
    }

    fetch_incidents(mock_client, {"max_fetch": 3})

    # Bounded: at most max_fetch (3) page pulls, then the loop exits instead of hanging.
    assert mock_client.get_findings_v2.call_count == 3
    incidents = incidents_mock.call_args[0][0]
    assert incidents == []
    # A token still remains, so it is persisted for the next cycle.
    last_run = set_last_run.call_args[0][0]
    assert last_run["next_token"] == "tok-forever"


def test_get_remote_data_command_returns_finding(mocker):
    """
    Given: A client returning a single finding for the requested uid.
    When: get_remote_data_command is called.
    Then: It fetches by metadata.uid and returns the finding as the mirrored object, enriched with a
          ready-to-use xsoar_severity, and (since status_id=4 Resolved) a close entry.
    """
    from AWS_SecurityHub_V2 import IncidentSeverity

    mock_client = mocker.Mock()
    finding = {"metadata": {"uid": "uid-1"}, "status_id": 4, "severity_id": 3}
    mock_client.get_findings_v2.return_value = {"Findings": [finding]}

    result = get_remote_data_command(mock_client, {"id": "uid-1", "lastUpdate": "2024-01-01T00:00:00Z"})

    # severity_id 3 (OCSF Medium) -> XSOAR Medium, injected as xsoar_severity for the mapper.
    assert result.mirrored_object["xsoar_severity"] == IncidentSeverity.MEDIUM
    assert result.mirrored_object["metadata"]["uid"] == "uid-1"
    # status_id 4 (Resolved) is wired to a close entry for full lifecycle sync.
    assert result.entries[0]["Contents"]["dbotIncidentClose"] is True
    string_filter = mock_client.get_findings_v2.call_args[1]["Filters"]["CompositeFilters"][0]["StringFilters"][0]
    assert string_filter["FieldName"] == "metadata.uid"
    assert string_filter["Filter"]["Value"] == "uid-1"


def test_get_remote_data_command_no_finding(mocker):
    """
    Given: A client returning no finding for the requested uid.
    When: get_remote_data_command is called.
    Then: It returns an empty mirrored object.
    """
    mock_client = mocker.Mock()
    mock_client.get_findings_v2.return_value = {"Findings": []}

    result = get_remote_data_command(mock_client, {"id": "missing", "lastUpdate": "2024-01-01T00:00:00Z"})

    assert result.mirrored_object == {}


def _update_remote_args(delta, remote_id="uid-1", incident_changed=True, status=IncidentStatus.ACTIVE):
    """Build an args dict compatible with UpdateRemoteSystemArgs for outgoing mirroring tests."""
    return {
        "remoteId": remote_id,
        "data": {},
        "entries": [],
        "incidentChanged": incident_changed,
        "delta": delta,
        "status": status,
    }


def test_update_remote_system_mirrors_severity_and_status(mocker):
    """
    Given: An incident whose severityid and statusid changed (delta), with mirroring enabled.
    When: update_remote_system_command is called.
    Then: batch_update_findings_v2 is called targeting the finding uid with SeverityId and StatusId.
    """
    mock_client = mocker.Mock()
    mock_client.batch_update_findings_v2.return_value = {"ProcessedFindings": [{}], "UnprocessedFindings": []}

    args = _update_remote_args({"severityid": "4", "statusid": "2"})
    result = update_remote_system_command(mock_client, args, resolve_finding=False)

    assert result == "uid-1"
    call_kwargs = mock_client.batch_update_findings_v2.call_args[1]
    assert call_kwargs["MetadataUids"] == ["uid-1"]
    assert call_kwargs["SeverityId"] == 4
    assert call_kwargs["StatusId"] == 2


def test_update_remote_system_mirrors_comment(mocker):
    """
    Given: An incident whose comment changed.
    When: update_remote_system_command is called.
    Then: batch_update_findings_v2 is called with the Comment.
    """
    mock_client = mocker.Mock()
    mock_client.batch_update_findings_v2.return_value = {"ProcessedFindings": [{}], "UnprocessedFindings": []}

    args = _update_remote_args({"comment": "investigated"})
    update_remote_system_command(mock_client, args, resolve_finding=False)

    call_kwargs = mock_client.batch_update_findings_v2.call_args[1]
    assert call_kwargs["Comment"] == "investigated"


def test_update_remote_system_no_changes_skips_call(mocker):
    """
    Given: An incident with no mirrorable delta.
    When: update_remote_system_command is called.
    Then: batch_update_findings_v2 is NOT called, and the uid is returned.
    """
    mock_client = mocker.Mock()

    args = _update_remote_args({}, incident_changed=False)
    result = update_remote_system_command(mock_client, args, resolve_finding=False)

    assert result == "uid-1"
    mock_client.batch_update_findings_v2.assert_not_called()


def test_update_remote_system_resolves_on_close(mocker):
    """
    Given: A closed incident (status DONE) and resolve_finding enabled.
    When: update_remote_system_command is called.
    Then: batch_update_findings_v2 forces StatusId=4 (Resolved).
    """
    mock_client = mocker.Mock()
    mock_client.batch_update_findings_v2.return_value = {"ProcessedFindings": [{}], "UnprocessedFindings": []}

    args = _update_remote_args({"comment": "closing"}, status=IncidentStatus.DONE)
    update_remote_system_command(mock_client, args, resolve_finding=True)

    call_kwargs = mock_client.batch_update_findings_v2.call_args[1]
    assert call_kwargs["StatusId"] == 4


@pytest.mark.parametrize(
    "delta,expected_severity_id,expect_call",
    [
        ({"severity": 2}, 3, True),  # built-in XSOAR Medium -> OCSF Medium (3)
        ({"severityid": "5", "severity": 2}, 5, True),  # explicit severityid wins over built-in severity
        ({"severity": 0}, None, False),  # Unknown has no OCSF equivalent -> nothing mirrored
    ],
)
def test_update_remote_system_builtin_severity(mocker, delta, expected_severity_id, expect_call):
    """
    Given: An incident delta carrying the built-in "severity" field (alone, with severityid, or Unknown).
    When: update_remote_system_command is called.
    Then: The built-in severity is translated to OCSF SeverityId, an explicit severityid takes precedence,
          and an unmappable (Unknown) severity mirrors nothing.
    """
    mock_client = mocker.Mock()
    mock_client.batch_update_findings_v2.return_value = {"ProcessedFindings": [{}], "UnprocessedFindings": []}

    update_remote_system_command(mock_client, _update_remote_args(delta), resolve_finding=False)

    if expect_call:
        assert mock_client.batch_update_findings_v2.call_args[1]["SeverityId"] == expected_severity_id
    else:
        mock_client.batch_update_findings_v2.assert_not_called()


def test_get_mapping_fields_command():
    """
    Given: The outgoing mapping schema request.
    When: get_mapping_fields_command is called.
    Then: It returns a scheme for the finding type with every field the outgoing mirror consumes
          (including the built-in "severity" field).
    """
    result = get_mapping_fields_command()

    entry = result.extract_mapping()
    assert "AWS Security Hub v2 Finding" in entry
    finding_fields = entry["AWS Security Hub v2 Finding"]
    assert {"severityid", "statusid", "comment", "severity"} <= set(finding_fields)


@pytest.mark.parametrize(
    "status_id,expected_reason",
    [(4, "Resolved"), (3, "Other")],
)
def test_build_close_entries_closes_on_resolved_or_suppressed(status_id, expected_reason):
    """
    Given: A finding whose OCSF status_id is Resolved (4) or Suppressed (3).
    When: build_close_entries is called.
    Then: A single dbotIncidentClose entry with the mapped close reason is returned.
    """
    entries = build_close_entries({"status_id": status_id, "status": "Resolved"})

    assert len(entries) == 1
    contents = entries[0]["Contents"]
    assert contents["dbotIncidentClose"] is True
    assert contents["closeReason"] == expected_reason


@pytest.mark.parametrize("finding", [{}, {"status_id": 0}, {"status_id": 1}, {"status_id": 2}, {"status_id": 99}])
def test_build_close_entries_no_action_for_non_close_status(finding):
    """
    Given: A finding with a missing or non-close OCSF status_id (including open statuses New/In Progress).
    When: build_close_entries is called.
    Then: No entries are returned (reopening a closed incident is not supported; the incident is left untouched).
    """
    assert build_close_entries(finding) == []


def _mock_client_with_exceptions(mocker):
    """Return a mock securityhub client whose ``exceptions.*`` are real Exception subclasses."""
    mock_client = mocker.Mock()
    mock_client.exceptions.ResourceNotFoundException = type("ResourceNotFoundException", (Exception,), {})
    mock_client.exceptions.AccessDeniedException = type("AccessDeniedException", (Exception,), {})
    return mock_client


def test_test_module_success(mocker):
    """
    Given: A client whose describe_security_hub_v2 call succeeds.
    When: test_module is called.
    Then: It returns 'ok'.
    """
    mock_client = _mock_client_with_exceptions(mocker)
    mock_client.describe_security_hub_v2.return_value = {}

    assert run_test_module(mock_client) == "ok"


def test_test_module_not_enabled_raises(mocker):
    """
    Given: A client whose describe_security_hub_v2 raises ResourceNotFoundException.
    When: test_module is called.
    Then: A DemistoException about Security Hub V2 not being enabled is raised.
    """
    mock_client = _mock_client_with_exceptions(mocker)
    mock_client.describe_security_hub_v2.side_effect = mock_client.exceptions.ResourceNotFoundException()

    with pytest.raises(DemistoException, match="not enabled"):
        run_test_module(mock_client)


def test_test_module_access_denied_raises(mocker):
    """
    Given: A client whose describe_security_hub_v2 raises AccessDeniedException.
    When: test_module is called.
    Then: A DemistoException about access being denied is raised.
    """
    mock_client = _mock_client_with_exceptions(mocker)
    mock_client.describe_security_hub_v2.side_effect = mock_client.exceptions.AccessDeniedException()

    with pytest.raises(DemistoException, match="Access denied"):
        run_test_module(mock_client)