DeHashed

This integration allows you to check if your personal information such as your email, username, or password is being compromised.

Utilities · DeHashed

Details

IDDeHashed
ProviderDeHashed
CategoryUtilities
From Version5.5.0
Docker Imagedemisto/fastapi:0.125.0.10158186
Supported ModulesAgentix XSIAM

README

This integration allows you to check if your personal information such as your email, username, or password is being compromised.

Configure DeHashed in Cortex

Parameter Description Required
API Key For generating an API Key, see https://www.dehashed.com. True
Trust any certificate (not secure)   False
Use system proxy settings   False
Email Severity: The DBot reputation for compromised emails (SUSPICIOUS or MALICIOUS)   False
Source Reliability Reliability of the source providing the intelligence data. 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.

dehashed-search


Performs a search to check if information is compromised.

Base Command

dehashed-search

Input

Argument Name Description Required
asset_type If you select the “all fields” option, the search is performed on all fields with the specified value entered in the “value” argument, and you don’t have to pass the “operation” argument. Possible values are: email, ip_address, username, hashed_password, name, vin, address, phone, all_fields. Required
value The searched value. Required
operation The search operator. Possible values are: is, regex. Required
page The number of page to return. Each page contains up to 5,000 results. Optional
results_from Starting result number to display. Default is 1. Optional
results_to Ending result number to display. Default is 50. Optional

Context Output

Path Type Description
DeHashed.Search.Id String ID of the object.
DeHashed.Search.Email String Email address of the object.
DeHashed.Search.Username String Username of the object.
DeHashed.Search.Password String Password of the object.
DeHashed.Search.HashedPassword String Hashed password of the object.
DeHashed.Search.Name String Name of the object.
DeHashed.Search.Address String Address of the object.
DeHashed.Search.IpAddress String IP address of the object.
DeHashed.Search.Phone Number Phone number of the object.
DeHashed.Search.Dob String Date of birth.
DeHashed.Search.LicensePlate String License plate.
DeHashed.Search.Company String Company name.
DeHashed.Search.Url String Associated URL.
DeHashed.Search.Social String Social media handle.
DeHashed.Search.CryptocurrencyAddress String Cryptocurrency address.
DeHashed.Search.DatabaseName String Source database/breach name (drives DBot score).
Dehashed.LastQuery.ResultsFrom Number The value of the “results_from” argument that was passed in the last query.
Dehashed.LastQuery.ResultsTo Unknown The value of the “results_to” argument that was passed in the last query.
Dehashed.LastQuery.TotalResults Number The total number of entries returned from the last query.
Dehashed.LastQuery.DisplayedResults Number The number of entries that were displayed in Cortex XSOAR from the last query.

Command Example

!dehashed-search asset_type=all_fields operation=contains value=or-gal@gmail.com results_to=4 results_from=1
!dehashed-search asset_type=email operation=is value=or-gal@gmail.com page=1
!dehashed-search asset_type=name operation=contains value=gal,gil,test1 results_from=2 results_to=30 page=3
!dehashed-search asset_type=name operation=regex value=joh?n(ath[oa]n)

Human Readable Output

email


Checks if an email address was compromised.

Base Command

email

Input

Argument Name Description Required
email The email address to check. Required

Context Output

Path Type Description
DeHashed.Search.Id String ID of the object.
DeHashed.Search.Email String Email address of the object.
DeHashed.Search.Username String Username of the object.
DeHashed.Search.Password String Password of the object.
DeHashed.Search.HashedPassword String Hashed password of the object.
DeHashed.Search.Name String Name of the object.
DeHashed.Search.Address String Address of the object.
DeHashed.Search.IpAddress String IP address of the object.
DeHashed.Search.Phone Number Phone number of the object.
DeHashed.Search.Dob String Date of birth.
DeHashed.Search.LicensePlate String License plate.
DeHashed.Search.Company String Company name.
DeHashed.Search.Url String Associated URL.
DeHashed.Search.Social String Social media handle.
DeHashed.Search.CryptocurrencyAddress String Cryptocurrency address.
DeHashed.Search.DatabaseName String Source database/breach name (drives DBot score).
DBotScore.Indicator String The indicator that was tested.
DBotScore.Type String The indicator type.
DBotScore.Vendor String The vendor used to calculate the score.
DBotScore.Score Number The actual score.
DBotScore.Reliability String Reliability of the source providing the intelligence data.

Command Example

!email email=or-gal@gmail.com

Human Readable Output

Configuration parameters

  • credentials — (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • email_dbot_score — Email Severity: The DBot reputation for compromised emails (SUSPICIOUS or MALICIOUS)
  • integration_reliability — Source Reliability

Commands (2)

  • dehashed-search

    Performs a search to check if information is compromised.

  • email

    Checks if an email address was compromised.

import demistomock as demisto
import pytest
from pytest_mock import MockerFixture
import json
from pathlib import Path
from typing import Any, TYPE_CHECKING

if TYPE_CHECKING:
    from DeHashed import DehashedClient


def load_mock_response(file_path: str) -> dict | list:
    """
    Helper function to load mock response data from a JSON file.

    Args:
        file_path (str): The relative path under the test_data directory to the JSON file
            (e.g. "dehashed-search/response.json").

    Returns:
        dict | list: The parsed JSON content.
    """
    with (Path(__file__).parent / "test_data" / file_path).open() as f:
        return json.load(f)


@pytest.fixture(autouse=True)
def mock_support_multithreading(mocker: MockerFixture) -> None:
    """Mock support_multithreading to prevent demistomock attribute errors.

    This fixture automatically runs before each test to mock the support_multithreading
    function which is called during ContentClient initialization. Without this mock,
    tests fail with: AttributeError: module 'demistomock' has no attribute '_Demisto__do'
    """
    mocker.patch("ContentClientApiModule.support_multithreading")


@pytest.fixture
def client() -> "DehashedClient":
    """
    Pytest fixture that initializes and returns a DehashedClient instance for testing.

    Returns:
        DehashedClient: An instance of the DeHashed API client.
    """
    from pydantic import SecretStr
    from DeHashed import DehashedClient, DehashedParams, Credentials

    params = DehashedParams(
        credentials=Credentials(
            password=SecretStr("test-key"),
        ),
    )  # type: ignore[call-arg]
    return DehashedClient(params)


# region helpers


def test_build_search_query_raises_on_empty_value() -> None:
    """
    Given:
        - An empty `value` string.
    When:
        - Calling _build_search_query.
    Then:
        - Raises DemistoException with the 'must get "value" as an argument' message.
    """
    from DeHashed import _build_search_query, DemistoException

    with pytest.raises(DemistoException, match='This command must get "value" as an argument.'):
        _build_search_query("email", "", "is")


@pytest.mark.parametrize(
    "asset_type, value, operation, expected_query",
    [
        pytest.param("email", "a@b.co", "is", 'email:"a@b.co"', id="is_single_email"),
        pytest.param("all_fields", "testgamil.co", "is", '"testgamil.co"', id="is_single_all_fields"),
        pytest.param("all_fields", "joh?n(ath[oa]n)", "regex", "joh?n(ath[oa]n)", id="regex_single_all_fields"),
        pytest.param("vin", "abc", "regex", "vin:abc", id="regex_single_vin"),
    ],
)
def test_build_search_query_parametrized(
    asset_type: str,
    value: str,
    operation: str,
    expected_query: str,
) -> None:
    """
    Given:
        - Various combinations of `asset_type`, `value`, and `operation` covering
          the `is` and `regex` operators (the only supported `Operation` Literal
          values) with a single string `value`.
    When:
        - Calling _build_search_query.
    Then:
        - Returns the query string per the documented rules:
          - `is`         -> wraps the value in double quotes.
          - `regex`      -> uses the raw value (regex relies on the client `regex=True` flag).
          - `all_fields` -> omits the leading `"<asset_type>:"` prefix.
    """
    from DeHashed import _build_search_query

    assert _build_search_query(asset_type, value, operation) == expected_query


def test_filter_results_explicit_in_range() -> None:
    """
    Given:
        - A 2-entry list and `results_from=1`, `results_to=2`.
    When:
        - Calling _filter_results.
    Then:
        - Returns the full list with resolved `(1, 2)`.
    """
    from DeHashed import _filter_results

    entries = [{"id": "id_1"}, {"id": "id_2"}]

    sliced, results_from, results_to = _filter_results(entries, 1, 2)

    assert sliced == entries
    assert results_from == 1
    assert results_to == 2


def test_filter_results_to_beyond_len() -> None:
    """
    Given:
        - A 2-entry list and `results_to=50` (beyond list length).
    When:
        - Calling _filter_results.
    Then:
        - Returns the full list (Python slicing tolerates `stop > len`); the resolved
          `results_to` is not clamped — it is returned verbatim.
    """
    from DeHashed import _filter_results

    entries = [{"id": "id_1"}, {"id": "id_2"}]

    sliced, results_from, results_to = _filter_results(entries, 1, 50)

    assert sliced == entries
    assert results_from == 1
    assert results_to == 2


def test_filter_results_explicit_range() -> None:
    """
    Given:
        - A 2-entry list and `results_from=1`, `results_to=1`.
    When:
        - Calling _filter_results.
    Then:
        - Returns only the first entry; resolved range is `(1, 1)`.
    """
    from DeHashed import _filter_results

    entries = [{"id": "id_1"}, {"id": "id_2"}]

    sliced, results_from, results_to = _filter_results(entries, 1, 1)

    assert sliced == entries[0:1]
    assert results_from == 1
    assert results_to == 1


def test_transform_entry_flattens_lists() -> None:
    """
    Given:
        - An entry with list-typed identity fields (`email`, `phone`).
    When:
        - Calling _transform_entry.
    Then:
        - List fields are flattened into comma-separated strings; keys are CamelCase.
    """
    from DeHashed import _transform_entry

    out = _transform_entry({"email": ["a@b.co"], "phone": ["1", "2"]})

    assert out == {"Email": "a@b.co", "Phone": "1, 2"}


def test_transform_entry_passthrough_scalars() -> None:
    """
    Given:
        - An entry with scalar string fields.
    When:
        - Calling _transform_entry.
    Then:
        - Scalar fields are passed through unchanged with CamelCase keys.
    """
    from DeHashed import _transform_entry

    out = _transform_entry({"id": "x", "database_name": "DB"})

    assert out == {"Id": "x", "DatabaseName": "DB"}


def test_transform_entry_omits_empty_and_none() -> None:
    """
    Given:
        - An entry containing `[]`, `[""]`, `None`, and `""` values mixed with valid ones.
    When:
        - Calling _transform_entry.
    Then:
        - Empty / None-valued keys are omitted from the output entirely.
    """
    from DeHashed import _transform_entry

    out = _transform_entry(
        {
            "id": "x",
            "empty_list": [],
            "list_of_empty": [""],
            "none_value": None,
            "empty_string": "",
            "email": ["a@b.co"],
        }
    )

    assert out == {"Id": "x", "Email": "a@b.co"}
    assert "EmptyList" not in out
    assert "ListOfEmpty" not in out
    assert "NoneValue" not in out
    assert "EmptyString" not in out


def test_transform_entry_passthrough_dict() -> None:
    """
    Given:
        - An entry with a nested dict value (`raw_record`).
    When:
        - Calling _transform_entry.
    Then:
        - The dict is passed through unchanged under a CamelCase key.
    """
    from DeHashed import _transform_entry

    out = _transform_entry({"raw_record": {"k": "v"}})

    assert out == {"RawRecord": {"k": "v"}}


def test_transform_entry_passthrough_dict_with_bool_values() -> None:
    """
    Given:
        - An entry whose `raw_record` dict contains bool values
          (mirrors the canonical api_response_example.json shape).
    When:
        - Calling _transform_entry.
    Then:
        - The dict (including bool values) is passed through unchanged.
    """
    from DeHashed import _transform_entry

    out = _transform_entry({"raw_record": {"le_only": True, "unstructured": True}})

    assert out == {"RawRecord": {"le_only": True, "unstructured": True}}


def test_transform_entry_camel_case_keys() -> None:
    """
    Given:
        - An entry with snake_case keys requiring multi-word CamelCase conversion.
    When:
        - Calling _transform_entry.
    Then:
        - Keys are converted to CamelCase (e.g. `hashed_password` -> `HashedPassword`).
    """
    from DeHashed import _transform_entry

    out = _transform_entry({"hashed_password": ["abc"], "ip_address": ["1.1.1.1"]})

    assert "HashedPassword" in out
    assert "IpAddress" in out
    assert out["HashedPassword"] == "abc"
    assert out["IpAddress"] == "1.1.1.1"


def test_compute_score_no_sources_returns_none() -> None:
    """
    Given:
        - An empty entries list (or entries lacking `database_name`).
    When:
        - Calling compute_score.
    Then:
        - Returns Common.DBotScore.NONE (0).
    """
    from CommonServerPython import Common
    from DeHashed import compute_score

    assert compute_score([], "SUSPICIOUS") == Common.DBotScore.NONE
    assert compute_score([{"id": "x"}], "SUSPICIOUS") == Common.DBotScore.NONE


def test_compute_score_suspicious_label() -> None:
    """
    Given:
        - Entries containing `database_name` and the configured label "SUSPICIOUS".
    When:
        - Calling compute_score.
    Then:
        - Returns Common.DBotScore.SUSPICIOUS (numeric 2).
    """
    from CommonServerPython import Common
    from DeHashed import compute_score

    score = compute_score([{"database_name": "DB-A"}], "SUSPICIOUS")

    assert score == Common.DBotScore.SUSPICIOUS
    assert score == 2


def test_compute_score_malicious_label() -> None:
    """
    Given:
        - Entries containing `database_name` and the configured label "MALICIOUS".
    When:
        - Calling compute_score.
    Then:
        - Returns Common.DBotScore.BAD (numeric 3, labeled "MALICIOUS" in the UI).
    """
    from CommonServerPython import Common
    from DeHashed import compute_score

    score = compute_score([{"database_name": "DB-A"}], "MALICIOUS")

    assert score == Common.DBotScore.BAD
    assert score == 3


def test_compute_score_unknown_label_defaults_to_bad() -> None:
    """
    Given:
        - Entries with `database_name` and an unknown label string ("UNKNOWN").
    When:
        - Calling compute_score.
    Then:
        - Defaults to Common.DBotScore.BAD (the `else` branch).
    """
    from CommonServerPython import Common
    from DeHashed import compute_score

    assert compute_score([{"database_name": "DB-A"}], "UNKNOWN") == Common.DBotScore.BAD


# endregion

# region parameters


class TestCredentials:
    """Tests for the Credentials pydantic model."""

    @pytest.mark.parametrize(
        "password",
        [
            pytest.param("simple-key", id="simple"),
            pytest.param("P@ssw0rd!#$%", id="complex"),
            pytest.param("a" * 64, id="long"),
        ],
    )
    def test_password_stored_as_secret_str(self, password: str) -> None:
        """
        Given:
            - A plain string password.
        When:
            - Constructing a Credentials model.
        Then:
            - The password field is stored as SecretStr and accessible via get_secret_value().
        """
        from pydantic import SecretStr
        from DeHashed import Credentials

        creds = Credentials(password=password)  # type: ignore[arg-type]

        assert isinstance(creds.password, SecretStr)
        assert creds.password.get_secret_value() == password

    def test_credentials_missing_password_raises(self) -> None:
        """
        Given:
            - No password provided.
        When:
            - Constructing Credentials.
        Then:
            - Raises DemistoException mentioning `password`.
        """
        from CommonServerPython import DemistoException
        from DeHashed import Credentials

        with pytest.raises(DemistoException, match="password"):
            Credentials()  # type: ignore[call-arg]


class TestDehashedParams:
    """Tests for the DehashedParams pydantic model."""

    def test_api_key_property_returns_credentials_password(self) -> None:
        """
        Given:
            - DehashedParams constructed with credentials.
        When:
            - Accessing the `api_key` property.
        Then:
            - Returns the SecretStr password from the credentials.
        """
        from pydantic import SecretStr
        from DeHashed import DehashedParams, Credentials

        params = DehashedParams(
            credentials=Credentials(password=SecretStr("my-secret")),
        )  # type: ignore[call-arg]

        assert isinstance(params.api_key, SecretStr)
        assert params.api_key.get_secret_value() == "my-secret"

    def test_email_dbot_score_default(self) -> None:
        """
        Given:
            - No explicit `email_dbot_score`.
        When:
            - Constructing DehashedParams.
        Then:
            - The `email_dbot_score` defaults to "SUSPICIOUS".
        """
        from pydantic import SecretStr
        from DeHashed import DehashedParams, Credentials

        params = DehashedParams(
            credentials=Credentials(password=SecretStr("my-secret")),
        )  # type: ignore[call-arg]

        assert params.email_dbot_score == "SUSPICIOUS"

    def test_email_dbot_score_explicit_malicious(self) -> None:
        """
        Given:
            - An explicit `email_dbot_score="MALICIOUS"`.
        When:
            - Constructing DehashedParams.
        Then:
            - The value is stored as "MALICIOUS".
        """
        from pydantic import SecretStr
        from DeHashed import DehashedParams, Credentials

        params = DehashedParams(
            credentials=Credentials(password=SecretStr("my-secret")),
            email_dbot_score="MALICIOUS",
        )  # type: ignore[call-arg]

        assert params.email_dbot_score == "MALICIOUS"

    def test_integration_reliability_default(self) -> None:
        """
        Given:
            - No `integration_reliability` passed.
        When:
            - Constructing DehashedParams.
        Then:
            - `integration_reliability` defaults to "B - Usually reliable" (matches the
              integration YAML default — see DehashedParams field default).
        """
        from pydantic import SecretStr
        from DeHashed import DehashedParams, Credentials

        params = DehashedParams(
            credentials=Credentials(password=SecretStr("my-secret")),
        )  # type: ignore[call-arg]

        assert params.integration_reliability == "B - Usually reliable"

    def test_params_missing_credentials_raises(self) -> None:
        """
        Given:
            - No credentials provided.
        When:
            - Constructing DehashedParams.
        Then:
            - Raises DemistoException mentioning `credentials`.
        """
        from CommonServerPython import DemistoException
        from DeHashed import DehashedParams

        with pytest.raises(DemistoException, match="credentials"):
            DehashedParams()  # type: ignore[call-arg]


class TestDehashedSearchArgs:
    """Tests for the DehashedSearchArgs pydantic model."""

    def test_required_fields_only(self) -> None:
        """
        Given:
            - Only `asset_type`, `value`, `operation`.
        When:
            - Constructing DehashedSearchArgs.
        Then:
            - Required fields are set; optional `page`, `results_from`, `results_to`
              fall back to their integer field defaults (`1`, `1`, `50`).
        """
        from DeHashed import DehashedSearchArgs

        args = DehashedSearchArgs(asset_type="email", value="a@b.co", operation="is")  # type: ignore[call-arg]

        assert args.asset_type == "email"
        assert args.value == "a@b.co"
        assert args.operation == "is"
        assert args.page == 1
        assert args.results_from == 1
        assert args.results_to == 50

    def test_value_passthrough_string(self) -> None:
        """
        Given:
            - A plain string `value="a@b.co"`.
        When:
            - Constructing DehashedSearchArgs.
        Then:
            - `value` is stored verbatim as a string (no list-splitting / no quoting —
              quoting is applied later in `_build_search_query` based on `operation`).
        """
        from DeHashed import DehashedSearchArgs

        args = DehashedSearchArgs(asset_type="email", value="a@b.co", operation="is")  # type: ignore[call-arg]

        assert args.value == "a@b.co"

    def test_value_with_commas_preserved(self) -> None:
        """
        Given:
            - A string containing commas `value="a,b,c"`.
        When:
            - Constructing DehashedSearchArgs.
        Then:
            - `value` is preserved as the literal string `"a,b,c"` — there is no
              CSV-splitting / multi-value handling in the new query construction logic.
        """
        from DeHashed import DehashedSearchArgs

        args = DehashedSearchArgs(asset_type="email", value="a,b,c", operation="is")  # type: ignore[call-arg]

        assert args.value == "a,b,c"

    @pytest.mark.parametrize(
        "page_input, expected_page",
        [
            pytest.param(3, 3, id="int"),
            pytest.param("4", 4, id="string"),
        ],
    )
    def test_page_coercion_parametrized(self, page_input: int | str, expected_page: int) -> None:
        """
        Given:
            - Page inputs of various supported types (int, string-int).
        When:
            - Constructing DehashedSearchArgs.
        Then:
            - `page` is coerced to int via `arg_to_number`.
        """
        from DeHashed import DehashedSearchArgs

        args = DehashedSearchArgs(
            asset_type="email",
            value="a@b.co",
            operation="is",
            page=page_input,
        )  # type: ignore[call-arg]

        assert args.page == expected_page

    @pytest.mark.parametrize(
        "page_input",
        [
            pytest.param(0, id="zero"),
            pytest.param(-1, id="negative"),
        ],
    )
    def test_page_zero_or_negative_raises(self, page_input: int) -> None:
        """
        Given:
            - `page=0` or `page=-1`.
        When:
            - Constructing DehashedSearchArgs.
        Then:
            - Raises DemistoException (validator wraps ValueError) — `page` must be > 0.
        """
        from CommonServerPython import DemistoException
        from DeHashed import DehashedSearchArgs

        with pytest.raises(DemistoException, match="page"):
            DehashedSearchArgs(asset_type="email", value="a@b.co", operation="is", page=page_input)  # type: ignore[call-arg]

    @pytest.mark.parametrize(
        "results_from_input",
        [
            pytest.param(0, id="zero"),
            pytest.param(-1, id="negative"),
        ],
    )
    def test_results_from_validator(self, results_from_input: int) -> None:
        """
        Given:
            - `results_from=0` or `results_from=-1`.
        When:
            - Constructing DehashedSearchArgs.
        Then:
            - Raises DemistoException mentioning `results_from` — must be > 0.
        """
        from CommonServerPython import DemistoException
        from DeHashed import DehashedSearchArgs

        with pytest.raises(DemistoException, match="results_from"):
            DehashedSearchArgs(asset_type="email", value="a@b.co", operation="is", results_from=results_from_input)  # type: ignore[call-arg]

    @pytest.mark.parametrize(
        "results_to_input",
        [
            pytest.param(0, id="zero"),
            pytest.param(-1, id="negative"),
        ],
    )
    def test_results_to_validator(self, results_to_input: int) -> None:
        """
        Given:
            - `results_to=0` or `results_to=-1`.
        When:
            - Constructing DehashedSearchArgs.
        Then:
            - Raises DemistoException mentioning `results_to` — must be > 0.
        """
        from CommonServerPython import DemistoException
        from DeHashed import DehashedSearchArgs

        with pytest.raises(DemistoException, match="results_to"):
            DehashedSearchArgs(asset_type="email", value="a@b.co", operation="is", results_to=results_to_input)  # type: ignore[call-arg]

    def test_invalid_asset_type_raises(self) -> None:
        """
        Given:
            - An `asset_type` not in the AssetType Literal.
        When:
            - Constructing DehashedSearchArgs.
        Then:
            - Raises DemistoException (Literal violation).
        """
        from CommonServerPython import DemistoException
        from DeHashed import DehashedSearchArgs

        with pytest.raises(DemistoException, match="asset_type"):
            DehashedSearchArgs(asset_type="bogus", value="x", operation="is")  # type: ignore[call-arg]

    def test_invalid_operation_raises(self) -> None:
        """
        Given:
            - An `operation` not in the Operation Literal.
        When:
            - Constructing DehashedSearchArgs.
        Then:
            - Raises DemistoException (Literal violation).
        """
        from CommonServerPython import DemistoException
        from DeHashed import DehashedSearchArgs

        with pytest.raises(DemistoException, match="operation"):
            DehashedSearchArgs(asset_type="email", value="x", operation="bogus")  # type: ignore[call-arg]


class TestEmailArgs:
    """Tests for the EmailArgs pydantic model."""

    def test_email_csv_split(self) -> None:
        """
        Given:
            - A CSV string `email="a@b.co,c@d.co"`.
        When:
            - Constructing EmailArgs.
        Then:
            - `email` is split into `["a@b.co", "c@d.co"]` via argToList.
        """
        from DeHashed import EmailArgs

        args = EmailArgs(email="a@b.co,c@d.co")  # type: ignore[arg-type]

        assert args.email == ["a@b.co", "c@d.co"]

    def test_email_list_passthrough(self) -> None:
        """
        Given:
            - A pre-built list `email=["a@b.co"]`.
        When:
            - Constructing EmailArgs.
        Then:
            - `email` is unchanged.
        """
        from DeHashed import EmailArgs

        args = EmailArgs(email=["a@b.co"])

        assert args.email == ["a@b.co"]

    def test_email_missing_raises(self) -> None:
        """
        Given:
            - No `email` provided.
        When:
            - Constructing EmailArgs.
        Then:
            - Raises DemistoException mentioning `email`.
        """
        from CommonServerPython import DemistoException
        from DeHashed import EmailArgs

        with pytest.raises(DemistoException, match="email"):
            EmailArgs()  # type: ignore[call-arg]


class TestDehashedExecutionConfig:
    """Tests for the DehashedExecutionConfig holder.

    `BaseExecutionConfig.__init__` reads from `demisto.command()`, `demisto.params()`,
    and `demisto.args()`. We patch all three via `mocker.patch.object(demisto, ...)`
    to construct the config in tests, per the resolved §7 question 5.
    """

    def test_params_property(self, mocker: MockerFixture) -> None:
        """
        Given:
            - `demisto.params()` returns a raw params dict with credentials.
        When:
            - Accessing `config.params`.
        Then:
            - Returns a `DehashedParams` instance with the credentials populated.
        """
        from DeHashed import DehashedExecutionConfig, DehashedParams

        mocker.patch.object(demisto, "command", return_value="dehashed-search")
        mocker.patch.object(
            demisto,
            "params",
            return_value={
                "credentials": {"password": "test-key"},
                "email_dbot_score": "MALICIOUS",
            },
        )
        mocker.patch.object(demisto, "args", return_value={})

        config = DehashedExecutionConfig()
        params = config.params

        assert isinstance(params, DehashedParams)
        assert params.email_dbot_score == "MALICIOUS"

    def test_dehashed_search_args_property(self, mocker: MockerFixture) -> None:
        """
        Given:
            - `demisto.args()` returns raw dehashed-search args.
        When:
            - Accessing `config.dehashed_search_args`.
        Then:
            - Returns a `DehashedSearchArgs` instance with values parsed/coerced.
        """
        from DeHashed import DehashedExecutionConfig, DehashedSearchArgs

        mocker.patch.object(demisto, "command", return_value="dehashed-search")
        mocker.patch.object(
            demisto,
            "params",
            return_value={"credentials": {"password": "test-key"}},
        )
        mocker.patch.object(
            demisto,
            "args",
            return_value={"asset_type": "email", "value": "a@b.co", "operation": "is"},
        )

        config = DehashedExecutionConfig()
        args = config.dehashed_search_args

        assert isinstance(args, DehashedSearchArgs)
        assert args.asset_type == "email"
        assert args.value == "a@b.co"
        assert args.operation == "is"

    def test_email_args_property(self, mocker: MockerFixture) -> None:
        """
        Given:
            - `demisto.args()` returns raw email args.
        When:
            - Accessing `config.email_args`.
        Then:
            - Returns an `EmailArgs` instance with the email value parsed.
        """
        from DeHashed import DehashedExecutionConfig, EmailArgs

        mocker.patch.object(demisto, "command", return_value="email")
        mocker.patch.object(
            demisto,
            "params",
            return_value={"credentials": {"password": "test-key"}},
        )
        mocker.patch.object(demisto, "args", return_value={"email": "a@b.co"})

        config = DehashedExecutionConfig()
        args = config.email_args

        assert isinstance(args, EmailArgs)
        assert args.email == ["a@b.co"]


# endregion

# region auth


def test_auth_handler_sets_dehashed_api_key_header() -> None:
    """
    Given:
        - A SecretStr API key.
    When:
        - Constructing DehashedAuthHandler.
    Then:
        - The handler's `header_name` is "Dehashed-Api-Key" and `key` is the
          unwrapped secret value (verified directly via the inherited
          `APIKeyAuthHandler` attributes — see resolved §7 question 3).
    """
    from pydantic import SecretStr
    from DeHashed import DehashedAuthHandler

    handler = DehashedAuthHandler(SecretStr("test-key"))

    assert handler.header_name == "Dehashed-Api-Key"
    assert handler.key == "test-key"
    assert handler.query_param is None


# endregion

# region client


def test_general_search_posts_to_search_with_query(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A DehashedClient with `client.post` patched to return an empty envelope.
    When:
        - Calling `client.general_search(query="email:x")`.
    Then:
        - `client.post` is called once with `url_suffix="/search"`,
          `json_data={"query": "email:x"}`, and `resp_type="json"`.
    """
    post_mock = mocker.patch.object(client, "post", return_value={"entries": [], "total": 0})

    client.general_search(query="email:x")

    post_mock.assert_called_once_with(
        url_suffix="/search",
        json_data={"query": "email:x"},
        resp_type="json",
    )


def test_general_search_assigns_only_provided_params(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `query`, `page=1`, `regex=True`; other params left as None.
    When:
        - Calling `client.general_search`.
    Then:
        - The forwarded `json_data` contains only the non-None fields
          (no `size`, `wildcard`, or `de_dupe` keys).
    """
    post_mock = mocker.patch.object(client, "post", return_value={"entries": [], "total": 0})

    client.general_search(query="email:x", page=1, regex=True)

    post_mock.assert_called_once_with(
        url_suffix="/search",
        json_data={"query": "email:x", "page": 1, "regex": True},
        resp_type="json",
    )


def test_general_search_returns_post_payload(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `client.post` returns a fixture-loaded empty envelope.
    When:
        - Calling `client.general_search`.
    Then:
        - The same payload is returned to the caller verbatim.
    """
    expected = load_mock_response("dehashed-search/response_empty.json")
    mocker.patch.object(client, "post", return_value=expected)

    result = client.general_search(query="email:x")

    assert result == expected


# endregion

# region test-module


def test_module_ok(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A reachable DeHashed API (client.general_search returns a valid envelope).
    When:
        - Running test_module.
    Then:
        - Returns "ok" and client.general_search was called with the canary email query.
    """
    from DeHashed import test_module

    mock_response = load_mock_response("test-module/response_empty.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    result = test_module(client)

    assert result == "ok"
    client.general_search.assert_called_once_with(query="email:example@example.com", page=1, size=1)  # type: ignore[attr-defined]


def test_module_authentication_error(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `client.general_search` raises an Exception (e.g. unauthorized).
    When:
        - Running test_module.
    Then:
        - Returns a string starting with "AuthenticationError: Connection failed."
          and containing the original error message (loose-match per §7 q4).
        - `demisto.error` is patched to keep stdout clean.
    """
    from DeHashed import test_module

    mocker.patch.object(client, "general_search", side_effect=Exception("Unauthorized"))
    mocker.patch.object(demisto, "error")

    result = test_module(client)

    assert result.startswith("AuthenticationError: Connection failed.")
    assert "Unauthorized" in result


# endregion

# region dehashed-search


def test_dehashed_search_returns_two_command_results(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A standard 2-entry response from the DeHashed API.
    When:
        - Calling dehashed_search_command with `asset_type="all_fields"`, `value=["testgamil.co"]`, `operation="is"`.
    Then:
        - Returns a list of two CommandResults: the first for `DeHashed.LastQuery`, the second for `DeHashed.Search`
          with `outputs_key_field == "Id"` (mirrors the v1 `(val.Id==obj.Id)` context path).
    """
    from DeHashed import dehashed_search_command, DehashedSearchArgs

    mock_response = load_mock_response("dehashed-search/response_two_entries_with_db.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = DehashedSearchArgs(asset_type="all_fields", value="testgamil.co", operation="is")  # type: ignore[call-arg]

    results = dehashed_search_command(client, args)

    assert isinstance(results, list)
    assert len(results) == 2
    assert results[0].outputs_prefix == "DeHashed.LastQuery(true)"
    assert results[1].outputs_prefix == "DeHashed.Search"
    assert results[1].outputs_key_field == "Id"


def test_dehashed_search_last_query_outputs(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A standard 2-entry response (`total=2`) and no explicit results-range args.
    When:
        - Calling dehashed_search_command.
    Then:
        - The first CommandResults `outputs` matches the v1 `LastQuery` block:
          `{"ResultsFrom": 1, "ResultsTo": 2, "DisplayedResults": 2, "TotalResults": 2, "PageNumber": 1}`
          (note: the new code defaults `results_to` to 50 when unset, but `DisplayedResults` reflects the actual
          slice length and `TotalResults` reflects the API-reported total).
    """
    from DeHashed import dehashed_search_command, DehashedSearchArgs

    mock_response = load_mock_response("dehashed-search/response_two_entries_with_db_simple.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = DehashedSearchArgs(asset_type="all_fields", value="testgamil.co", operation="is")  # type: ignore[call-arg]

    results = dehashed_search_command(client, args)

    last_query: dict[str, Any] = results[0].outputs  # type: ignore[assignment, index]
    assert last_query["ResultsFrom"] == 1
    assert last_query["DisplayedResults"] == 2
    assert last_query["TotalResults"] == 2
    assert last_query["PageNumber"] == 1


def test_dehashed_search_transformed_entries_outputs(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - The standard fixture (2 entries with mixed list / scalar fields).
    When:
        - Calling dehashed_search_command.
    Then:
        - `results[1].outputs` is a list of CamelCase-keyed dicts equivalent to
          `_transform_entry` applied to each raw entry.
    """
    from DeHashed import dehashed_search_command, DehashedSearchArgs, _transform_entry

    mock_response = load_mock_response("dehashed-search/response.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = DehashedSearchArgs(asset_type="all_fields", value="testgamil.co", operation="is")  # type: ignore[call-arg]

    results = dehashed_search_command(client, args)
    transformed: list[dict[str, Any]] = results[1].outputs  # type: ignore[assignment, index]

    expected = [_transform_entry(e) for e in mock_response["entries"]]  # type: ignore[arg-type, call-overload]
    assert transformed == expected


def test_dehashed_search_total_results_propagated(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A response with `total=2`.
    When:
        - Calling dehashed_search_command.
    Then:
        - `LastQuery.TotalResults` equals the API-reported `total` (preserves the v1 `TotalResults` assertion).
    """
    from DeHashed import dehashed_search_command, DehashedSearchArgs

    mock_response = load_mock_response("dehashed-search/response_two_entries_no_db.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = DehashedSearchArgs(asset_type="all_fields", value="testgamil.co", operation="is")  # type: ignore[call-arg]

    results = dehashed_search_command(client, args)

    last_query: dict[str, Any] = results[0].outputs  # type: ignore[assignment, index]
    assert last_query["TotalResults"] == 2


@pytest.mark.parametrize(
    "asset_type, value, operation, expected_query, expected_regex",
    [
        pytest.param("all_fields", "testgamil.co", "is", '"testgamil.co"', None, id="is_single_all_fields"),
        pytest.param("all_fields", "joh?n(ath[oa]n)", "regex", "joh?n(ath[oa]n)", True, id="regex_single_all_fields"),
        pytest.param("email", "a@b.co", "is", 'email:"a@b.co"', None, id="is_single_email"),
        pytest.param("name", "test1", "is", 'name:"test1"', None, id="is_single_name"),
        pytest.param("vin", "joh?n(ath[oa]n)", "regex", "vin:joh?n(ath[oa]n)", True, id="regex_single_vin"),
    ],
)
def test_dehashed_search_query_construction_per_operator(
    mocker: MockerFixture,
    client: "DehashedClient",
    asset_type: str,
    value: str,
    operation: str,
    expected_query: str,
    expected_regex: bool | None,
) -> None:
    """
    Given:
        - Combinations of `asset_type`, `value`, and `operation` covering the
          `is`, `contains`, and `regex` operators with a single string `value`.
    When:
        - Calling dehashed_search_command (with default `page=1`).
    Then:
        - `client.general_search` is invoked with the constructed query string,
          `page=1` (the field default), `size=REQUEST_PAGE_SIZE` (1000 — the
          dehashed-search command always fetches a full page from the API), and
          `regex=True` only when `operation == "regex"` (else `None`).
    """
    from DeHashed import dehashed_search_command, DehashedSearchArgs, REQUEST_PAGE_SIZE

    mock_response = load_mock_response("dehashed-search/response_two_entries_no_db.json")
    general_search_mock = mocker.patch.object(client, "general_search", return_value=mock_response)

    args = DehashedSearchArgs(asset_type=asset_type, value=value, operation=operation)  # type: ignore[call-arg]

    dehashed_search_command(client, args)

    general_search_mock.assert_called_once_with(
        query=expected_query,
        page=1,
        size=REQUEST_PAGE_SIZE,
        wildcard=None,
        regex=expected_regex,
        de_dupe=None,
    )


def test_dehashed_search_results_range_slices_output(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A 2-entry response and `results_from=1`, `results_to=1` (request only the first entry).
    When:
        - Calling dehashed_search_command.
    Then:
        - `LastQuery.ResultsFrom == 1`, `ResultsTo == 1`, `DisplayedResults == 1`,
          `TotalResults == 2` (full total preserved); `Search.outputs` length is 1.
        - Direct port of the v1 `regex_operator_with_filter_and_change_result_range` test.
    """
    from DeHashed import dehashed_search_command, DehashedSearchArgs

    mock_response = load_mock_response("dehashed-search/response_two_entries_no_db.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = DehashedSearchArgs(
        asset_type="vin",
        value="joh?n(ath[oa]n)",
        operation="regex",
        results_from=1,
        results_to=1,
    )  # type: ignore[call-arg]

    results = dehashed_search_command(client, args)

    last_query: dict[str, Any] = results[0].outputs  # type: ignore[assignment, index]
    assert last_query["ResultsFrom"] == 1
    assert last_query["ResultsTo"] == 1
    assert last_query["DisplayedResults"] == 1
    assert last_query["TotalResults"] == 2

    search_outputs: list[dict[str, Any]] = results[1].outputs  # type: ignore[assignment, index]
    assert len(search_outputs) == 1


def test_dehashed_search_no_entries_returns_no_results_message(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `client.general_search` returns an empty envelope (no entries).
    When:
        - Calling dehashed_search_command.
    Then:
        - Returns a single CommandResults with `readable_output == "No matching results found"`
          and no outputs.
    """
    from CommonServerPython import CommandResults
    from DeHashed import dehashed_search_command, DehashedSearchArgs

    mock_response = load_mock_response("dehashed-search/response_empty.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = DehashedSearchArgs(asset_type="email", value="nobody@example.com", operation="is")  # type: ignore[call-arg]

    result = dehashed_search_command(client, args)

    assert isinstance(result, CommandResults)
    assert result.readable_output == "No matching results found"
    assert result.outputs is None


def test_dehashed_search_unexpected_response_raises(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `client.general_search` returns a non-dict (e.g. a list).
    When:
        - Calling dehashed_search_command.
    Then:
        - Raises DemistoException containing "Got unexpected output from api".
    """
    from DeHashed import dehashed_search_command, DehashedSearchArgs, DemistoException

    mocker.patch.object(client, "general_search", return_value=["not", "a", "dict"])

    args = DehashedSearchArgs(asset_type="email", value="a@b.co", operation="is")  # type: ignore[call-arg]

    with pytest.raises(DemistoException, match="Got unexpected output from api"):
        dehashed_search_command(client, args)


def test_dehashed_search_passes_page_to_client(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `DehashedSearchArgs(page=3)`.
    When:
        - Calling dehashed_search_command.
    Then:
        - `client.general_search` is called with `page=3`, `size=REQUEST_PAGE_SIZE`
          (1000 — the dehashed-search command always fetches a full page from the
          API), and the `LastQuery.PageNumber` reflects the requested page.
    """
    from DeHashed import dehashed_search_command, DehashedSearchArgs, REQUEST_PAGE_SIZE

    mock_response = load_mock_response("dehashed-search/response_one_entry.json")
    general_search_mock = mocker.patch.object(client, "general_search", return_value=mock_response)

    args = DehashedSearchArgs(asset_type="email", value="a@b.co", operation="is", page=3)  # type: ignore[call-arg]

    results = dehashed_search_command(client, args)

    general_search_mock.assert_called_once_with(
        query='email:"a@b.co"',
        page=3,
        size=REQUEST_PAGE_SIZE,
        wildcard=None,
        regex=None,
        de_dupe=None,
    )

    last_query: dict[str, Any] = results[0].outputs  # type: ignore[assignment, index]
    assert last_query["PageNumber"] == 3


# endregion

# region email


def test_email_command_returns_search_outputs(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A response with breach entries and `email_dbot_score="SUSPICIOUS"`.
    When:
        - Calling email_command for `target@example.com`.
    Then:
        - Returns a `list[CommandResults]` with one entry whose `outputs_prefix=="DeHashed.Search"`,
          `outputs_key_field=="Id"`, `outputs` populated from `_transform_entry`,
          and an attached `Common.EMAIL` indicator.
    """
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response("email/response.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["target@example.com"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability="B - Usually reliable")

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.outputs_prefix == "DeHashed.Search"
    assert response.outputs_key_field == "Id"
    assert response.outputs is not None
    assert len(response.outputs) == 3  # type: ignore[arg-type]
    assert response.indicator is not None


def test_email_command_dbot_score_suspicious(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A response with entries carrying `database_name` and `email_dbot_score="SUSPICIOUS"`.
    When:
        - Calling email_command for `testgamil.com`.
    Then:
        - The returned CommandResults exposes a `Common.EMAIL` indicator whose DBotScore equals
          `Common.DBotScore.SUSPICIOUS` (numeric 2). Outputs target `DeHashed.Search` with `Id` as key field.
        - Direct port of the v1 `test_email_command_suspicious_dbot_score`.
    """
    from CommonServerPython import Common
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response("email/response_two_entries_with_db.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["testgamil.com"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability="B - Usually reliable")

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.outputs_prefix == "DeHashed.Search"
    assert response.outputs_key_field == "Id"
    assert response.indicator is not None
    assert response.indicator.dbot_score.score == Common.DBotScore.SUSPICIOUS  # type: ignore[attr-defined]
    assert response.indicator.dbot_score.score == 2  # type: ignore[attr-defined]


def test_email_command_dbot_score_malicious(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A response with entries carrying `database_name` and `email_dbot_score="MALICIOUS"`.
    When:
        - Calling email_command for `testgamil.com`.
    Then:
        - The returned CommandResults exposes a `Common.EMAIL` indicator whose DBotScore equals
          `Common.DBotScore.BAD` (numeric 3, labeled "MALICIOUS" in the UI).
        - Direct port of the v1 `test_email_command_malicious_dbot_score`.
    """
    from CommonServerPython import Common
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response("email/response_two_entries_with_db.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["testgamil.com"])

    results = email_command(client, args, email_dbot_score="MALICIOUS", reliability="B - Usually reliable")

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.indicator is not None
    assert response.indicator.dbot_score.score == Common.DBotScore.BAD  # type: ignore[attr-defined]
    assert response.indicator.dbot_score.score == 3  # type: ignore[attr-defined]


@pytest.mark.parametrize(
    "fixture_path",
    [
        pytest.param("email/response_empty.json", id="no_entries"),
        pytest.param("email/response_no_breaches.json", id="entries_without_db_name"),
    ],
)
def test_email_command_dbot_score_none_when_no_breaches(
    mocker: MockerFixture, client: "DehashedClient", fixture_path: str
) -> None:
    """
    Given:
        - A response with either no entries or entries missing `database_name`.
    When:
        - Calling email_command for `testgamil.com`.
    Then:
        - The returned CommandResults still exposes a `Common.EMAIL` indicator with
          `DBotScore == NONE` (0) and the readable output equals the
          "No matching results found" message (when no transformed entries).
        - Direct port of the v1 `test_email_command_no_entries_returned`.
    """
    from CommonServerPython import Common
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response(fixture_path)
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["testgamil.com"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability="B - Usually reliable")

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.indicator is not None
    assert response.indicator.dbot_score.score == Common.DBotScore.NONE  # type: ignore[attr-defined]
    assert response.indicator.dbot_score.score == 0  # type: ignore[attr-defined]


@pytest.mark.parametrize(
    "reliability",
    [
        pytest.param("A+ - 3rd party enrichment", id="a_plus"),
        pytest.param("A - Completely reliable", id="a"),
        pytest.param("B - Usually reliable", id="b"),
        pytest.param("C - Fairly reliable", id="c"),
        pytest.param("D - Not usually reliable", id="d"),
        pytest.param("E - Unreliable", id="e"),
        pytest.param("F - Reliability cannot be judged", id="f"),
    ],
)
def test_email_command_reliability_propagated_parametrized(
    mocker: MockerFixture,
    client: "DehashedClient",
    reliability: str,
) -> None:
    """
    Given:
        - A response with breach entries and a configured source reliability string.
    When:
        - Calling email_command across all 7 reliability values.
    Then:
        - The configured reliability is propagated onto the `Common.EMAIL` indicator's DBotScore.
        - Direct port of the v1 `test_email_different_reliability` parametrize.
    """
    from CommonServerPython import DBotScoreReliability
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response("email/response_one_entry_with_db.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["testgamil.com"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability=reliability)

    expected_reliability = DBotScoreReliability.get_dbot_score_reliability_from_str(reliability)

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.indicator is not None
    assert response.indicator.dbot_score.reliability == expected_reliability  # type: ignore[attr-defined]


def test_email_command_reliability_none_omits_field(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `reliability=None`.
    When:
        - Calling email_command.
    Then:
        - The indicator's DBotScore has no reliability set (falsy).
    """
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response("email/response_one_entry_with_db.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["testgamil.com"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability=None)

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.indicator is not None
    # When reliability=None the integration code does not set the kwarg,
    # so the indicator's DBotScore.reliability should not be a valid reliability string.
    assert not getattr(response.indicator.dbot_score, "reliability", None)  # type: ignore[attr-defined]


def test_email_command_breach_description_includes_unique_sources(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - Entries with duplicate `database_name` values (`["TestBreach-A", "TestBreach-A", "TestBreach-B"]`)
          and a SUSPICIOUS-or-higher score.
    When:
        - Calling email_command.
    Then:
        - The malicious_description on the DBotScore reads
          `"Found in 2 breach(es): TestBreach-A, TestBreach-B"` — duplicates collapsed,
          sources sorted, count reflects unique sources.
    """
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response("email/response.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["target@example.com"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability="B - Usually reliable")

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.indicator is not None
    description = response.indicator.dbot_score.malicious_description  # type: ignore[attr-defined]
    assert description == "Found in 2 breach(es): TestBreach-A, TestBreach-B"


def test_email_command_email_domain_extraction(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `EmailArgs(email=["user@example.com"])`.
    When:
        - Calling email_command.
    Then:
        - The attached `Common.EMAIL` indicator's `domain == "example.com"`.
    """
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response("email/response_user_at_example.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["user@example.com"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability=None)

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.indicator is not None
    assert response.indicator.domain == "example.com"  # type: ignore[attr-defined]


def test_email_command_email_without_at_sets_no_domain(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - An email-shaped value without "@" (`["nodomain"]`) — exercises the defensive
          `if "@" in indicator_value` guard.
    When:
        - Calling email_command.
    Then:
        - The indicator's `domain is None`.
    """
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response("email/response_empty.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["nodomain"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability=None)

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.indicator is not None
    assert response.indicator.domain is None  # type: ignore[attr-defined]


def test_email_command_query_construction(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `EmailArgs(email=["a@b.co"])`.
    When:
        - Calling email_command.
    Then:
        - `client.general_search` is called with `query='email:"a@b.co"'` (the email
          value is wrapped in double quotes via `_build_search_query` with
          `operation="is"`), `regex=None`, and all other optional params set to None.
    """
    from DeHashed import email_command, EmailArgs, MAX_REQUEST_PAGE_SIZE

    mock_response = load_mock_response("email/response_one_entry_a_at_b.json")
    general_search_mock = mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["a@b.co"])

    email_command(client, args, email_dbot_score="SUSPICIOUS", reliability=None)

    general_search_mock.assert_called_once_with(
        query='email:"a@b.co"',
        page=None,
        size=MAX_REQUEST_PAGE_SIZE,
        wildcard=None,
        regex=None,
        de_dupe=None,
    )


def test_email_command_unexpected_response_raises(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `client.general_search` returns a non-dict (e.g. a list).
    When:
        - Calling email_command.
    Then:
        - Raises DemistoException containing "Got unexpected output from api".
    """
    from DeHashed import email_command, EmailArgs, DemistoException

    mocker.patch.object(client, "general_search", return_value=["bad"])

    args = EmailArgs(email=["a@b.co"])

    with pytest.raises(DemistoException, match="Got unexpected output from api"):
        email_command(client, args, email_dbot_score="SUSPICIOUS", reliability=None)


def test_email_command_dbot_score_none_when_no_breaches_readable_output(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - A response with no entries.
    When:
        - Calling email_command.
    Then:
        - The readable_output is "No matching results found" and the DBotScore is NONE.
        - Direct port of the v1 `test_email_command_no_entries_returned` (readable-output assertion).
    """
    from CommonServerPython import Common
    from DeHashed import email_command, EmailArgs

    mock_response = load_mock_response("email/response_empty.json")
    mocker.patch.object(client, "general_search", return_value=mock_response)

    args = EmailArgs(email=["testgamil.com"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability="B - Usually reliable")

    assert isinstance(results, list)
    assert len(results) == 1
    response = results[0]
    assert response.indicator is not None
    assert response.indicator.dbot_score.score == Common.DBotScore.NONE  # type: ignore[attr-defined]
    assert response.readable_output == "No matching results found for testgamil.com"


def test_email_command_multiple_emails_returns_per_email_results(mocker: MockerFixture, client: "DehashedClient") -> None:
    """
    Given:
        - `EmailArgs(email=["found@example.com", "missing@example.com"])` - one email has
          breach entries, the other has no entries.
    When:
        - Calling email_command.
    Then:
        - A list of two `CommandResults` is returned, one per email.
        - The first email has its own `Common.EMAIL` indicator with SUSPICIOUS DBotScore
          and outputs populated from `_transform_entry`.
        - The second email has its own `Common.EMAIL` indicator with NONE DBotScore and
          a per-email "No matching results found for missing@example.com" readable output.
        - `client.general_search` is called once per email with a per-email scoped query.
    """
    from CommonServerPython import Common
    from DeHashed import email_command, EmailArgs

    found_response = load_mock_response("email/response_found_multi.json")
    missing_response = load_mock_response("email/response_missing_multi.json")

    general_search_mock = mocker.patch.object(client, "general_search", side_effect=[found_response, missing_response])

    args = EmailArgs(email=["found@example.com", "missing@example.com"])

    results = email_command(client, args, email_dbot_score="SUSPICIOUS", reliability="B - Usually reliable")

    # Two CommandResults — one per email.
    assert isinstance(results, list)
    assert len(results) == 2

    # First email: breaches found.
    found = results[0]
    assert found.outputs_prefix == "DeHashed.Search"
    assert found.outputs_key_field == "Id"
    assert found.outputs is not None
    assert len(found.outputs) == 2  # type: ignore[arg-type]
    assert found.indicator is not None
    assert found.indicator.address == "found@example.com"  # type: ignore[attr-defined]
    assert found.indicator.dbot_score.indicator == "found@example.com"  # type: ignore[attr-defined]
    assert found.indicator.dbot_score.score == Common.DBotScore.SUSPICIOUS  # type: ignore[attr-defined]

    # Second email: no entries — indicator surfaced with NONE score.
    missing = results[1]
    assert missing.indicator is not None
    assert missing.indicator.address == "missing@example.com"  # type: ignore[attr-defined]
    assert missing.indicator.dbot_score.indicator == "missing@example.com"  # type: ignore[attr-defined]
    assert missing.indicator.dbot_score.score == Common.DBotScore.NONE  # type: ignore[attr-defined]
    assert missing.readable_output == "No matching results found for missing@example.com"

    # Each email queried separately.
    assert general_search_mock.call_count == 2
    first_call_kwargs = general_search_mock.call_args_list[0].kwargs
    second_call_kwargs = general_search_mock.call_args_list[1].kwargs
    assert first_call_kwargs["query"] == 'email:"found@example.com"'
    assert second_call_kwargs["query"] == 'email:"missing@example.com"'


# endregion